From 2aa4a82499d4becd2284cdb482213d541b8804dd Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Sun, 28 Apr 2024 16:29:10 +0200 Subject: Adding upstream version 86.0.1. Signed-off-by: Daniel Baumann --- browser/components/newtab/.eslintrc.js | 226 + browser/components/newtab/.nvmrc | 1 + browser/components/newtab/.sass-lint.yml | 27 + browser/components/newtab/AboutNewTabService.jsm | 512 + .../newtab/aboutwelcome/AboutWelcomeChild.jsm | 386 + .../newtab/aboutwelcome/AboutWelcomeParent.jsm | 310 + .../aboutwelcome/content/aboutwelcome.bundle.js | 1501 ++ .../newtab/aboutwelcome/content/aboutwelcome.css | 538 + .../newtab/aboutwelcome/content/aboutwelcome.html | 26 + .../aboutwelcome/lib/AboutWelcomeTelemetry.jsm | 115 + browser/components/newtab/actors/ASRouterChild.jsm | 109 + .../components/newtab/actors/ASRouterParent.jsm | 115 + .../newtab/bin/render-activity-stream-html.js | 130 + browser/components/newtab/bin/try-runner.js | 179 + browser/components/newtab/bin/vendor.js | 38 + browser/components/newtab/common/Actions.jsm | 451 + .../components/newtab/common/ActorConstants.jsm | 45 + browser/components/newtab/common/Dedupe.jsm | 38 + browser/components/newtab/common/Reducers.jsm | 834 + browser/components/newtab/components.conf | 14 + .../newtab/components/CustomElements/paragraph.js | 72 + browser/components/newtab/content-src/.eslintrc.js | 12 + .../content-src/aboutwelcome/aboutwelcome.jsx | 188 + .../content-src/aboutwelcome/aboutwelcome.scss | 673 + .../aboutwelcome/components/FxCards.jsx | 83 + .../aboutwelcome/components/HeroText.jsx | 19 + .../aboutwelcome/components/MSLocalized.jsx | 50 + .../components/MultiStageAboutWelcome.jsx | 445 + .../aboutwelcome/components/ReturnToAMO.jsx | 100 + .../aboutwelcome/components/SimpleAboutWelcome.jsx | 35 + .../content-src/aboutwelcome/components/Zap.jsx | 60 + .../newtab/content-src/activity-stream.jsx | 54 + .../newtab/content-src/asrouter/README.md | 34 + .../content-src/asrouter/asrouter-content.jsx | 326 + .../newtab/content-src/asrouter/asrouter-utils.js | 108 + .../asrouter/components/Button/Button.jsx | 32 + .../asrouter/components/Button/_Button.scss | 94 + .../ConditionalWrapper/ConditionalWrapper.jsx | 9 + .../ImpressionsWrapper/ImpressionsWrapper.jsx | 76 + .../components/ModalOverlay/ModalOverlay.jsx | 56 + .../components/ModalOverlay/_ModalOverlay.scss | 104 + .../asrouter/components/RichText/RichText.jsx | 83 + .../components/SnippetBase/SnippetBase.jsx | 121 + .../components/SnippetBase/_SnippetBase.scss | 117 + .../asrouter/docs/cfr_doorhanger_screenshot.png | Bin 0 -> 257709 bytes .../content-src/asrouter/docs/debugging-docs.md | 62 + .../content-src/asrouter/docs/debugging-guide.png | Bin 0 -> 247644 bytes .../content-src/asrouter/docs/experiment-guide.md | 52 + .../newtab/content-src/asrouter/docs/first-run.md | 9 + .../newtab/content-src/asrouter/docs/index.rst | 104 + .../asrouter/docs/message-routing-overview.png | Bin 0 -> 50250 bytes .../asrouter/docs/simple-cfr-template.rst | 37 + .../asrouter/docs/targeting-attributes.md | 828 + .../content-src/asrouter/docs/targeting-guide.md | 37 + .../asrouter/docs/telemetry-screenshot.png | Bin 0 -> 104954 bytes .../content-src/asrouter/rich-text-strings.js | 44 + .../content-src/asrouter/schemas/message-format.md | 101 + .../asrouter/schemas/message-group.schema.json | 63 + .../schemas/panel/cfr-fxa-bookmark.schema.json | 163 + .../asrouter/schemas/provider-response.schema.json | 75 + .../newtab/content-src/asrouter/template-utils.js | 21 + .../CFR/templates/CFRUrlbarChiclet.schema.json | 75 + .../CFR/templates/ExtensionDoorhanger.schema.json | 365 + .../templates/CFR/templates/InfoBar.schema.json | 96 + .../asrouter/templates/EOYSnippet/EOYSnippet.jsx | 153 + .../templates/EOYSnippet/EOYSnippet.schema.json | 159 + .../asrouter/templates/EOYSnippet/_EOYSnippet.scss | 54 + .../FXASignupSnippet/FXASignupSnippet.jsx | 38 + .../FXASignupSnippet/FXASignupSnippet.schema.json | 187 + .../asrouter/templates/FirstRun/addUtmParams.js | 30 + .../NewsletterSnippet/NewsletterSnippet.jsx | 34 + .../NewsletterSnippet.schema.json | 177 + .../OnboardingMessage/OnboardingMessage.jsx | 52 + .../OnboardingMessage.schema.json | 142 + .../ToolbarBadgeMessage.schema.json | 39 + .../OnboardingMessage/UpdateAction.schema.json | 36 + .../OnboardingMessage/WhatsNewMessage.schema.json | 97 + .../OnboardingMessage/_OnboardingMessage.scss | 131 + .../SendToDeviceSnippet/SendToDeviceSnippet.jsx | 76 + .../SendToDeviceSnippet.schema.json | 234 + .../SendToDeviceSnippet/isEmailOrPhoneNumber.js | 38 + .../SimpleBelowSearchSnippet.jsx | 133 + .../SimpleBelowSearchSnippet.schema.json | 110 + .../_SimpleBelowSearchSnippet.scss | 198 + .../templates/SimpleSnippet/SimpleSnippet.jsx | 225 + .../SimpleSnippet/SimpleSnippet.schema.json | 155 + .../templates/SimpleSnippet/_SimpleSnippet.scss | 135 + .../SubmitFormScene2Snippet.schema.json | 163 + .../SubmitFormSnippet/SubmitFormSnippet.jsx | 409 + .../SubmitFormSnippet.schema.json | 225 + .../SubmitFormSnippet/_SubmitFormSnippet.scss | 176 + .../asrouter/templates/template-manifest.jsx | 24 + .../components/A11yLinkButton/A11yLinkButton.jsx | 18 + .../components/A11yLinkButton/_A11yLinkButton.scss | 13 + .../components/ASRouterAdmin/ASRouterAdmin.jsx | 1936 ++ .../components/ASRouterAdmin/ASRouterAdmin.scss | 273 + .../components/ASRouterAdmin/SimpleHashRouter.jsx | 35 + .../newtab/content-src/components/Base/Base.jsx | 294 + .../newtab/content-src/components/Base/_Base.scss | 179 + .../newtab/content-src/components/Card/Card.jsx | 354 + .../newtab/content-src/components/Card/_Card.scss | 369 + .../newtab/content-src/components/Card/types.js | 30 + .../CollapsibleSection/CollapsibleSection.jsx | 342 + .../CollapsibleSection/_CollapsibleSection.scss | 188 + .../ComponentPerfTimer/ComponentPerfTimer.jsx | 174 + .../components/ConfirmDialog/ConfirmDialog.jsx | 103 + .../components/ConfirmDialog/_ConfirmDialog.scss | 68 + .../components/ContextMenu/ContextMenu.jsx | 179 + .../components/ContextMenu/ContextMenuButton.jsx | 72 + .../components/ContextMenu/_ContextMenu.scss | 55 + .../BackgroundsSection/BackgroundsSection.jsx | 11 + .../ContentSection/ContentSection.jsx | 277 + .../components/CustomizeMenu/CustomizeMenu.jsx | 38 + .../CustomizeMenu/ThemesSection/ThemesSection.jsx | 11 + .../components/CustomizeMenu/_CustomizeMenu.scss | 297 + .../DiscoveryStreamBase/DiscoveryStreamBase.jsx | 393 + .../DiscoveryStreamBase/_DiscoveryStreamBase.scss | 70 + .../CardGrid/CardGrid.jsx | 109 + .../CardGrid/_CardGrid.scss | 164 + .../CollectionCardGrid/CollectionCardGrid.jsx | 139 + .../CollectionCardGrid/_CollectionCardGrid.scss | 46 + .../DiscoveryStreamComponents/DSCard/DSCard.jsx | 323 + .../DiscoveryStreamComponents/DSCard/_DSCard.scss | 313 + .../DSContextFooter/DSContextFooter.jsx | 85 + .../DSContextFooter/_DSContextFooter.scss | 109 + .../DSDismiss/DSDismiss.jsx | 57 + .../DSDismiss/_DSDismiss.scss | 68 + .../DSEmptyState/DSEmptyState.jsx | 97 + .../DSEmptyState/_DSEmptyState.scss | 87 + .../DiscoveryStreamComponents/DSImage/DSImage.jsx | 157 + .../DSImage/_DSImage.scss | 23 + .../DSLinkMenu/DSLinkMenu.jsx | 90 + .../DSLinkMenu/_DSLinkMenu.scss | 33 + .../DSMessage/DSMessage.jsx | 34 + .../DSMessage/_DSMessage.scss | 45 + .../DSPrivacyModal/DSPrivacyModal.jsx | 69 + .../DSPrivacyModal/_DSPrivacyModal.scss | 48 + .../DSSignup/DSSignup.jsx | 167 + .../DSSignup/DSSignup.scss | 52 + .../DSTextPromo/DSTextPromo.jsx | 143 + .../DSTextPromo/_DSTextPromo.scss | 119 + .../DiscoveryStreamComponents/Hero/Hero.jsx | 207 + .../DiscoveryStreamComponents/Hero/_Hero.scss | 282 + .../Highlights/Highlights.jsx | 26 + .../Highlights/_Highlights.scss | 40 + .../HorizontalRule/HorizontalRule.jsx | 11 + .../HorizontalRule/_HorizontalRule.scss | 7 + .../DiscoveryStreamComponents/List/List.jsx | 221 + .../DiscoveryStreamComponents/List/_List.scss | 269 + .../Navigation/Navigation.jsx | 73 + .../Navigation/_Navigation.scss | 64 + .../SafeAnchor/SafeAnchor.jsx | 62 + .../SectionTitle/SectionTitle.jsx | 19 + .../SectionTitle/_SectionTitle.scss | 26 + .../TopSites/TopSites.jsx | 157 + .../TopSites/_TopSites.scss | 119 + .../ImpressionStats.jsx | 223 + .../_ImpressionStats.scss | 7 + .../components/ErrorBoundary/ErrorBoundary.jsx | 68 + .../components/ErrorBoundary/_ErrorBoundary.scss | 21 + .../components/FluentOrText/FluentOrText.jsx | 36 + .../content-src/components/LinkMenu/LinkMenu.jsx | 108 + .../MoreRecommendations/MoreRecommendations.jsx | 21 + .../MoreRecommendations/_MoreRecommendations.scss | 22 + .../PocketLoggedInCta/PocketLoggedInCta.jsx | 42 + .../PocketLoggedInCta/_PocketLoggedInCta.scss | 39 + .../content-src/components/Search/Search.jsx | 208 + .../content-src/components/Search/_Search.scss | 516 + .../components/SectionMenu/SectionMenu.jsx | 122 + .../content-src/components/Sections/Sections.jsx | 390 + .../content-src/components/Sections/_Sections.scss | 135 + .../components/TopSites/SearchShortcutsForm.jsx | 189 + .../content-src/components/TopSites/TopSite.jsx | 823 + .../components/TopSites/TopSiteForm.jsx | 330 + .../components/TopSites/TopSiteFormInput.jsx | 111 + .../content-src/components/TopSites/TopSites.jsx | 241 + .../components/TopSites/TopSitesConstants.js | 43 + .../content-src/components/TopSites/_TopSites.scss | 803 + .../content-src/components/Topics/Topics.jsx | 33 + .../content-src/components/Topics/_Topics.scss | 23 + .../newtab/content-src/lib/aboutwelcome-utils.js | 235 + .../components/newtab/content-src/lib/constants.js | 32 + .../content-src/lib/detect-user-session-start.js | 78 + .../newtab/content-src/lib/init-store.js | 175 + .../newtab/content-src/lib/link-menu-options.js | 276 + .../newtab/content-src/lib/perf-service.js | 104 + .../newtab/content-src/lib/screenshot-utils.js | 61 + .../newtab/content-src/lib/section-menu-options.js | 93 + .../newtab/content-src/lib/selectLayoutRender.js | 260 + .../content-src/styles/_OnboardingImages.scss | 71 + .../content-src/styles/_activity-stream.scss | 179 + .../newtab/content-src/styles/_icons.scss | 208 + .../newtab/content-src/styles/_mixins.scss | 50 + .../newtab/content-src/styles/_normalize.scss | 29 + .../newtab/content-src/styles/_theme.scss | 231 + .../newtab/content-src/styles/_variables.scss | 323 + .../content-src/styles/activity-stream-linux.scss | 13 + .../content-src/styles/activity-stream-mac.scss | 17 + .../styles/activity-stream-windows.scss | 13 + browser/components/newtab/contributing.md | 154 + .../newtab/css/activity-stream-linux.css | 4545 ++++ .../components/newtab/css/activity-stream-mac.css | 4548 ++++ .../newtab/css/activity-stream-windows.css | 4545 ++++ .../data/content/abouthomecache/page.html.template | 43 + .../data/content/abouthomecache/script.js.template | 19 + .../newtab/data/content/activity-stream.bundle.js | 15952 ++++++++++++ .../data/content/assets/cfr_enhancer_youtube.png | Bin 0 -> 1387 bytes .../data/content/assets/cfr_fb_container.png | Bin 0 -> 3345 bytes .../data/content/assets/cfr_google_translate.png | Bin 0 -> 1722 bytes .../data/content/assets/cfr_pinnedtab_animated.png | Bin 0 -> 95908 bytes .../content/assets/cfr_pinnedtab_animated@2x.png | Bin 0 -> 191893 bytes .../assets/cfr_pinnedtab_animated_darktheme.png | Bin 0 -> 99064 bytes .../assets/cfr_pinnedtab_animated_darktheme@2x.png | Bin 0 -> 198663 bytes .../data/content/assets/cfr_pinnedtab_static.png | Bin 0 -> 1936 bytes .../content/assets/cfr_pinnedtab_static@2x.png | Bin 0 -> 3799 bytes .../data/content/assets/cfr_reddit_enhancement.png | Bin 0 -> 6508 bytes .../newtab/data/content/assets/cfr_wiki_search.png | Bin 0 -> 1258 bytes .../data/content/assets/firefox-protections.svg | 4 + .../newtab/data/content/assets/glyph-add-16.svg | 4 + .../newtab/data/content/assets/glyph-arrow.svg | 4 + .../content/assets/glyph-arrowhead-down-12.svg | 4 + .../content/assets/glyph-arrowhead-down-16.svg | 4 + .../newtab/data/content/assets/glyph-cancel-16.svg | 4 + .../data/content/assets/glyph-caret-right.svg | 4 + .../data/content/assets/glyph-cfr-feature-16.svg | 4 + .../newtab/data/content/assets/glyph-delete-16.svg | 4 + .../data/content/assets/glyph-dismiss-16.svg | 4 + .../newtab/data/content/assets/glyph-edit-16.svg | 4 + .../newtab/data/content/assets/glyph-help-24.svg | 4 + .../data/content/assets/glyph-highlights-16.svg | 4 + .../newtab/data/content/assets/glyph-info-16.svg | 4 + .../newtab/data/content/assets/glyph-mail-16.svg | 4 + .../data/content/assets/glyph-maximize-16.svg | 4 + .../data/content/assets/glyph-minimize-16.svg | 4 + .../data/content/assets/glyph-modal-delete-32.svg | 4 + .../data/content/assets/glyph-newWindow-16.svg | 4 + .../data/content/assets/glyph-open-file-16.svg | 4 + .../newtab/data/content/assets/glyph-pause-12.svg | 4 + .../newtab/data/content/assets/glyph-pin-12.svg | 4 + .../newtab/data/content/assets/glyph-pin-16.svg | 4 + .../newtab/data/content/assets/glyph-play-12.svg | 4 + .../newtab/data/content/assets/glyph-playhead.svg | 6 + .../newtab/data/content/assets/glyph-pocket-16.svg | 4 + .../content/assets/glyph-pocket-archive-16.svg | 4 + .../data/content/assets/glyph-pocket-delete-16.svg | 4 + .../data/content/assets/glyph-pocket-save-16.svg | 4 + .../newtab/data/content/assets/glyph-search-16.svg | 4 + .../newtab/data/content/assets/glyph-star-17.svg | 4 + .../data/content/assets/glyph-topsites-16.svg | 4 + .../data/content/assets/glyph-trending-16.svg | 4 + .../newtab/data/content/assets/glyph-unpin-16.svg | 4 + .../data/content/assets/glyph-webextension-16.svg | 4 + .../data/content/assets/icon-removed-bookmark.svg | 4 + .../data/content/assets/illustration-addons@2x.png | Bin 0 -> 20104 bytes .../data/content/assets/illustration-gift@2x.png | Bin 0 -> 14115 bytes .../assets/illustration-privatebrowsing@2x.png | Bin 0 -> 16627 bytes .../content/assets/illustration-screenshots@2x.png | Bin 0 -> 10990 bytes .../data/content/assets/illustration-sync@2x.png | Bin 0 -> 12047 bytes .../newtab/data/content/assets/long-zap.svg | 4 + .../data/content/assets/protection-report-icon.png | Bin 0 -> 4309 bytes .../content/assets/remote/pin-to-taskbar-v1.svg | 4 + .../content/assets/remote/pin-to-taskbar-v2.svg | 116 + .../content/assets/remote/pip-message-icon.svg | 4 + .../newtab/data/content/assets/short-zap.svg | 4 + .../newtab/data/content/assets/spinner.svg | 4 + .../data/content/assets/topic-show-more-12.svg | 4 + .../content/assets/trailhead/card-illo-devices.svg | 5 + .../content/assets/trailhead/card-illo-fbcont.svg | 5 + .../assets/trailhead/card-illo-ffmonitor.svg | 5 + .../content/assets/trailhead/card-illo-ffsend.svg | 5 + .../content/assets/trailhead/card-illo-import.svg | 4 + .../assets/trailhead/card-illo-lockwise.svg | 5 + .../content/assets/trailhead/card-illo-mobile.svg | 5 + .../content/assets/trailhead/card-illo-pledge.svg | 5 + .../content/assets/trailhead/card-illo-pocket.svg | 5 + .../content/assets/trailhead/card-illo-private.svg | 5 + .../content/assets/trailhead/card-illo-sendtab.svg | 5 + .../assets/trailhead/card-illo-tracking.svg | 5 + .../data/content/assets/whatsnew-send-icon.png | Bin 0 -> 2038 bytes .../newtab/data/content/newtab-render.js | 11 + .../data/content/tippytop/favicons/adidas.png | Bin 0 -> 3226 bytes .../content/tippytop/favicons/aliexpress-com.ico | Bin 0 -> 4286 bytes .../data/content/tippytop/favicons/allegro-pl.ico | Bin 0 -> 1150 bytes .../data/content/tippytop/favicons/amazon.ico | Bin 0 -> 1407 bytes .../data/content/tippytop/favicons/avito-ru.ico | Bin 0 -> 5430 bytes .../data/content/tippytop/favicons/baidu-com.png | Bin 0 -> 1983 bytes .../data/content/tippytop/favicons/bbc-uk.ico | Bin 0 -> 958 bytes .../data/content/tippytop/favicons/bing-com.ico | Bin 0 -> 3638 bytes .../data/content/tippytop/favicons/ctrip-com.ico | Bin 0 -> 1150 bytes .../content/tippytop/favicons/duckduckgo-com.ico | Bin 0 -> 5430 bytes .../newtab/data/content/tippytop/favicons/ebay.ico | Bin 0 -> 1455 bytes .../newtab/data/content/tippytop/favicons/etsy.ico | Bin 0 -> 4286 bytes .../content/tippytop/favicons/facebook-com.ico | Bin 0 -> 5430 bytes .../data/content/tippytop/favicons/geico.png | Bin 0 -> 1472 bytes .../data/content/tippytop/favicons/google-com.ico | Bin 0 -> 5430 bytes .../data/content/tippytop/favicons/hrblock.ico | Bin 0 -> 3950 bytes .../data/content/tippytop/favicons/ifeng-com.ico | Bin 0 -> 4038 bytes .../data/content/tippytop/favicons/iqiyi-com.ico | Bin 0 -> 5430 bytes .../content/tippytop/favicons/leboncoin-fr.png | Bin 0 -> 454 bytes .../newtab/data/content/tippytop/favicons/nike.ico | Bin 0 -> 1150 bytes .../data/content/tippytop/favicons/ok-ru.ico | Bin 0 -> 5430 bytes .../data/content/tippytop/favicons/olx-pl.ico | Bin 0 -> 5430 bytes .../data/content/tippytop/favicons/reddit-com.png | Bin 0 -> 2094 bytes .../data/content/tippytop/favicons/samsung.ico | Bin 0 -> 4286 bytes .../data/content/tippytop/favicons/turbotax.png | Bin 0 -> 3744 bytes .../data/content/tippytop/favicons/twitter-com.ico | Bin 0 -> 1650 bytes .../data/content/tippytop/favicons/vk-com.ico | Bin 0 -> 302 bytes .../data/content/tippytop/favicons/vodafone.png | Bin 0 -> 1757 bytes .../data/content/tippytop/favicons/weibo-com.ico | Bin 0 -> 10134 bytes .../content/tippytop/favicons/wikipedia-org.ico | Bin 0 -> 2734 bytes .../newtab/data/content/tippytop/favicons/wix.ico | Bin 0 -> 1061 bytes .../data/content/tippytop/favicons/wykop-pl.png | Bin 0 -> 1705 bytes .../data/content/tippytop/favicons/yandex-com.png | Bin 0 -> 826 bytes .../data/content/tippytop/favicons/yandex-ru.png | Bin 0 -> 850 bytes .../data/content/tippytop/favicons/youtube-com.png | Bin 0 -> 348 bytes .../data/content/tippytop/favicons/zhihu-com.ico | Bin 0 -> 6518 bytes .../data/content/tippytop/images/adidas@2x.png | Bin 0 -> 5448 bytes .../content/tippytop/images/aliexpress-com@2x.png | Bin 0 -> 12459 bytes .../data/content/tippytop/images/allegro-pl@2x.png | Bin 0 -> 5041 bytes .../data/content/tippytop/images/amazon@2x.png | Bin 0 -> 6061 bytes .../data/content/tippytop/images/avito-ru@2x.png | Bin 0 -> 1568 bytes .../data/content/tippytop/images/baidu-com@2x.png | Bin 0 -> 8198 bytes .../data/content/tippytop/images/bbc-uk@2x.png | Bin 0 -> 18207 bytes .../data/content/tippytop/images/bing-com@2x.png | Bin 0 -> 2875 bytes .../data/content/tippytop/images/ctrip-com@2x.png | Bin 0 -> 15862 bytes .../content/tippytop/images/duckduckgo-com@2x.png | Bin 0 -> 4391 bytes .../data/content/tippytop/images/ebay@2x.png | Bin 0 -> 4665 bytes .../data/content/tippytop/images/etsy@2x.jpg | Bin 0 -> 4094 bytes .../content/tippytop/images/facebook-com@2x.png | Bin 0 -> 10780 bytes .../data/content/tippytop/images/geico@2x.jpg | Bin 0 -> 11834 bytes .../data/content/tippytop/images/google-com@2x.png | Bin 0 -> 3035 bytes .../data/content/tippytop/images/hrblock@2x.png | Bin 0 -> 4642 bytes .../data/content/tippytop/images/ifeng-com@2x.png | Bin 0 -> 22282 bytes .../data/content/tippytop/images/iqiyi-com@2x.png | Bin 0 -> 14340 bytes .../content/tippytop/images/leboncoin-fr@2x.png | Bin 0 -> 7146 bytes .../data/content/tippytop/images/nike@2x.jpg | Bin 0 -> 5163 bytes .../data/content/tippytop/images/ok-ru@2x.png | Bin 0 -> 2526 bytes .../data/content/tippytop/images/olx-pl@2x.png | Bin 0 -> 5287 bytes .../data/content/tippytop/images/reddit-com@2x.png | Bin 0 -> 5180 bytes .../data/content/tippytop/images/samsung@2x.jpg | Bin 0 -> 3347 bytes .../data/content/tippytop/images/turbotax@2x.jpg | Bin 0 -> 11930 bytes .../content/tippytop/images/twitter-com@2x.png | Bin 0 -> 1260 bytes .../data/content/tippytop/images/vk-com@2x.png | Bin 0 -> 9897 bytes .../data/content/tippytop/images/vodafone@2x.jpg | Bin 0 -> 7050 bytes .../data/content/tippytop/images/weibo-com@2x.png | Bin 0 -> 15507 bytes .../content/tippytop/images/wikipedia-org@2x.png | Bin 0 -> 19001 bytes .../newtab/data/content/tippytop/images/wix@2x.jpg | Bin 0 -> 8714 bytes .../data/content/tippytop/images/wykop-pl@2x.png | Bin 0 -> 4415 bytes .../data/content/tippytop/images/yandex-com@2x.png | Bin 0 -> 1708 bytes .../data/content/tippytop/images/yandex-ru@2x.png | Bin 0 -> 1647 bytes .../content/tippytop/images/youtube-com@2x.png | Bin 0 -> 2924 bytes .../data/content/tippytop/images/zhihu-com@2x.png | Bin 0 -> 10225 bytes .../newtab/data/content/tippytop/top_sites.json | 182 + browser/components/newtab/docs/index.rst | 99 + .../v2-system-addon/about_home_startup_cache.md | 86 + .../newtab/docs/v2-system-addon/data_dictionary.md | 341 + .../newtab/docs/v2-system-addon/data_events.md | 1144 + .../newtab/docs/v2-system-addon/geo_locale.md | 23 + .../newtab/docs/v2-system-addon/mochitests.md | 28 + .../newtab/docs/v2-system-addon/preferences.md | 282 + .../newtab/docs/v2-system-addon/remote_cfr.md | 63 + .../newtab/docs/v2-system-addon/sections.md | 82 + .../newtab/docs/v2-system-addon/telemetry.md | 13 + .../newtab/docs/v2-system-addon/tippytop.md | 40 + .../docs/v2-system-addon/unit_testing_guide.md | 149 + browser/components/newtab/jar.mn | 44 + browser/components/newtab/karma.mc.config.js | 299 + browser/components/newtab/lib/ASRouter.jsm | 1753 ++ .../newtab/lib/ASRouterDefaultConfig.jsm | 65 + .../components/newtab/lib/ASRouterNewTabHook.jsm | 122 + .../lib/ASRouterParentProcessMessageHandler.jsm | 161 + .../components/newtab/lib/ASRouterPreferences.jsm | 228 + .../components/newtab/lib/ASRouterTargeting.jsm | 837 + .../newtab/lib/ASRouterTriggerListeners.jsm | 594 + browser/components/newtab/lib/AboutPreferences.jsm | 334 + browser/components/newtab/lib/ActivityStream.jsm | 842 + .../newtab/lib/ActivityStreamMessageChannel.jsm | 344 + .../components/newtab/lib/ActivityStreamPrefs.jsm | 95 + .../newtab/lib/ActivityStreamStorage.jsm | 121 + browser/components/newtab/lib/BookmarkPanelHub.jsm | 332 + .../components/newtab/lib/CFRMessageProvider.jsm | 1076 + browser/components/newtab/lib/CFRPageActions.jsm | 1194 + browser/components/newtab/lib/DefaultSites.jsm | 50 + .../components/newtab/lib/DiscoveryStreamFeed.jsm | 1945 ++ browser/components/newtab/lib/DownloadsManager.jsm | 194 + browser/components/newtab/lib/FaviconFeed.jsm | 213 + browser/components/newtab/lib/FilterAdult.jsm | 2980 +++ browser/components/newtab/lib/HighlightsFeed.jsm | 361 + browser/components/newtab/lib/InfoBar.jsm | 158 + browser/components/newtab/lib/LinksCache.jsm | 136 + browser/components/newtab/lib/MomentsPageHub.jsm | 179 + browser/components/newtab/lib/NewTabInit.jsm | 57 + .../newtab/lib/OnboardingMessageProvider.jsm | 92 + .../components/newtab/lib/PanelTestProvider.jsm | 355 + browser/components/newtab/lib/PersistentCache.jsm | 108 + .../PersonalityProvider/NaiveBayesTextTagger.jsm | 67 + .../lib/PersonalityProvider/NmfTextTagger.jsm | 65 + .../PersonalityProvider/PersonalityProvider.jsm | 315 + .../PersonalityProviderWorker.js | 37 + .../PersonalityProviderWorkerClass.jsm | 279 + .../lib/PersonalityProvider/RecipeExecutor.jsm | 1126 + .../newtab/lib/PersonalityProvider/Tokenize.jsm | 89 + browser/components/newtab/lib/PlacesFeed.jsm | 568 + browser/components/newtab/lib/PrefsFeed.jsm | 291 + .../newtab/lib/RecommendationProviderSwitcher.jsm | 195 + browser/components/newtab/lib/RemoteL10n.jsm | 248 + browser/components/newtab/lib/Screenshots.jsm | 151 + browser/components/newtab/lib/SearchShortcuts.jsm | 82 + browser/components/newtab/lib/SectionsManager.jsm | 727 + browser/components/newtab/lib/ShortURL.jsm | 84 + browser/components/newtab/lib/SiteClassifier.jsm | 99 + .../newtab/lib/SnippetsTestMessageProvider.jsm | 721 + browser/components/newtab/lib/Store.jsm | 190 + browser/components/newtab/lib/SystemTickFeed.jsm | 45 + browser/components/newtab/lib/TelemetryFeed.jsm | 1112 + browser/components/newtab/lib/TippyTopProvider.jsm | 72 + browser/components/newtab/lib/ToolbarBadgeHub.jsm | 316 + browser/components/newtab/lib/ToolbarPanelHub.jsm | 631 + browser/components/newtab/lib/TopSitesFeed.jsm | 1174 + browser/components/newtab/lib/TopStoriesFeed.jsm | 929 + browser/components/newtab/lib/UTEventReporting.jsm | 68 + .../newtab/lib/UserDomainAffinityProvider.jsm | 390 + browser/components/newtab/lib/cache-worker.js | 193 + browser/components/newtab/loaders/inject-loader.js | 57 + browser/components/newtab/moz.build | 43 + .../components/newtab/nsIAboutNewTabService.idl | 39 + browser/components/newtab/package-lock.json | 11820 +++++++++ browser/components/newtab/package.json | 126 + .../newtab/prerendered/activity-stream-debug.html | 33 + .../prerendered/activity-stream-noscripts.html | 22 + .../newtab/prerendered/activity-stream.html | 33 + browser/components/newtab/test/.eslintrc.js | 19 + .../newtab/test/browser/abouthomecache/browser.ini | 35 + .../abouthomecache/browser_basic_endtoend.js | 22 + .../browser/abouthomecache/browser_bump_version.js | 29 + .../browser/abouthomecache/browser_disabled.js | 97 + .../abouthomecache/browser_locale_change.js | 23 + .../browser/abouthomecache/browser_no_cache.js | 21 + .../abouthomecache/browser_no_startup_actions.js | 79 + .../abouthomecache/browser_overwrite_cache.js | 38 + .../abouthomecache/browser_process_crash.js | 81 + .../abouthomecache/browser_same_consumer.js | 52 + .../browser/abouthomecache/browser_sanitize.js | 47 + .../abouthomecache/browser_shutdown_timeout.js | 45 + .../newtab/test/browser/abouthomecache/head.js | 325 + .../components/newtab/test/browser/blue_page.html | 6 + browser/components/newtab/test/browser/browser.ini | 58 + .../test/browser/browser_aboutwelcome_actors.js | 237 + .../browser/browser_aboutwelcome_attribution.js | 113 + .../browser/browser_aboutwelcome_multistage.js | 779 + .../test/browser/browser_aboutwelcome_observer.js | 76 + .../test/browser/browser_aboutwelcome_rtamo.js | 193 + .../browser/browser_aboutwelcome_simplified.js | 77 + .../test/browser/browser_as_load_location.js | 44 + .../newtab/test/browser/browser_as_render.js | 83 + .../test/browser/browser_asrouter_bookmarkpanel.js | 94 + .../newtab/test/browser/browser_asrouter_cfr.js | 1134 + .../browser_asrouter_experimentsAPILoader.js | 268 + .../browser/browser_asrouter_group_frequency.js | 188 + .../browser/browser_asrouter_group_userprefs.js | 156 + .../test/browser/browser_asrouter_infobar.js | 85 + .../browser/browser_asrouter_momentspagehub.js | 113 + .../test/browser/browser_asrouter_snippets.js | 193 + .../test/browser/browser_asrouter_targeting.js | 1137 + .../test/browser/browser_asrouter_toolbarbadge.js | 143 + .../test/browser/browser_asrouter_whatsnewpanel.js | 99 + .../test/browser/browser_context_menu_item.js | 29 + .../test/browser/browser_customize_menu_content.js | 243 + .../test/browser/browser_customize_menu_render.js | 183 + .../newtab/test/browser/browser_discovery_card.js | 44 + .../test/browser/browser_discovery_render.js | 32 + .../test/browser/browser_discovery_styles.js | 171 + .../test/browser/browser_enabled_newtabpage.js | 25 + .../newtab/test/browser/browser_getScreenshots.js | 90 + .../test/browser/browser_highlights_section.js | 101 + .../test/browser/browser_newtab_experiment_api.js | 90 + .../newtab/test/browser/browser_newtab_header.js | 296 + .../test/browser/browser_newtab_overrides.js | 138 + .../newtab/test/browser/browser_open_tab_focus.js | 37 + .../browser_topsites_contextMenu_options.js | 129 + .../test/browser/browser_topsites_section.js | 213 + .../test/browser/browser_trigger_listeners.js | 65 + .../components/newtab/test/browser/ds_layout.json | 89 + browser/components/newtab/test/browser/head.js | 256 + .../components/newtab/test/browser/red_page.html | 6 + .../components/newtab/test/browser/snippet.json | 25 + .../test/browser/snippet_below_search_test.json | 20 + .../newtab/test/browser/snippet_simple_test.json | 24 + .../components/newtab/test/browser/topstories.json | 1 + browser/components/newtab/test/schemas/pings.js | 317 + .../aboutwelcome/MultiStageAboutWelcome.test.jsx | 186 + .../newtab/test/unit/asrouter/ASRouter.test.js | 2898 +++ .../test/unit/asrouter/ASRouterChild.test.js | 74 + .../test/unit/asrouter/ASRouterNewTabHook.test.js | 151 + .../test/unit/asrouter/ASRouterParent.test.js | 103 + .../ASRouterParentProcessMessageHandler.test.js | 418 + .../test/unit/asrouter/ASRouterPreferences.test.js | 377 + .../test/unit/asrouter/ASRouterTargeting.test.js | 546 + .../unit/asrouter/ASRouterTriggerListeners.test.js | 506 + .../test/unit/asrouter/CFRMessageProvider.test.js | 46 + .../test/unit/asrouter/CFRPageActions.test.js | 1241 + .../test/unit/asrouter/MessageLoaderUtils.test.js | 459 + .../test/unit/asrouter/ModalOverlay.test.jsx | 69 + .../test/unit/asrouter/PanelTestProvider.test.js | 42 + .../newtab/test/unit/asrouter/RemoteL10n.test.js | 165 + .../newtab/test/unit/asrouter/RichText.test.jsx | 81 + .../asrouter/SnippetsTestMessageProvider.test.js | 43 + .../test/unit/asrouter/TargetingDocs.test.js | 92 + .../test/unit/asrouter/asrouter-content.test.jsx | 516 + .../test/unit/asrouter/asrouter-utils.test.js | 100 + .../compatibility-reference/fx57-compat.test.js | 26 + .../compatibility-reference/snippets-fx57.js | 126 + .../newtab/test/unit/asrouter/constants.js | 166 + .../schemas/panel/cfr-fxa-bookmark.schema.test.js | 28 + .../test/unit/asrouter/template-utils.test.js | 31 + .../unit/asrouter/templates/EOYSnippet.test.jsx | 194 + .../templates/ExtensionDoorhanger.test.jsx | 98 + .../asrouter/templates/FXASignupSnippet.test.jsx | 84 + .../asrouter/templates/NewsletterSnippet.test.jsx | 86 + .../asrouter/templates/OnboardingMessage.test.jsx | 80 + .../templates/SendToDeviceSnippet.test.jsx | 252 + .../templates/SimpleBelowSearchSnippet.test.jsx | 62 + .../unit/asrouter/templates/SimpleSnippet.test.jsx | 255 + .../asrouter/templates/SubmitFormSnippet.test.jsx | 341 + .../templates/isEmailOrPhoneNumber.test.js | 56 + .../newtab/test/unit/common/Actions.test.js | 241 + .../newtab/test/unit/common/Dedupe.test.js | 38 + .../newtab/test/unit/common/Reducers.test.js | 1560 ++ .../content-src/components/ASRouterAdmin.test.jsx | 610 + .../test/unit/content-src/components/Base.test.jsx | 147 + .../test/unit/content-src/components/Card.test.jsx | 507 + .../components/CollapsibleSection.test.jsx | 224 + .../components/ComponentPerfTimer.test.jsx | 444 + .../content-src/components/ConfirmDialog.test.jsx | 179 + .../content-src/components/ContextMenu.test.jsx | 230 + .../content-src/components/CustomiseMenu.test.jsx | 76 + .../components/DiscoveryStreamBase.test.jsx | 377 + .../DiscoveryStreamComponents/CardGrid.test.jsx | 40 + .../CollectionCardGrid.test.jsx | 149 + .../DiscoveryStreamComponents/DSCard.test.jsx | 373 + .../DSContextFooter.test.jsx | 145 + .../DiscoveryStreamComponents/DSDismiss.test.jsx | 51 + .../DSEmptyState.test.jsx | 73 + .../DiscoveryStreamComponents/DSImage.test.jsx | 127 + .../DiscoveryStreamComponents/DSLinkMenu.test.jsx | 171 + .../DiscoveryStreamComponents/DSMessage.test.jsx | 75 + .../DSPrivacyModal.test.jsx | 50 + .../DiscoveryStreamComponents/DSSignup.test.jsx | 92 + .../DiscoveryStreamComponents/DSTextPromo.test.jsx | 93 + .../DiscoveryStreamComponents/Hero.test.jsx | 179 + .../DiscoveryStreamComponents/Highlights.test.jsx | 41 + .../HorizontalRule.test.jsx | 16 + .../ImpressionStats.test.jsx | 252 + .../DiscoveryStreamComponents/List.test.jsx | 278 + .../DiscoveryStreamComponents/Navigation.test.jsx | 119 + .../DiscoveryStreamComponents/SafeAnchor.test.jsx | 56 + .../SectionTitle.test.jsx | 22 + .../DiscoveryStreamComponents/TopSites.test.jsx | 247 + .../content-src/components/ErrorBoundary.test.jsx | 110 + .../content-src/components/FluentOrText.test.jsx | 66 + .../unit/content-src/components/LinkMenu.test.jsx | 523 + .../content-src/components/MSLocalized.test.jsx | 46 + .../components/MoreRecommendations.test.jsx | 24 + .../components/PocketLoggedInCta.test.jsx | 46 + .../unit/content-src/components/Search.test.jsx | 176 + .../content-src/components/SectionMenu.test.jsx | 273 + .../unit/content-src/components/Sections.test.jsx | 633 + .../unit/content-src/components/TopSites.test.jsx | 1833 ++ .../TopSites/SearchShortcutsForm.test.jsx | 60 + .../unit/content-src/components/Topics.test.jsx | 22 + .../content-src/components/addUtmParams.test.js | 28 + .../lib/detect-user-session-start.test.js | 117 + .../test/unit/content-src/lib/init-store.test.js | 204 + .../test/unit/content-src/lib/perf-service.test.js | 89 + .../unit/content-src/lib/screenshot-utils.test.js | 147 + .../content-src/lib/selectLayoutRender.test.js | 605 + .../newtab/test/unit/lib/AboutPreferences.test.js | 423 + .../newtab/test/unit/lib/ActivityStream.test.js | 531 + .../unit/lib/ActivityStreamMessageChannel.test.js | 508 + .../test/unit/lib/ActivityStreamPrefs.test.js | 113 + .../test/unit/lib/ActivityStreamStorage.test.js | 161 + .../newtab/test/unit/lib/BookmarkPanelHub.test.js | 514 + .../test/unit/lib/DiscoveryStreamFeed.test.js | 2915 +++ .../newtab/test/unit/lib/DownloadsManager.test.js | 373 + .../newtab/test/unit/lib/FaviconFeed.test.js | 233 + .../newtab/test/unit/lib/FilterAdult.test.js | 54 + .../newtab/test/unit/lib/HighlightsFeed.test.js | 819 + .../newtab/test/unit/lib/LinksCache.test.js | 16 + .../newtab/test/unit/lib/MomentsPageHub.test.js | 336 + .../newtab/test/unit/lib/NewTabInit.test.js | 78 + .../newtab/test/unit/lib/PersistentCache.test.js | 131 + .../NaiveBayesTextTagger.test.js | 109 + .../lib/PersonalityProvider/NmfTextTagger.test.js | 1083 + .../PersonalityProvider.test.js | 389 + .../PersonalityProviderWorkerClass.test.js | 450 + .../lib/PersonalityProvider/RecipeExecutor.test.js | 1547 ++ .../unit/lib/PersonalityProvider/Tokenize.test.js | 134 + .../newtab/test/unit/lib/PlacesFeed.test.js | 1139 + .../newtab/test/unit/lib/PrefsFeed.test.js | 248 + .../lib/RecommendationProviderSwitcher.test.js | 243 + .../newtab/test/unit/lib/Screenshots.test.js | 209 + .../newtab/test/unit/lib/SectionsManager.test.js | 884 + .../newtab/test/unit/lib/ShortUrl.test.js | 104 + .../newtab/test/unit/lib/SiteClassifier.test.js | 252 + .../components/newtab/test/unit/lib/Store.test.js | 307 + .../newtab/test/unit/lib/SystemTickFeed.test.js | 46 + .../newtab/test/unit/lib/TelemetryFeed.test.js | 1816 ++ .../newtab/test/unit/lib/TippyTopProvider.test.js | 121 + .../newtab/test/unit/lib/ToolbarBadgeHub.test.js | 651 + .../newtab/test/unit/lib/ToolbarPanelHub.test.js | 927 + .../newtab/test/unit/lib/TopSitesFeed.test.js | 2015 ++ .../newtab/test/unit/lib/TopStoriesFeed.test.js | 2252 ++ .../newtab/test/unit/lib/UTEventReporting.test.js | 115 + .../unit/lib/UserDomainAffinityProvider.test.js | 256 + browser/components/newtab/test/unit/unit-entry.js | 482 + browser/components/newtab/test/unit/utils.js | 263 + .../components/newtab/test/xpcshell/ds_layout.json | 89 + browser/components/newtab/test/xpcshell/head.js | 11 + .../xpcshell/test_ASRouterTargeting_attribution.js | 64 + .../xpcshell/test_AboutHomeStartupCacheChild.js | 33 + .../xpcshell/test_AboutHomeStartupCacheWorker.js | 239 + .../newtab/test/xpcshell/test_AboutNewTab.js | 359 + .../test/xpcshell/test_AboutWelcomeAttribution.js | 38 + .../test/xpcshell/test_AboutWelcomeTelemetry.js | 87 + .../newtab/test/xpcshell/topstories.json | 1 + .../components/newtab/test/xpcshell/xpcshell.ini | 19 + .../components/newtab/vendor/PROP_TYPES_LICENSE | 21 + .../newtab/vendor/REACT_AND_REACT_DOM_LICENSE | 21 + .../components/newtab/vendor/REACT_REDUX_LICENSE | 21 + .../newtab/vendor/REACT_TRANSITION_GROUP_LICENSE | 30 + browser/components/newtab/vendor/REDUX_LICENSE | 21 + browser/components/newtab/vendor/Redux.jsm | 690 + browser/components/newtab/vendor/prop-types.js | 1 + browser/components/newtab/vendor/react-dev.js | 3318 +++ browser/components/newtab/vendor/react-dom-dev.js | 25147 +++++++++++++++++++ .../components/newtab/vendor/react-dom-server.js | 45 + browser/components/newtab/vendor/react-dom.js | 239 + browser/components/newtab/vendor/react-redux.js | 1 + .../newtab/vendor/react-transition-group.js | 1 + browser/components/newtab/vendor/react.js | 32 + browser/components/newtab/vendor/redux.js | 948 + .../newtab/webpack.aboutwelcome.config.js | 24 + .../newtab/webpack.system-addon.config.js | 72 + browser/components/newtab/yamscripts.yml | 63 + 644 files changed, 187626 insertions(+) create mode 100644 browser/components/newtab/.eslintrc.js create mode 100644 browser/components/newtab/.nvmrc create mode 100644 browser/components/newtab/.sass-lint.yml create mode 100644 browser/components/newtab/AboutNewTabService.jsm create mode 100644 browser/components/newtab/aboutwelcome/AboutWelcomeChild.jsm create mode 100644 browser/components/newtab/aboutwelcome/AboutWelcomeParent.jsm create mode 100644 browser/components/newtab/aboutwelcome/content/aboutwelcome.bundle.js create mode 100644 browser/components/newtab/aboutwelcome/content/aboutwelcome.css create mode 100644 browser/components/newtab/aboutwelcome/content/aboutwelcome.html create mode 100644 browser/components/newtab/aboutwelcome/lib/AboutWelcomeTelemetry.jsm create mode 100644 browser/components/newtab/actors/ASRouterChild.jsm create mode 100644 browser/components/newtab/actors/ASRouterParent.jsm create mode 100644 browser/components/newtab/bin/render-activity-stream-html.js create mode 100644 browser/components/newtab/bin/try-runner.js create mode 100644 browser/components/newtab/bin/vendor.js create mode 100644 browser/components/newtab/common/Actions.jsm create mode 100644 browser/components/newtab/common/ActorConstants.jsm create mode 100644 browser/components/newtab/common/Dedupe.jsm create mode 100644 browser/components/newtab/common/Reducers.jsm create mode 100644 browser/components/newtab/components.conf create mode 100644 browser/components/newtab/components/CustomElements/paragraph.js create mode 100644 browser/components/newtab/content-src/.eslintrc.js create mode 100644 browser/components/newtab/content-src/aboutwelcome/aboutwelcome.jsx create mode 100644 browser/components/newtab/content-src/aboutwelcome/aboutwelcome.scss create mode 100644 browser/components/newtab/content-src/aboutwelcome/components/FxCards.jsx create mode 100644 browser/components/newtab/content-src/aboutwelcome/components/HeroText.jsx create mode 100644 browser/components/newtab/content-src/aboutwelcome/components/MSLocalized.jsx create mode 100644 browser/components/newtab/content-src/aboutwelcome/components/MultiStageAboutWelcome.jsx create mode 100644 browser/components/newtab/content-src/aboutwelcome/components/ReturnToAMO.jsx create mode 100644 browser/components/newtab/content-src/aboutwelcome/components/SimpleAboutWelcome.jsx create mode 100644 browser/components/newtab/content-src/aboutwelcome/components/Zap.jsx create mode 100644 browser/components/newtab/content-src/activity-stream.jsx create mode 100644 browser/components/newtab/content-src/asrouter/README.md create mode 100644 browser/components/newtab/content-src/asrouter/asrouter-content.jsx create mode 100644 browser/components/newtab/content-src/asrouter/asrouter-utils.js create mode 100644 browser/components/newtab/content-src/asrouter/components/Button/Button.jsx create mode 100644 browser/components/newtab/content-src/asrouter/components/Button/_Button.scss create mode 100644 browser/components/newtab/content-src/asrouter/components/ConditionalWrapper/ConditionalWrapper.jsx create mode 100644 browser/components/newtab/content-src/asrouter/components/ImpressionsWrapper/ImpressionsWrapper.jsx create mode 100644 browser/components/newtab/content-src/asrouter/components/ModalOverlay/ModalOverlay.jsx create mode 100644 browser/components/newtab/content-src/asrouter/components/ModalOverlay/_ModalOverlay.scss create mode 100644 browser/components/newtab/content-src/asrouter/components/RichText/RichText.jsx create mode 100644 browser/components/newtab/content-src/asrouter/components/SnippetBase/SnippetBase.jsx create mode 100644 browser/components/newtab/content-src/asrouter/components/SnippetBase/_SnippetBase.scss create mode 100644 browser/components/newtab/content-src/asrouter/docs/cfr_doorhanger_screenshot.png create mode 100644 browser/components/newtab/content-src/asrouter/docs/debugging-docs.md create mode 100644 browser/components/newtab/content-src/asrouter/docs/debugging-guide.png create mode 100644 browser/components/newtab/content-src/asrouter/docs/experiment-guide.md create mode 100644 browser/components/newtab/content-src/asrouter/docs/first-run.md create mode 100644 browser/components/newtab/content-src/asrouter/docs/index.rst create mode 100644 browser/components/newtab/content-src/asrouter/docs/message-routing-overview.png create mode 100644 browser/components/newtab/content-src/asrouter/docs/simple-cfr-template.rst create mode 100644 browser/components/newtab/content-src/asrouter/docs/targeting-attributes.md create mode 100644 browser/components/newtab/content-src/asrouter/docs/targeting-guide.md create mode 100644 browser/components/newtab/content-src/asrouter/docs/telemetry-screenshot.png create mode 100644 browser/components/newtab/content-src/asrouter/rich-text-strings.js create mode 100644 browser/components/newtab/content-src/asrouter/schemas/message-format.md create mode 100644 browser/components/newtab/content-src/asrouter/schemas/message-group.schema.json create mode 100644 browser/components/newtab/content-src/asrouter/schemas/panel/cfr-fxa-bookmark.schema.json create mode 100644 browser/components/newtab/content-src/asrouter/schemas/provider-response.schema.json create mode 100644 browser/components/newtab/content-src/asrouter/template-utils.js create mode 100644 browser/components/newtab/content-src/asrouter/templates/CFR/templates/CFRUrlbarChiclet.schema.json create mode 100644 browser/components/newtab/content-src/asrouter/templates/CFR/templates/ExtensionDoorhanger.schema.json create mode 100644 browser/components/newtab/content-src/asrouter/templates/CFR/templates/InfoBar.schema.json create mode 100644 browser/components/newtab/content-src/asrouter/templates/EOYSnippet/EOYSnippet.jsx create mode 100644 browser/components/newtab/content-src/asrouter/templates/EOYSnippet/EOYSnippet.schema.json create mode 100644 browser/components/newtab/content-src/asrouter/templates/EOYSnippet/_EOYSnippet.scss create mode 100644 browser/components/newtab/content-src/asrouter/templates/FXASignupSnippet/FXASignupSnippet.jsx create mode 100644 browser/components/newtab/content-src/asrouter/templates/FXASignupSnippet/FXASignupSnippet.schema.json create mode 100644 browser/components/newtab/content-src/asrouter/templates/FirstRun/addUtmParams.js create mode 100644 browser/components/newtab/content-src/asrouter/templates/NewsletterSnippet/NewsletterSnippet.jsx create mode 100644 browser/components/newtab/content-src/asrouter/templates/NewsletterSnippet/NewsletterSnippet.schema.json create mode 100644 browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/OnboardingMessage.jsx create mode 100644 browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/OnboardingMessage.schema.json create mode 100644 browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/ToolbarBadgeMessage.schema.json create mode 100644 browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/UpdateAction.schema.json create mode 100644 browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/WhatsNewMessage.schema.json create mode 100644 browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/_OnboardingMessage.scss create mode 100644 browser/components/newtab/content-src/asrouter/templates/SendToDeviceSnippet/SendToDeviceSnippet.jsx create mode 100644 browser/components/newtab/content-src/asrouter/templates/SendToDeviceSnippet/SendToDeviceSnippet.schema.json create mode 100644 browser/components/newtab/content-src/asrouter/templates/SendToDeviceSnippet/isEmailOrPhoneNumber.js create mode 100644 browser/components/newtab/content-src/asrouter/templates/SimpleBelowSearchSnippet/SimpleBelowSearchSnippet.jsx create mode 100644 browser/components/newtab/content-src/asrouter/templates/SimpleBelowSearchSnippet/SimpleBelowSearchSnippet.schema.json create mode 100644 browser/components/newtab/content-src/asrouter/templates/SimpleBelowSearchSnippet/_SimpleBelowSearchSnippet.scss create mode 100644 browser/components/newtab/content-src/asrouter/templates/SimpleSnippet/SimpleSnippet.jsx create mode 100644 browser/components/newtab/content-src/asrouter/templates/SimpleSnippet/SimpleSnippet.schema.json create mode 100644 browser/components/newtab/content-src/asrouter/templates/SimpleSnippet/_SimpleSnippet.scss create mode 100644 browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/SubmitFormScene2Snippet.schema.json create mode 100644 browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/SubmitFormSnippet.jsx create mode 100644 browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/SubmitFormSnippet.schema.json create mode 100644 browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/_SubmitFormSnippet.scss create mode 100644 browser/components/newtab/content-src/asrouter/templates/template-manifest.jsx create mode 100644 browser/components/newtab/content-src/components/A11yLinkButton/A11yLinkButton.jsx create mode 100644 browser/components/newtab/content-src/components/A11yLinkButton/_A11yLinkButton.scss create mode 100644 browser/components/newtab/content-src/components/ASRouterAdmin/ASRouterAdmin.jsx create mode 100644 browser/components/newtab/content-src/components/ASRouterAdmin/ASRouterAdmin.scss create mode 100644 browser/components/newtab/content-src/components/ASRouterAdmin/SimpleHashRouter.jsx create mode 100644 browser/components/newtab/content-src/components/Base/Base.jsx create mode 100644 browser/components/newtab/content-src/components/Base/_Base.scss create mode 100644 browser/components/newtab/content-src/components/Card/Card.jsx create mode 100644 browser/components/newtab/content-src/components/Card/_Card.scss create mode 100644 browser/components/newtab/content-src/components/Card/types.js create mode 100644 browser/components/newtab/content-src/components/CollapsibleSection/CollapsibleSection.jsx create mode 100644 browser/components/newtab/content-src/components/CollapsibleSection/_CollapsibleSection.scss create mode 100644 browser/components/newtab/content-src/components/ComponentPerfTimer/ComponentPerfTimer.jsx create mode 100644 browser/components/newtab/content-src/components/ConfirmDialog/ConfirmDialog.jsx create mode 100644 browser/components/newtab/content-src/components/ConfirmDialog/_ConfirmDialog.scss create mode 100644 browser/components/newtab/content-src/components/ContextMenu/ContextMenu.jsx create mode 100644 browser/components/newtab/content-src/components/ContextMenu/ContextMenuButton.jsx create mode 100644 browser/components/newtab/content-src/components/ContextMenu/_ContextMenu.scss create mode 100644 browser/components/newtab/content-src/components/CustomizeMenu/BackgroundsSection/BackgroundsSection.jsx create mode 100644 browser/components/newtab/content-src/components/CustomizeMenu/ContentSection/ContentSection.jsx create mode 100644 browser/components/newtab/content-src/components/CustomizeMenu/CustomizeMenu.jsx create mode 100644 browser/components/newtab/content-src/components/CustomizeMenu/ThemesSection/ThemesSection.jsx create mode 100644 browser/components/newtab/content-src/components/CustomizeMenu/_CustomizeMenu.scss create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamBase/DiscoveryStreamBase.jsx create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamBase/_DiscoveryStreamBase.scss create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid.jsx create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamComponents/CardGrid/_CardGrid.scss create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamComponents/CollectionCardGrid/CollectionCardGrid.jsx create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamComponents/CollectionCardGrid/_CollectionCardGrid.scss create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSCard/DSCard.jsx create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSCard/_DSCard.scss create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSContextFooter/DSContextFooter.jsx create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSContextFooter/_DSContextFooter.scss create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSDismiss/DSDismiss.jsx create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSDismiss/_DSDismiss.scss create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSEmptyState/DSEmptyState.jsx create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSEmptyState/_DSEmptyState.scss create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSImage/DSImage.jsx create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSImage/_DSImage.scss create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSLinkMenu/DSLinkMenu.jsx create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSLinkMenu/_DSLinkMenu.scss create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSMessage/DSMessage.jsx create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSMessage/_DSMessage.scss create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSPrivacyModal/DSPrivacyModal.jsx create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSPrivacyModal/_DSPrivacyModal.scss create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSSignup/DSSignup.jsx create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSSignup/DSSignup.scss create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSTextPromo/DSTextPromo.jsx create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSTextPromo/_DSTextPromo.scss create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamComponents/Hero/Hero.jsx create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamComponents/Hero/_Hero.scss create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamComponents/Highlights/Highlights.jsx create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamComponents/Highlights/_Highlights.scss create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamComponents/HorizontalRule/HorizontalRule.jsx create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamComponents/HorizontalRule/_HorizontalRule.scss create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamComponents/List/List.jsx create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamComponents/List/_List.scss create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamComponents/Navigation/Navigation.jsx create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamComponents/Navigation/_Navigation.scss create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamComponents/SafeAnchor/SafeAnchor.jsx create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamComponents/SectionTitle/SectionTitle.jsx create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamComponents/SectionTitle/_SectionTitle.scss create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamComponents/TopSites/TopSites.jsx create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamComponents/TopSites/_TopSites.scss create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamImpressionStats/ImpressionStats.jsx create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamImpressionStats/_ImpressionStats.scss create mode 100644 browser/components/newtab/content-src/components/ErrorBoundary/ErrorBoundary.jsx create mode 100644 browser/components/newtab/content-src/components/ErrorBoundary/_ErrorBoundary.scss create mode 100644 browser/components/newtab/content-src/components/FluentOrText/FluentOrText.jsx create mode 100644 browser/components/newtab/content-src/components/LinkMenu/LinkMenu.jsx create mode 100644 browser/components/newtab/content-src/components/MoreRecommendations/MoreRecommendations.jsx create mode 100644 browser/components/newtab/content-src/components/MoreRecommendations/_MoreRecommendations.scss create mode 100644 browser/components/newtab/content-src/components/PocketLoggedInCta/PocketLoggedInCta.jsx create mode 100644 browser/components/newtab/content-src/components/PocketLoggedInCta/_PocketLoggedInCta.scss create mode 100644 browser/components/newtab/content-src/components/Search/Search.jsx create mode 100644 browser/components/newtab/content-src/components/Search/_Search.scss create mode 100644 browser/components/newtab/content-src/components/SectionMenu/SectionMenu.jsx create mode 100644 browser/components/newtab/content-src/components/Sections/Sections.jsx create mode 100644 browser/components/newtab/content-src/components/Sections/_Sections.scss create mode 100644 browser/components/newtab/content-src/components/TopSites/SearchShortcutsForm.jsx create mode 100644 browser/components/newtab/content-src/components/TopSites/TopSite.jsx create mode 100644 browser/components/newtab/content-src/components/TopSites/TopSiteForm.jsx create mode 100644 browser/components/newtab/content-src/components/TopSites/TopSiteFormInput.jsx create mode 100644 browser/components/newtab/content-src/components/TopSites/TopSites.jsx create mode 100644 browser/components/newtab/content-src/components/TopSites/TopSitesConstants.js create mode 100644 browser/components/newtab/content-src/components/TopSites/_TopSites.scss create mode 100644 browser/components/newtab/content-src/components/Topics/Topics.jsx create mode 100644 browser/components/newtab/content-src/components/Topics/_Topics.scss create mode 100644 browser/components/newtab/content-src/lib/aboutwelcome-utils.js create mode 100644 browser/components/newtab/content-src/lib/constants.js create mode 100644 browser/components/newtab/content-src/lib/detect-user-session-start.js create mode 100644 browser/components/newtab/content-src/lib/init-store.js create mode 100644 browser/components/newtab/content-src/lib/link-menu-options.js create mode 100644 browser/components/newtab/content-src/lib/perf-service.js create mode 100644 browser/components/newtab/content-src/lib/screenshot-utils.js create mode 100644 browser/components/newtab/content-src/lib/section-menu-options.js create mode 100644 browser/components/newtab/content-src/lib/selectLayoutRender.js create mode 100644 browser/components/newtab/content-src/styles/_OnboardingImages.scss create mode 100644 browser/components/newtab/content-src/styles/_activity-stream.scss create mode 100644 browser/components/newtab/content-src/styles/_icons.scss create mode 100644 browser/components/newtab/content-src/styles/_mixins.scss create mode 100644 browser/components/newtab/content-src/styles/_normalize.scss create mode 100644 browser/components/newtab/content-src/styles/_theme.scss create mode 100644 browser/components/newtab/content-src/styles/_variables.scss create mode 100644 browser/components/newtab/content-src/styles/activity-stream-linux.scss create mode 100644 browser/components/newtab/content-src/styles/activity-stream-mac.scss create mode 100644 browser/components/newtab/content-src/styles/activity-stream-windows.scss create mode 100644 browser/components/newtab/contributing.md create mode 100644 browser/components/newtab/css/activity-stream-linux.css create mode 100644 browser/components/newtab/css/activity-stream-mac.css create mode 100644 browser/components/newtab/css/activity-stream-windows.css create mode 100644 browser/components/newtab/data/content/abouthomecache/page.html.template create mode 100644 browser/components/newtab/data/content/abouthomecache/script.js.template create mode 100644 browser/components/newtab/data/content/activity-stream.bundle.js create mode 100644 browser/components/newtab/data/content/assets/cfr_enhancer_youtube.png create mode 100644 browser/components/newtab/data/content/assets/cfr_fb_container.png create mode 100644 browser/components/newtab/data/content/assets/cfr_google_translate.png create mode 100644 browser/components/newtab/data/content/assets/cfr_pinnedtab_animated.png create mode 100644 browser/components/newtab/data/content/assets/cfr_pinnedtab_animated@2x.png create mode 100644 browser/components/newtab/data/content/assets/cfr_pinnedtab_animated_darktheme.png create mode 100644 browser/components/newtab/data/content/assets/cfr_pinnedtab_animated_darktheme@2x.png create mode 100644 browser/components/newtab/data/content/assets/cfr_pinnedtab_static.png create mode 100644 browser/components/newtab/data/content/assets/cfr_pinnedtab_static@2x.png create mode 100644 browser/components/newtab/data/content/assets/cfr_reddit_enhancement.png create mode 100644 browser/components/newtab/data/content/assets/cfr_wiki_search.png create mode 100644 browser/components/newtab/data/content/assets/firefox-protections.svg create mode 100644 browser/components/newtab/data/content/assets/glyph-add-16.svg create mode 100644 browser/components/newtab/data/content/assets/glyph-arrow.svg create mode 100644 browser/components/newtab/data/content/assets/glyph-arrowhead-down-12.svg create mode 100644 browser/components/newtab/data/content/assets/glyph-arrowhead-down-16.svg create mode 100644 browser/components/newtab/data/content/assets/glyph-cancel-16.svg create mode 100644 browser/components/newtab/data/content/assets/glyph-caret-right.svg create mode 100644 browser/components/newtab/data/content/assets/glyph-cfr-feature-16.svg create mode 100644 browser/components/newtab/data/content/assets/glyph-delete-16.svg create mode 100644 browser/components/newtab/data/content/assets/glyph-dismiss-16.svg create mode 100644 browser/components/newtab/data/content/assets/glyph-edit-16.svg create mode 100644 browser/components/newtab/data/content/assets/glyph-help-24.svg create mode 100644 browser/components/newtab/data/content/assets/glyph-highlights-16.svg create mode 100644 browser/components/newtab/data/content/assets/glyph-info-16.svg create mode 100644 browser/components/newtab/data/content/assets/glyph-mail-16.svg create mode 100644 browser/components/newtab/data/content/assets/glyph-maximize-16.svg create mode 100644 browser/components/newtab/data/content/assets/glyph-minimize-16.svg create mode 100644 browser/components/newtab/data/content/assets/glyph-modal-delete-32.svg create mode 100644 browser/components/newtab/data/content/assets/glyph-newWindow-16.svg create mode 100644 browser/components/newtab/data/content/assets/glyph-open-file-16.svg create mode 100644 browser/components/newtab/data/content/assets/glyph-pause-12.svg create mode 100644 browser/components/newtab/data/content/assets/glyph-pin-12.svg create mode 100644 browser/components/newtab/data/content/assets/glyph-pin-16.svg create mode 100644 browser/components/newtab/data/content/assets/glyph-play-12.svg create mode 100644 browser/components/newtab/data/content/assets/glyph-playhead.svg create mode 100644 browser/components/newtab/data/content/assets/glyph-pocket-16.svg create mode 100644 browser/components/newtab/data/content/assets/glyph-pocket-archive-16.svg create mode 100644 browser/components/newtab/data/content/assets/glyph-pocket-delete-16.svg create mode 100644 browser/components/newtab/data/content/assets/glyph-pocket-save-16.svg create mode 100644 browser/components/newtab/data/content/assets/glyph-search-16.svg create mode 100644 browser/components/newtab/data/content/assets/glyph-star-17.svg create mode 100644 browser/components/newtab/data/content/assets/glyph-topsites-16.svg create mode 100644 browser/components/newtab/data/content/assets/glyph-trending-16.svg create mode 100644 browser/components/newtab/data/content/assets/glyph-unpin-16.svg create mode 100644 browser/components/newtab/data/content/assets/glyph-webextension-16.svg create mode 100644 browser/components/newtab/data/content/assets/icon-removed-bookmark.svg create mode 100644 browser/components/newtab/data/content/assets/illustration-addons@2x.png create mode 100644 browser/components/newtab/data/content/assets/illustration-gift@2x.png create mode 100644 browser/components/newtab/data/content/assets/illustration-privatebrowsing@2x.png create mode 100644 browser/components/newtab/data/content/assets/illustration-screenshots@2x.png create mode 100644 browser/components/newtab/data/content/assets/illustration-sync@2x.png create mode 100644 browser/components/newtab/data/content/assets/long-zap.svg create mode 100644 browser/components/newtab/data/content/assets/protection-report-icon.png create mode 100644 browser/components/newtab/data/content/assets/remote/pin-to-taskbar-v1.svg create mode 100644 browser/components/newtab/data/content/assets/remote/pin-to-taskbar-v2.svg create mode 100644 browser/components/newtab/data/content/assets/remote/pip-message-icon.svg create mode 100644 browser/components/newtab/data/content/assets/short-zap.svg create mode 100644 browser/components/newtab/data/content/assets/spinner.svg create mode 100644 browser/components/newtab/data/content/assets/topic-show-more-12.svg create mode 100644 browser/components/newtab/data/content/assets/trailhead/card-illo-devices.svg create mode 100644 browser/components/newtab/data/content/assets/trailhead/card-illo-fbcont.svg create mode 100644 browser/components/newtab/data/content/assets/trailhead/card-illo-ffmonitor.svg create mode 100644 browser/components/newtab/data/content/assets/trailhead/card-illo-ffsend.svg create mode 100644 browser/components/newtab/data/content/assets/trailhead/card-illo-import.svg create mode 100644 browser/components/newtab/data/content/assets/trailhead/card-illo-lockwise.svg create mode 100644 browser/components/newtab/data/content/assets/trailhead/card-illo-mobile.svg create mode 100644 browser/components/newtab/data/content/assets/trailhead/card-illo-pledge.svg create mode 100644 browser/components/newtab/data/content/assets/trailhead/card-illo-pocket.svg create mode 100644 browser/components/newtab/data/content/assets/trailhead/card-illo-private.svg create mode 100644 browser/components/newtab/data/content/assets/trailhead/card-illo-sendtab.svg create mode 100644 browser/components/newtab/data/content/assets/trailhead/card-illo-tracking.svg create mode 100644 browser/components/newtab/data/content/assets/whatsnew-send-icon.png create mode 100644 browser/components/newtab/data/content/newtab-render.js create mode 100644 browser/components/newtab/data/content/tippytop/favicons/adidas.png create mode 100644 browser/components/newtab/data/content/tippytop/favicons/aliexpress-com.ico create mode 100644 browser/components/newtab/data/content/tippytop/favicons/allegro-pl.ico create mode 100644 browser/components/newtab/data/content/tippytop/favicons/amazon.ico create mode 100644 browser/components/newtab/data/content/tippytop/favicons/avito-ru.ico create mode 100644 browser/components/newtab/data/content/tippytop/favicons/baidu-com.png create mode 100644 browser/components/newtab/data/content/tippytop/favicons/bbc-uk.ico create mode 100644 browser/components/newtab/data/content/tippytop/favicons/bing-com.ico create mode 100644 browser/components/newtab/data/content/tippytop/favicons/ctrip-com.ico create mode 100644 browser/components/newtab/data/content/tippytop/favicons/duckduckgo-com.ico create mode 100644 browser/components/newtab/data/content/tippytop/favicons/ebay.ico create mode 100644 browser/components/newtab/data/content/tippytop/favicons/etsy.ico create mode 100644 browser/components/newtab/data/content/tippytop/favicons/facebook-com.ico create mode 100644 browser/components/newtab/data/content/tippytop/favicons/geico.png create mode 100644 browser/components/newtab/data/content/tippytop/favicons/google-com.ico create mode 100644 browser/components/newtab/data/content/tippytop/favicons/hrblock.ico create mode 100644 browser/components/newtab/data/content/tippytop/favicons/ifeng-com.ico create mode 100644 browser/components/newtab/data/content/tippytop/favicons/iqiyi-com.ico create mode 100644 browser/components/newtab/data/content/tippytop/favicons/leboncoin-fr.png create mode 100644 browser/components/newtab/data/content/tippytop/favicons/nike.ico create mode 100644 browser/components/newtab/data/content/tippytop/favicons/ok-ru.ico create mode 100644 browser/components/newtab/data/content/tippytop/favicons/olx-pl.ico create mode 100644 browser/components/newtab/data/content/tippytop/favicons/reddit-com.png create mode 100644 browser/components/newtab/data/content/tippytop/favicons/samsung.ico create mode 100644 browser/components/newtab/data/content/tippytop/favicons/turbotax.png create mode 100644 browser/components/newtab/data/content/tippytop/favicons/twitter-com.ico create mode 100644 browser/components/newtab/data/content/tippytop/favicons/vk-com.ico create mode 100644 browser/components/newtab/data/content/tippytop/favicons/vodafone.png create mode 100644 browser/components/newtab/data/content/tippytop/favicons/weibo-com.ico create mode 100644 browser/components/newtab/data/content/tippytop/favicons/wikipedia-org.ico create mode 100644 browser/components/newtab/data/content/tippytop/favicons/wix.ico create mode 100644 browser/components/newtab/data/content/tippytop/favicons/wykop-pl.png create mode 100644 browser/components/newtab/data/content/tippytop/favicons/yandex-com.png create mode 100644 browser/components/newtab/data/content/tippytop/favicons/yandex-ru.png create mode 100644 browser/components/newtab/data/content/tippytop/favicons/youtube-com.png create mode 100644 browser/components/newtab/data/content/tippytop/favicons/zhihu-com.ico create mode 100644 browser/components/newtab/data/content/tippytop/images/adidas@2x.png create mode 100644 browser/components/newtab/data/content/tippytop/images/aliexpress-com@2x.png create mode 100644 browser/components/newtab/data/content/tippytop/images/allegro-pl@2x.png create mode 100644 browser/components/newtab/data/content/tippytop/images/amazon@2x.png create mode 100644 browser/components/newtab/data/content/tippytop/images/avito-ru@2x.png create mode 100644 browser/components/newtab/data/content/tippytop/images/baidu-com@2x.png create mode 100644 browser/components/newtab/data/content/tippytop/images/bbc-uk@2x.png create mode 100644 browser/components/newtab/data/content/tippytop/images/bing-com@2x.png create mode 100644 browser/components/newtab/data/content/tippytop/images/ctrip-com@2x.png create mode 100644 browser/components/newtab/data/content/tippytop/images/duckduckgo-com@2x.png create mode 100644 browser/components/newtab/data/content/tippytop/images/ebay@2x.png create mode 100644 browser/components/newtab/data/content/tippytop/images/etsy@2x.jpg create mode 100644 browser/components/newtab/data/content/tippytop/images/facebook-com@2x.png create mode 100644 browser/components/newtab/data/content/tippytop/images/geico@2x.jpg create mode 100644 browser/components/newtab/data/content/tippytop/images/google-com@2x.png create mode 100644 browser/components/newtab/data/content/tippytop/images/hrblock@2x.png create mode 100644 browser/components/newtab/data/content/tippytop/images/ifeng-com@2x.png create mode 100644 browser/components/newtab/data/content/tippytop/images/iqiyi-com@2x.png create mode 100644 browser/components/newtab/data/content/tippytop/images/leboncoin-fr@2x.png create mode 100644 browser/components/newtab/data/content/tippytop/images/nike@2x.jpg create mode 100644 browser/components/newtab/data/content/tippytop/images/ok-ru@2x.png create mode 100644 browser/components/newtab/data/content/tippytop/images/olx-pl@2x.png create mode 100644 browser/components/newtab/data/content/tippytop/images/reddit-com@2x.png create mode 100644 browser/components/newtab/data/content/tippytop/images/samsung@2x.jpg create mode 100644 browser/components/newtab/data/content/tippytop/images/turbotax@2x.jpg create mode 100644 browser/components/newtab/data/content/tippytop/images/twitter-com@2x.png create mode 100644 browser/components/newtab/data/content/tippytop/images/vk-com@2x.png create mode 100644 browser/components/newtab/data/content/tippytop/images/vodafone@2x.jpg create mode 100644 browser/components/newtab/data/content/tippytop/images/weibo-com@2x.png create mode 100644 browser/components/newtab/data/content/tippytop/images/wikipedia-org@2x.png create mode 100644 browser/components/newtab/data/content/tippytop/images/wix@2x.jpg create mode 100644 browser/components/newtab/data/content/tippytop/images/wykop-pl@2x.png create mode 100644 browser/components/newtab/data/content/tippytop/images/yandex-com@2x.png create mode 100644 browser/components/newtab/data/content/tippytop/images/yandex-ru@2x.png create mode 100644 browser/components/newtab/data/content/tippytop/images/youtube-com@2x.png create mode 100644 browser/components/newtab/data/content/tippytop/images/zhihu-com@2x.png create mode 100644 browser/components/newtab/data/content/tippytop/top_sites.json create mode 100644 browser/components/newtab/docs/index.rst create mode 100644 browser/components/newtab/docs/v2-system-addon/about_home_startup_cache.md create mode 100644 browser/components/newtab/docs/v2-system-addon/data_dictionary.md create mode 100644 browser/components/newtab/docs/v2-system-addon/data_events.md create mode 100644 browser/components/newtab/docs/v2-system-addon/geo_locale.md create mode 100644 browser/components/newtab/docs/v2-system-addon/mochitests.md create mode 100644 browser/components/newtab/docs/v2-system-addon/preferences.md create mode 100644 browser/components/newtab/docs/v2-system-addon/remote_cfr.md create mode 100644 browser/components/newtab/docs/v2-system-addon/sections.md create mode 100644 browser/components/newtab/docs/v2-system-addon/telemetry.md create mode 100644 browser/components/newtab/docs/v2-system-addon/tippytop.md create mode 100644 browser/components/newtab/docs/v2-system-addon/unit_testing_guide.md create mode 100644 browser/components/newtab/jar.mn create mode 100644 browser/components/newtab/karma.mc.config.js create mode 100644 browser/components/newtab/lib/ASRouter.jsm create mode 100644 browser/components/newtab/lib/ASRouterDefaultConfig.jsm create mode 100644 browser/components/newtab/lib/ASRouterNewTabHook.jsm create mode 100644 browser/components/newtab/lib/ASRouterParentProcessMessageHandler.jsm create mode 100644 browser/components/newtab/lib/ASRouterPreferences.jsm create mode 100644 browser/components/newtab/lib/ASRouterTargeting.jsm create mode 100644 browser/components/newtab/lib/ASRouterTriggerListeners.jsm create mode 100644 browser/components/newtab/lib/AboutPreferences.jsm create mode 100644 browser/components/newtab/lib/ActivityStream.jsm create mode 100644 browser/components/newtab/lib/ActivityStreamMessageChannel.jsm create mode 100644 browser/components/newtab/lib/ActivityStreamPrefs.jsm create mode 100644 browser/components/newtab/lib/ActivityStreamStorage.jsm create mode 100644 browser/components/newtab/lib/BookmarkPanelHub.jsm create mode 100644 browser/components/newtab/lib/CFRMessageProvider.jsm create mode 100644 browser/components/newtab/lib/CFRPageActions.jsm create mode 100644 browser/components/newtab/lib/DefaultSites.jsm create mode 100644 browser/components/newtab/lib/DiscoveryStreamFeed.jsm create mode 100644 browser/components/newtab/lib/DownloadsManager.jsm create mode 100644 browser/components/newtab/lib/FaviconFeed.jsm create mode 100644 browser/components/newtab/lib/FilterAdult.jsm create mode 100644 browser/components/newtab/lib/HighlightsFeed.jsm create mode 100644 browser/components/newtab/lib/InfoBar.jsm create mode 100644 browser/components/newtab/lib/LinksCache.jsm create mode 100644 browser/components/newtab/lib/MomentsPageHub.jsm create mode 100644 browser/components/newtab/lib/NewTabInit.jsm create mode 100644 browser/components/newtab/lib/OnboardingMessageProvider.jsm create mode 100644 browser/components/newtab/lib/PanelTestProvider.jsm create mode 100644 browser/components/newtab/lib/PersistentCache.jsm create mode 100644 browser/components/newtab/lib/PersonalityProvider/NaiveBayesTextTagger.jsm create mode 100644 browser/components/newtab/lib/PersonalityProvider/NmfTextTagger.jsm create mode 100644 browser/components/newtab/lib/PersonalityProvider/PersonalityProvider.jsm create mode 100644 browser/components/newtab/lib/PersonalityProvider/PersonalityProviderWorker.js create mode 100644 browser/components/newtab/lib/PersonalityProvider/PersonalityProviderWorkerClass.jsm create mode 100644 browser/components/newtab/lib/PersonalityProvider/RecipeExecutor.jsm create mode 100644 browser/components/newtab/lib/PersonalityProvider/Tokenize.jsm create mode 100644 browser/components/newtab/lib/PlacesFeed.jsm create mode 100644 browser/components/newtab/lib/PrefsFeed.jsm create mode 100644 browser/components/newtab/lib/RecommendationProviderSwitcher.jsm create mode 100644 browser/components/newtab/lib/RemoteL10n.jsm create mode 100644 browser/components/newtab/lib/Screenshots.jsm create mode 100644 browser/components/newtab/lib/SearchShortcuts.jsm create mode 100644 browser/components/newtab/lib/SectionsManager.jsm create mode 100644 browser/components/newtab/lib/ShortURL.jsm create mode 100644 browser/components/newtab/lib/SiteClassifier.jsm create mode 100644 browser/components/newtab/lib/SnippetsTestMessageProvider.jsm create mode 100644 browser/components/newtab/lib/Store.jsm create mode 100644 browser/components/newtab/lib/SystemTickFeed.jsm create mode 100644 browser/components/newtab/lib/TelemetryFeed.jsm create mode 100644 browser/components/newtab/lib/TippyTopProvider.jsm create mode 100644 browser/components/newtab/lib/ToolbarBadgeHub.jsm create mode 100644 browser/components/newtab/lib/ToolbarPanelHub.jsm create mode 100644 browser/components/newtab/lib/TopSitesFeed.jsm create mode 100644 browser/components/newtab/lib/TopStoriesFeed.jsm create mode 100644 browser/components/newtab/lib/UTEventReporting.jsm create mode 100644 browser/components/newtab/lib/UserDomainAffinityProvider.jsm create mode 100644 browser/components/newtab/lib/cache-worker.js create mode 100644 browser/components/newtab/loaders/inject-loader.js create mode 100644 browser/components/newtab/moz.build create mode 100644 browser/components/newtab/nsIAboutNewTabService.idl create mode 100644 browser/components/newtab/package-lock.json create mode 100644 browser/components/newtab/package.json create mode 100644 browser/components/newtab/prerendered/activity-stream-debug.html create mode 100644 browser/components/newtab/prerendered/activity-stream-noscripts.html create mode 100644 browser/components/newtab/prerendered/activity-stream.html create mode 100644 browser/components/newtab/test/.eslintrc.js create mode 100644 browser/components/newtab/test/browser/abouthomecache/browser.ini create mode 100644 browser/components/newtab/test/browser/abouthomecache/browser_basic_endtoend.js create mode 100644 browser/components/newtab/test/browser/abouthomecache/browser_bump_version.js create mode 100644 browser/components/newtab/test/browser/abouthomecache/browser_disabled.js create mode 100644 browser/components/newtab/test/browser/abouthomecache/browser_locale_change.js create mode 100644 browser/components/newtab/test/browser/abouthomecache/browser_no_cache.js create mode 100644 browser/components/newtab/test/browser/abouthomecache/browser_no_startup_actions.js create mode 100644 browser/components/newtab/test/browser/abouthomecache/browser_overwrite_cache.js create mode 100644 browser/components/newtab/test/browser/abouthomecache/browser_process_crash.js create mode 100644 browser/components/newtab/test/browser/abouthomecache/browser_same_consumer.js create mode 100644 browser/components/newtab/test/browser/abouthomecache/browser_sanitize.js create mode 100644 browser/components/newtab/test/browser/abouthomecache/browser_shutdown_timeout.js create mode 100644 browser/components/newtab/test/browser/abouthomecache/head.js create mode 100644 browser/components/newtab/test/browser/blue_page.html create mode 100644 browser/components/newtab/test/browser/browser.ini create mode 100644 browser/components/newtab/test/browser/browser_aboutwelcome_actors.js create mode 100644 browser/components/newtab/test/browser/browser_aboutwelcome_attribution.js create mode 100644 browser/components/newtab/test/browser/browser_aboutwelcome_multistage.js create mode 100644 browser/components/newtab/test/browser/browser_aboutwelcome_observer.js create mode 100644 browser/components/newtab/test/browser/browser_aboutwelcome_rtamo.js create mode 100644 browser/components/newtab/test/browser/browser_aboutwelcome_simplified.js create mode 100644 browser/components/newtab/test/browser/browser_as_load_location.js create mode 100644 browser/components/newtab/test/browser/browser_as_render.js create mode 100644 browser/components/newtab/test/browser/browser_asrouter_bookmarkpanel.js create mode 100644 browser/components/newtab/test/browser/browser_asrouter_cfr.js create mode 100644 browser/components/newtab/test/browser/browser_asrouter_experimentsAPILoader.js create mode 100644 browser/components/newtab/test/browser/browser_asrouter_group_frequency.js create mode 100644 browser/components/newtab/test/browser/browser_asrouter_group_userprefs.js create mode 100644 browser/components/newtab/test/browser/browser_asrouter_infobar.js create mode 100644 browser/components/newtab/test/browser/browser_asrouter_momentspagehub.js create mode 100644 browser/components/newtab/test/browser/browser_asrouter_snippets.js create mode 100644 browser/components/newtab/test/browser/browser_asrouter_targeting.js create mode 100644 browser/components/newtab/test/browser/browser_asrouter_toolbarbadge.js create mode 100644 browser/components/newtab/test/browser/browser_asrouter_whatsnewpanel.js create mode 100644 browser/components/newtab/test/browser/browser_context_menu_item.js create mode 100644 browser/components/newtab/test/browser/browser_customize_menu_content.js create mode 100644 browser/components/newtab/test/browser/browser_customize_menu_render.js create mode 100644 browser/components/newtab/test/browser/browser_discovery_card.js create mode 100644 browser/components/newtab/test/browser/browser_discovery_render.js create mode 100644 browser/components/newtab/test/browser/browser_discovery_styles.js create mode 100644 browser/components/newtab/test/browser/browser_enabled_newtabpage.js create mode 100644 browser/components/newtab/test/browser/browser_getScreenshots.js create mode 100644 browser/components/newtab/test/browser/browser_highlights_section.js create mode 100644 browser/components/newtab/test/browser/browser_newtab_experiment_api.js create mode 100644 browser/components/newtab/test/browser/browser_newtab_header.js create mode 100644 browser/components/newtab/test/browser/browser_newtab_overrides.js create mode 100644 browser/components/newtab/test/browser/browser_open_tab_focus.js create mode 100644 browser/components/newtab/test/browser/browser_topsites_contextMenu_options.js create mode 100644 browser/components/newtab/test/browser/browser_topsites_section.js create mode 100644 browser/components/newtab/test/browser/browser_trigger_listeners.js create mode 100644 browser/components/newtab/test/browser/ds_layout.json create mode 100644 browser/components/newtab/test/browser/head.js create mode 100644 browser/components/newtab/test/browser/red_page.html create mode 100644 browser/components/newtab/test/browser/snippet.json create mode 100644 browser/components/newtab/test/browser/snippet_below_search_test.json create mode 100644 browser/components/newtab/test/browser/snippet_simple_test.json create mode 100644 browser/components/newtab/test/browser/topstories.json create mode 100644 browser/components/newtab/test/schemas/pings.js create mode 100644 browser/components/newtab/test/unit/aboutwelcome/MultiStageAboutWelcome.test.jsx create mode 100644 browser/components/newtab/test/unit/asrouter/ASRouter.test.js create mode 100644 browser/components/newtab/test/unit/asrouter/ASRouterChild.test.js create mode 100644 browser/components/newtab/test/unit/asrouter/ASRouterNewTabHook.test.js create mode 100644 browser/components/newtab/test/unit/asrouter/ASRouterParent.test.js create mode 100644 browser/components/newtab/test/unit/asrouter/ASRouterParentProcessMessageHandler.test.js create mode 100644 browser/components/newtab/test/unit/asrouter/ASRouterPreferences.test.js create mode 100644 browser/components/newtab/test/unit/asrouter/ASRouterTargeting.test.js create mode 100644 browser/components/newtab/test/unit/asrouter/ASRouterTriggerListeners.test.js create mode 100644 browser/components/newtab/test/unit/asrouter/CFRMessageProvider.test.js create mode 100644 browser/components/newtab/test/unit/asrouter/CFRPageActions.test.js create mode 100644 browser/components/newtab/test/unit/asrouter/MessageLoaderUtils.test.js create mode 100644 browser/components/newtab/test/unit/asrouter/ModalOverlay.test.jsx create mode 100644 browser/components/newtab/test/unit/asrouter/PanelTestProvider.test.js create mode 100644 browser/components/newtab/test/unit/asrouter/RemoteL10n.test.js create mode 100644 browser/components/newtab/test/unit/asrouter/RichText.test.jsx create mode 100644 browser/components/newtab/test/unit/asrouter/SnippetsTestMessageProvider.test.js create mode 100644 browser/components/newtab/test/unit/asrouter/TargetingDocs.test.js create mode 100644 browser/components/newtab/test/unit/asrouter/asrouter-content.test.jsx create mode 100644 browser/components/newtab/test/unit/asrouter/asrouter-utils.test.js create mode 100644 browser/components/newtab/test/unit/asrouter/compatibility-reference/fx57-compat.test.js create mode 100644 browser/components/newtab/test/unit/asrouter/compatibility-reference/snippets-fx57.js create mode 100644 browser/components/newtab/test/unit/asrouter/constants.js create mode 100644 browser/components/newtab/test/unit/asrouter/schemas/panel/cfr-fxa-bookmark.schema.test.js create mode 100644 browser/components/newtab/test/unit/asrouter/template-utils.test.js create mode 100644 browser/components/newtab/test/unit/asrouter/templates/EOYSnippet.test.jsx create mode 100644 browser/components/newtab/test/unit/asrouter/templates/ExtensionDoorhanger.test.jsx create mode 100644 browser/components/newtab/test/unit/asrouter/templates/FXASignupSnippet.test.jsx create mode 100644 browser/components/newtab/test/unit/asrouter/templates/NewsletterSnippet.test.jsx create mode 100644 browser/components/newtab/test/unit/asrouter/templates/OnboardingMessage.test.jsx create mode 100644 browser/components/newtab/test/unit/asrouter/templates/SendToDeviceSnippet.test.jsx create mode 100644 browser/components/newtab/test/unit/asrouter/templates/SimpleBelowSearchSnippet.test.jsx create mode 100644 browser/components/newtab/test/unit/asrouter/templates/SimpleSnippet.test.jsx create mode 100644 browser/components/newtab/test/unit/asrouter/templates/SubmitFormSnippet.test.jsx create mode 100644 browser/components/newtab/test/unit/asrouter/templates/isEmailOrPhoneNumber.test.js create mode 100644 browser/components/newtab/test/unit/common/Actions.test.js create mode 100644 browser/components/newtab/test/unit/common/Dedupe.test.js create mode 100644 browser/components/newtab/test/unit/common/Reducers.test.js create mode 100644 browser/components/newtab/test/unit/content-src/components/ASRouterAdmin.test.jsx create mode 100644 browser/components/newtab/test/unit/content-src/components/Base.test.jsx create mode 100644 browser/components/newtab/test/unit/content-src/components/Card.test.jsx create mode 100644 browser/components/newtab/test/unit/content-src/components/CollapsibleSection.test.jsx create mode 100644 browser/components/newtab/test/unit/content-src/components/ComponentPerfTimer.test.jsx create mode 100644 browser/components/newtab/test/unit/content-src/components/ConfirmDialog.test.jsx create mode 100644 browser/components/newtab/test/unit/content-src/components/ContextMenu.test.jsx create mode 100644 browser/components/newtab/test/unit/content-src/components/CustomiseMenu.test.jsx create mode 100644 browser/components/newtab/test/unit/content-src/components/DiscoveryStreamBase.test.jsx create mode 100644 browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/CardGrid.test.jsx create mode 100644 browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/CollectionCardGrid.test.jsx create mode 100644 browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSCard.test.jsx create mode 100644 browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSContextFooter.test.jsx create mode 100644 browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSDismiss.test.jsx create mode 100644 browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSEmptyState.test.jsx create mode 100644 browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSImage.test.jsx create mode 100644 browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSLinkMenu.test.jsx create mode 100644 browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSMessage.test.jsx create mode 100644 browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSPrivacyModal.test.jsx create mode 100644 browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSSignup.test.jsx create mode 100644 browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSTextPromo.test.jsx create mode 100644 browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/Hero.test.jsx create mode 100644 browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/Highlights.test.jsx create mode 100644 browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/HorizontalRule.test.jsx create mode 100644 browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/ImpressionStats.test.jsx create mode 100644 browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/List.test.jsx create mode 100644 browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/Navigation.test.jsx create mode 100644 browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/SafeAnchor.test.jsx create mode 100644 browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/SectionTitle.test.jsx create mode 100644 browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/TopSites.test.jsx create mode 100644 browser/components/newtab/test/unit/content-src/components/ErrorBoundary.test.jsx create mode 100644 browser/components/newtab/test/unit/content-src/components/FluentOrText.test.jsx create mode 100644 browser/components/newtab/test/unit/content-src/components/LinkMenu.test.jsx create mode 100644 browser/components/newtab/test/unit/content-src/components/MSLocalized.test.jsx create mode 100644 browser/components/newtab/test/unit/content-src/components/MoreRecommendations.test.jsx create mode 100644 browser/components/newtab/test/unit/content-src/components/PocketLoggedInCta.test.jsx create mode 100644 browser/components/newtab/test/unit/content-src/components/Search.test.jsx create mode 100644 browser/components/newtab/test/unit/content-src/components/SectionMenu.test.jsx create mode 100644 browser/components/newtab/test/unit/content-src/components/Sections.test.jsx create mode 100644 browser/components/newtab/test/unit/content-src/components/TopSites.test.jsx create mode 100644 browser/components/newtab/test/unit/content-src/components/TopSites/SearchShortcutsForm.test.jsx create mode 100644 browser/components/newtab/test/unit/content-src/components/Topics.test.jsx create mode 100644 browser/components/newtab/test/unit/content-src/components/addUtmParams.test.js create mode 100644 browser/components/newtab/test/unit/content-src/lib/detect-user-session-start.test.js create mode 100644 browser/components/newtab/test/unit/content-src/lib/init-store.test.js create mode 100644 browser/components/newtab/test/unit/content-src/lib/perf-service.test.js create mode 100644 browser/components/newtab/test/unit/content-src/lib/screenshot-utils.test.js create mode 100644 browser/components/newtab/test/unit/content-src/lib/selectLayoutRender.test.js create mode 100644 browser/components/newtab/test/unit/lib/AboutPreferences.test.js create mode 100644 browser/components/newtab/test/unit/lib/ActivityStream.test.js create mode 100644 browser/components/newtab/test/unit/lib/ActivityStreamMessageChannel.test.js create mode 100644 browser/components/newtab/test/unit/lib/ActivityStreamPrefs.test.js create mode 100644 browser/components/newtab/test/unit/lib/ActivityStreamStorage.test.js create mode 100644 browser/components/newtab/test/unit/lib/BookmarkPanelHub.test.js create mode 100644 browser/components/newtab/test/unit/lib/DiscoveryStreamFeed.test.js create mode 100644 browser/components/newtab/test/unit/lib/DownloadsManager.test.js create mode 100644 browser/components/newtab/test/unit/lib/FaviconFeed.test.js create mode 100644 browser/components/newtab/test/unit/lib/FilterAdult.test.js create mode 100644 browser/components/newtab/test/unit/lib/HighlightsFeed.test.js create mode 100644 browser/components/newtab/test/unit/lib/LinksCache.test.js create mode 100644 browser/components/newtab/test/unit/lib/MomentsPageHub.test.js create mode 100644 browser/components/newtab/test/unit/lib/NewTabInit.test.js create mode 100644 browser/components/newtab/test/unit/lib/PersistentCache.test.js create mode 100644 browser/components/newtab/test/unit/lib/PersonalityProvider/NaiveBayesTextTagger.test.js create mode 100644 browser/components/newtab/test/unit/lib/PersonalityProvider/NmfTextTagger.test.js create mode 100644 browser/components/newtab/test/unit/lib/PersonalityProvider/PersonalityProvider.test.js create mode 100644 browser/components/newtab/test/unit/lib/PersonalityProvider/PersonalityProviderWorkerClass.test.js create mode 100644 browser/components/newtab/test/unit/lib/PersonalityProvider/RecipeExecutor.test.js create mode 100644 browser/components/newtab/test/unit/lib/PersonalityProvider/Tokenize.test.js create mode 100644 browser/components/newtab/test/unit/lib/PlacesFeed.test.js create mode 100644 browser/components/newtab/test/unit/lib/PrefsFeed.test.js create mode 100644 browser/components/newtab/test/unit/lib/RecommendationProviderSwitcher.test.js create mode 100644 browser/components/newtab/test/unit/lib/Screenshots.test.js create mode 100644 browser/components/newtab/test/unit/lib/SectionsManager.test.js create mode 100644 browser/components/newtab/test/unit/lib/ShortUrl.test.js create mode 100644 browser/components/newtab/test/unit/lib/SiteClassifier.test.js create mode 100644 browser/components/newtab/test/unit/lib/Store.test.js create mode 100644 browser/components/newtab/test/unit/lib/SystemTickFeed.test.js create mode 100644 browser/components/newtab/test/unit/lib/TelemetryFeed.test.js create mode 100644 browser/components/newtab/test/unit/lib/TippyTopProvider.test.js create mode 100644 browser/components/newtab/test/unit/lib/ToolbarBadgeHub.test.js create mode 100644 browser/components/newtab/test/unit/lib/ToolbarPanelHub.test.js create mode 100644 browser/components/newtab/test/unit/lib/TopSitesFeed.test.js create mode 100644 browser/components/newtab/test/unit/lib/TopStoriesFeed.test.js create mode 100644 browser/components/newtab/test/unit/lib/UTEventReporting.test.js create mode 100644 browser/components/newtab/test/unit/lib/UserDomainAffinityProvider.test.js create mode 100644 browser/components/newtab/test/unit/unit-entry.js create mode 100644 browser/components/newtab/test/unit/utils.js create mode 100644 browser/components/newtab/test/xpcshell/ds_layout.json create mode 100644 browser/components/newtab/test/xpcshell/head.js create mode 100644 browser/components/newtab/test/xpcshell/test_ASRouterTargeting_attribution.js create mode 100644 browser/components/newtab/test/xpcshell/test_AboutHomeStartupCacheChild.js create mode 100644 browser/components/newtab/test/xpcshell/test_AboutHomeStartupCacheWorker.js create mode 100644 browser/components/newtab/test/xpcshell/test_AboutNewTab.js create mode 100644 browser/components/newtab/test/xpcshell/test_AboutWelcomeAttribution.js create mode 100644 browser/components/newtab/test/xpcshell/test_AboutWelcomeTelemetry.js create mode 100644 browser/components/newtab/test/xpcshell/topstories.json create mode 100644 browser/components/newtab/test/xpcshell/xpcshell.ini create mode 100644 browser/components/newtab/vendor/PROP_TYPES_LICENSE create mode 100644 browser/components/newtab/vendor/REACT_AND_REACT_DOM_LICENSE create mode 100644 browser/components/newtab/vendor/REACT_REDUX_LICENSE create mode 100644 browser/components/newtab/vendor/REACT_TRANSITION_GROUP_LICENSE create mode 100644 browser/components/newtab/vendor/REDUX_LICENSE create mode 100644 browser/components/newtab/vendor/Redux.jsm create mode 100644 browser/components/newtab/vendor/prop-types.js create mode 100644 browser/components/newtab/vendor/react-dev.js create mode 100644 browser/components/newtab/vendor/react-dom-dev.js create mode 100644 browser/components/newtab/vendor/react-dom-server.js create mode 100644 browser/components/newtab/vendor/react-dom.js create mode 100644 browser/components/newtab/vendor/react-redux.js create mode 100644 browser/components/newtab/vendor/react-transition-group.js create mode 100644 browser/components/newtab/vendor/react.js create mode 100644 browser/components/newtab/vendor/redux.js create mode 100644 browser/components/newtab/webpack.aboutwelcome.config.js create mode 100644 browser/components/newtab/webpack.system-addon.config.js create mode 100644 browser/components/newtab/yamscripts.yml (limited to 'browser/components/newtab') diff --git a/browser/components/newtab/.eslintrc.js b/browser/components/newtab/.eslintrc.js new file mode 100644 index 0000000000..fa180392c0 --- /dev/null +++ b/browser/components/newtab/.eslintrc.js @@ -0,0 +1,226 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +module.exports = { + // When adding items to this file please check for effects on sub-directories. + parserOptions: { + ecmaVersion: 2018, + ecmaFeatures: { + jsx: true, + }, + sourceType: "module", + }, + env: { + node: true, + }, + plugins: [ + "import", // require("eslint-plugin-import") + "react", // require("eslint-plugin-react") + "jsx-a11y", // require("eslint-plugin-jsx-a11y") + ], + settings: { + react: { + version: "16.2.0", + }, + }, + extends: [ + "eslint:recommended", + "plugin:jsx-a11y/recommended", // require("eslint-plugin-jsx-a11y") + "plugin:mozilla/recommended", // require("eslint-plugin-mozilla") require("eslint-plugin-fetch-options") require("eslint-plugin-html") require("eslint-plugin-no-unsanitized") + "plugin:mozilla/browser-test", + "plugin:mozilla/mochitest-test", + "plugin:mozilla/xpcshell-test", + "plugin:prettier/recommended", // require("eslint-plugin-prettier") + "prettier/react", // require("eslint-config-prettier") + ], + overrides: [ + { + // These files use fluent-dom to insert content + files: [ + "content-src/aboutwelcome/components/HeroText.jsx", + "content-src/aboutwelcome/components/Zap.jsx", + "content-src/aboutwelcome/components/MultiStageAboutWelcome.jsx", + "content-src/aboutwelcome/components/ReturnToAMO.jsx", + "content-src/asrouter/templates/OnboardingMessage/**", + "content-src/asrouter/templates/FirstRun/**", + "content-src/components/TopSites/**", + "content-src/components/MoreRecommendations/MoreRecommendations.jsx", + "content-src/components/CollapsibleSection/CollapsibleSection.jsx", + "content-src/components/DiscoveryStreamComponents/DSEmptyState/DSEmptyState.jsx", + "content-src/components/DiscoveryStreamComponents/DSPrivacyModal/DSPrivacyModal.jsx", + "content-src/components/CustomizeMenu/**", + ], + rules: { + "jsx-a11y/anchor-has-content": 0, + "jsx-a11y/heading-has-content": 0, + "jsx-a11y/label-has-associated-control": 0, + "jsx-a11y/no-onchange": 0, + }, + }, + { + // Use a configuration that's more appropriate for JSMs + files: "**/*.jsm", + parserOptions: { + sourceType: "script", + }, + env: { + node: false, + }, + rules: { + "no-implicit-globals": 0, + }, + }, + ], + rules: { + "fetch-options/no-fetch-credentials": 2, + + "react/jsx-boolean-value": [2, "always"], + "react/jsx-key": 2, + "react/jsx-no-bind": 2, + "react/jsx-no-comment-textnodes": 2, + "react/jsx-no-duplicate-props": 2, + "react/jsx-no-target-blank": 2, + "react/jsx-no-undef": 2, + "react/jsx-pascal-case": 2, + "react/jsx-uses-react": 2, + "react/jsx-uses-vars": 2, + "react/no-access-state-in-setstate": 2, + "react/no-danger": 2, + "react/no-deprecated": 2, + "react/no-did-mount-set-state": 2, + "react/no-did-update-set-state": 2, + "react/no-direct-mutation-state": 2, + "react/no-is-mounted": 2, + "react/no-unknown-property": 2, + "react/require-render-return": 2, + + "accessor-pairs": [2, { setWithoutGet: true, getWithoutSet: false }], + "array-callback-return": 2, + "block-scoped-var": 2, + "callback-return": 0, + camelcase: 0, + "capitalized-comments": 0, + "class-methods-use-this": 0, + "consistent-this": [2, "use-bind"], + "default-case": 0, + eqeqeq: 2, + "for-direction": 2, + "func-name-matching": 2, + "func-names": 0, + "func-style": 0, + "getter-return": 2, + "global-require": 0, + "guard-for-in": 2, + "handle-callback-err": 2, + "id-blacklist": 0, + "id-length": 0, + "id-match": 0, + "init-declarations": 0, + "line-comment-position": 0, + "lines-between-class-members": 2, + "max-depth": [2, 4], + "max-lines": 0, + "max-nested-callbacks": [2, 4], + "max-params": [2, 6], + "max-statements": [2, 50], + "max-statements-per-line": [2, { max: 2 }], + "multiline-comment-style": 0, + "new-cap": [2, { newIsCap: true, capIsNew: false }], + "newline-after-var": 0, + "newline-before-return": 0, + "no-alert": 2, + "no-await-in-loop": 0, + "no-bitwise": 0, + "no-buffer-constructor": 2, + "no-catch-shadow": 2, + "no-console": 1, + "no-continue": 0, + "no-div-regex": 2, + "no-duplicate-imports": 2, + "no-empty-function": 0, + "no-eq-null": 2, + "no-extend-native": 2, + "no-extra-label": 2, + "no-implicit-coercion": [2, { allow: ["!!"] }], + "no-implicit-globals": 2, + "no-inline-comments": 0, + "no-invalid-this": 0, + "no-label-var": 2, + "no-loop-func": 2, + "no-magic-numbers": 0, + "no-mixed-requires": 2, + "no-multi-assign": 2, + "no-multi-str": 2, + "no-negated-condition": 0, + "no-negated-in-lhs": 2, + "no-new": 2, + "no-new-func": 2, + "no-new-require": 2, + "no-octal-escape": 2, + "no-param-reassign": 2, + "no-path-concat": 2, + "no-plusplus": 0, + "no-process-env": 0, + "no-process-exit": 2, + "no-proto": 2, + "no-prototype-builtins": 2, + "no-restricted-globals": 0, + "no-restricted-imports": 0, + "no-restricted-modules": 0, + "no-restricted-properties": 0, + "no-restricted-syntax": 0, + "no-return-assign": [2, "except-parens"], + "no-script-url": 2, + "no-shadow": 2, + "no-sync": 0, + "no-template-curly-in-string": 2, + "no-ternary": 0, + "no-undef-init": 2, + "no-undefined": 0, + "no-underscore-dangle": 0, + "no-unmodified-loop-condition": 2, + "no-unused-expressions": 2, + "no-use-before-define": 2, + "no-useless-computed-key": 2, + "no-useless-constructor": 2, + "no-useless-rename": 2, + "no-var": 2, + "no-void": 2, + "no-warning-comments": 0, // TODO: Change to `1`? + "one-var": [2, "never"], + "operator-assignment": [2, "always"], + "padding-line-between-statements": 0, + "prefer-const": 0, // TODO: Change to `1`? + "prefer-destructuring": [ + 2, + { + AssignmentExpression: { array: true }, + VariableDeclarator: { array: true, object: true }, + }, + ], + "prefer-numeric-literals": 2, + "prefer-promise-reject-errors": 2, + "prefer-reflect": 0, + "prefer-rest-params": 2, + "prefer-spread": 2, + "prefer-template": 2, + radix: [2, "always"], + "require-await": 2, + "require-jsdoc": 0, + "sort-keys": 0, + "sort-vars": 2, + strict: 0, + "symbol-description": 2, + "valid-jsdoc": [ + 0, + { + requireReturn: false, + requireParamDescription: false, + requireReturnDescription: false, + }, + ], + "vars-on-top": 2, + yoda: [2, "never"], + }, +}; diff --git a/browser/components/newtab/.nvmrc b/browser/components/newtab/.nvmrc new file mode 100644 index 0000000000..2baa2d433a --- /dev/null +++ b/browser/components/newtab/.nvmrc @@ -0,0 +1 @@ +10.23.1 diff --git a/browser/components/newtab/.sass-lint.yml b/browser/components/newtab/.sass-lint.yml new file mode 100644 index 0000000000..bb7a68d3eb --- /dev/null +++ b/browser/components/newtab/.sass-lint.yml @@ -0,0 +1,27 @@ +options: + merge-default-rules: true + max-warnings: 0 + +files: + include: 'content-src/**/*.scss' + +rules: + class-name-format: 0 + extends-before-declarations: 2 + extends-before-mixins: 2 + force-element-nesting: 0 + force-pseudo-nesting: 0 + hex-notation: [2, {style: uppercase}] + indentation: [2, {size: 2}] + leading-zero: [2, {include: true}] + mixins-before-declarations: [2, {exclude: [breakpoint, mq]}] + nesting-depth: [2, {max-depth: 4}] + no-debug: 1 + no-disallowed-properties: [1, {properties: [margin-left, margin-right, text-transform]}] + no-duplicate-properties: 2 + no-misspelled-properties: [2, {extra-properties: [-moz-context-properties]}] + no-url-domains: 0 + no-vendor-prefixes: 0 + no-warn: 1 + placeholder-in-extend: 2 + property-sort-order: 0 diff --git a/browser/components/newtab/AboutNewTabService.jsm b/browser/components/newtab/AboutNewTabService.jsm new file mode 100644 index 0000000000..65e4d38b8d --- /dev/null +++ b/browser/components/newtab/AboutNewTabService.jsm @@ -0,0 +1,512 @@ +/** + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +"use strict"; + +const EXPORTED_SYMBOLS = [ + "AboutNewTabStubService", + "AboutHomeStartupCacheChild", +]; + +/** + * The nsIAboutNewTabService is accessed by the AboutRedirector anytime + * about:home, about:newtab or about:welcome are requested. The primary + * job of an nsIAboutNewTabService is to tell the AboutRedirector what + * resources to actually load for those requests. + * + * The nsIAboutNewTabService is not involved when the user has overridden + * the default about:home or about:newtab pages. + * + * There are two implementations of this service - one for the parent + * process, and one for content processes. Each one has some secondary + * responsibilties that are process-specific. + * + * The need for two implementations is an unfortunate consequence of how + * document loading and process redirection for about: pages currently + * works in Gecko. The commonalities between the two implementations has + * been put into an abstract base class. + */ + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +const { AppConstants } = ChromeUtils.import( + "resource://gre/modules/AppConstants.jsm" +); +const { E10SUtils } = ChromeUtils.import( + "resource://gre/modules/E10SUtils.jsm" +); + +const { ExperimentAPI } = ChromeUtils.import( + "resource://messaging-system/experiments/ExperimentAPI.jsm" +); + +/** + * BEWARE: Do not add variables for holding state in the global scope. + * Any state variables should be properties of the appropriate class + * below. This is to avoid confusion where the state is set in one process, + * but not in another. + * + * Constants are fine in the global scope. + */ + +const PREF_ABOUT_HOME_CACHE_ENABLED = + "browser.startup.homepage.abouthome_cache.enabled"; +const PREF_ABOUT_HOME_CACHE_TESTING = + "browser.startup.homepage.abouthome_cache.testing"; +const PREF_ABOUT_WELCOME_ENABLED = "browser.aboutwelcome.enabled"; +const ABOUT_WELCOME_URL = + "resource://activity-stream/aboutwelcome/aboutwelcome.html"; + +ChromeUtils.defineModuleGetter( + this, + "BasePromiseWorker", + "resource://gre/modules/PromiseWorker.jsm" +); + +const CACHE_WORKER_URL = "resource://activity-stream/lib/cache-worker.js"; + +const IS_PRIVILEGED_PROCESS = + Services.appinfo.remoteType === E10SUtils.PRIVILEGEDABOUT_REMOTE_TYPE; + +const PREF_SEPARATE_PRIVILEGEDABOUT_CONTENT_PROCESS = + "browser.tabs.remote.separatePrivilegedContentProcess"; +const PREF_ACTIVITY_STREAM_DEBUG = "browser.newtabpage.activity-stream.debug"; + +/** + * The AboutHomeStartupCacheChild is responsible for connecting the + * nsIAboutNewTabService with a cached document and script for about:home + * if one happens to exist. The AboutHomeStartupCacheChild is only ever + * handed the streams for those caches when the "privileged about content + * process" first launches, so subsequent loads of about:home do not read + * from this cache. + * + * See https://firefox-source-docs.mozilla.org/browser/components/newtab/docs/v2-system-addon/about_home_startup_cache.html + * for further details. + */ +const AboutHomeStartupCacheChild = { + _initted: false, + CACHE_REQUEST_MESSAGE: "AboutHomeStartupCache:CacheRequest", + CACHE_RESPONSE_MESSAGE: "AboutHomeStartupCache:CacheResponse", + CACHE_USAGE_RESULT_MESSAGE: "AboutHomeStartupCache:UsageResult", + STATES: { + UNAVAILABLE: 0, + UNCONSUMED: 1, + PAGE_CONSUMED: 2, + PAGE_AND_SCRIPT_CONSUMED: 3, + FAILED: 4, + }, + REQUEST_TYPE: { + PAGE: 0, + SCRIPT: 1, + }, + _state: 0, + _consumerBCID: null, + + /** + * Called via a process script very early on in the process lifetime. This + * prepares the AboutHomeStartupCacheChild to pass an nsIChannel back to + * the nsIAboutNewTabService when the initial about:home document is + * eventually requested. + * + * @param pageInputStream (nsIInputStream) + * The stream for the cached page markup. + * @param scriptInputStream (nsIInputStream) + * The stream for the cached script to run on the page. + */ + init(pageInputStream, scriptInputStream) { + if ( + !IS_PRIVILEGED_PROCESS && + !Services.prefs.getBoolPref(PREF_ABOUT_HOME_CACHE_TESTING, false) + ) { + throw new Error( + "Can only instantiate in the privileged about content processes." + ); + } + + if (!Services.prefs.getBoolPref(PREF_ABOUT_HOME_CACHE_ENABLED, false)) { + return; + } + + if (this._initted) { + throw new Error("AboutHomeStartupCacheChild already initted."); + } + + Services.obs.addObserver(this, "memory-pressure"); + Services.cpmm.addMessageListener(this.CACHE_REQUEST_MESSAGE, this); + + this._pageInputStream = pageInputStream; + this._scriptInputStream = scriptInputStream; + this._initted = true; + this.setState(this.STATES.UNCONSUMED); + }, + + /** + * A function that lets us put the AboutHomeStartupCacheChild back into + * its initial state. This is used by tests to let us simulate the startup + * behaviour of the module without having to manually launch a new privileged + * about content process every time. + */ + uninit() { + if (!Services.prefs.getBoolPref(PREF_ABOUT_HOME_CACHE_TESTING, false)) { + throw new Error( + "Cannot uninit AboutHomeStartupCacheChild unless testing." + ); + } + + if (!this._initted) { + return; + } + + Services.obs.removeObserver(this, "memory-pressure"); + Services.cpmm.removeMessageListener(this.CACHE_REQUEST_MESSAGE, this); + + if (this._cacheWorker) { + this._cacheWorker.terminate(); + this._cacheWorker = null; + } + + this._pageInputStream = null; + this._scriptInputStream = null; + this._initted = false; + this._state = this.STATES.UNAVAILABLE; + this._consumerBCID = null; + }, + + /** + * A public method called from nsIAboutNewTabService that attempts + * return an nsIChannel for a cached about:home document that we + * were initialized with. If we failed to be initted with the + * cache, or the input streams that we were sent have no data + * yet available, this function returns null. The caller should + * fall back to generating the page dynamically. + * + * This function will be called when loading about:home, or + * about:home?jscache - the latter returns the cached script. + * + * It is expected that the same BrowsingContext that loads the cached + * page will also load the cached script. + * + * @param uri (nsIURI) + * The URI for the requested page, as passed by nsIAboutNewTabService. + * @param loadInfo (nsILoadInfo) + * The nsILoadInfo for the requested load, as passed by + * nsIAboutNewWTabService. + * @return nsIChannel or null. + */ + maybeGetCachedPageChannel(uri, loadInfo) { + if (!this._initted) { + return null; + } + + if (this._state >= this.STATES.PAGE_AND_SCRIPT_CONSUMED) { + return null; + } + + let requestType = + uri.query === "jscache" + ? this.REQUEST_TYPE.SCRIPT + : this.REQUEST_TYPE.PAGE; + + // If this is a page request, then we need to be in the UNCONSUMED state, + // since we expect the page request to come first. If this is a script + // request, we expect to be in PAGE_CONSUMED state, since the page cache + // stream should he been consumed already. + if ( + (requestType === this.REQUEST_TYPE.PAGE && + this._state !== this.STATES.UNCONSUMED) || + (requestType === this.REQUEST_TYPE_SCRIPT && + this._state !== this.STATES.PAGE_CONSUMED) + ) { + return null; + } + + // If by this point, we don't have anything in the streams, + // then either the cache was too slow to give us data, or the cache + // doesn't exist. The caller should fall back to generating the + // page dynamically. + // + // We only do this on the page request, because by the time + // we get to the script request, we should have already drained + // the page input stream. + if (requestType === this.REQUEST_TYPE.PAGE) { + try { + if ( + !this._scriptInputStream.available() || + !this._pageInputStream.available() + ) { + this.setState(this.STATES.FAILED); + this.reportUsageResult(false /* success */); + return null; + } + } catch (e) { + this.setState(this.STATES.FAILED); + if (e.result === Cr.NS_BASE_STREAM_CLOSED) { + this.reportUsageResult(false /* success */); + return null; + } + throw e; + } + } + + if ( + requestType === this.REQUEST_TYPE.SCRIPT && + this._consumerBCID !== loadInfo.browsingContextID + ) { + // Some other document is somehow requesting the script - one + // that didn't originally request the page. This is not allowed. + this.setState(this.STATES.FAILED); + return null; + } + + let channel = Cc[ + "@mozilla.org/network/input-stream-channel;1" + ].createInstance(Ci.nsIInputStreamChannel); + channel.QueryInterface(Ci.nsIChannel); + channel.setURI(uri); + channel.loadInfo = loadInfo; + channel.contentStream = + requestType === this.REQUEST_TYPE.PAGE + ? this._pageInputStream + : this._scriptInputStream; + + if (requestType === this.REQUEST_TYPE.SCRIPT) { + this.setState(this.STATES.PAGE_AND_SCRIPT_CONSUMED); + this.reportUsageResult(true /* success */); + } else { + this.setState(this.STATES.PAGE_CONSUMED); + // Stash the BrowsingContext ID so that when the script stream + // attempts to be consumed, we ensure that it's from the same + // BrowsingContext that loaded the page. + this._consumerBCID = loadInfo.browsingContextID; + } + + return channel; + }, + + /** + * This function takes the state information required to generate + * the about:home cache markup and script, and then generates that + * markup in script asynchronously. Once that's done, a message + * is sent to the parent process with the nsIInputStream's for the + * markup and script contents. + * + * @param state (Object) + * The Redux state of the about:home document to render. + * @return Promise + * @resolves undefined + * After the message with the nsIInputStream's have been sent to + * the parent. + */ + async constructAndSendCache(state) { + if (!IS_PRIVILEGED_PROCESS) { + throw new Error("Wrong process type."); + } + + let worker = this.getOrCreateWorker(); + + TelemetryStopwatch.start("FX_ABOUTHOME_CACHE_CONSTRUCTION"); + + let { page, script } = await worker + .post("construct", [state]) + .finally(() => { + TelemetryStopwatch.finish("FX_ABOUTHOME_CACHE_CONSTRUCTION"); + }); + + let pageInputStream = Cc[ + "@mozilla.org/io/string-input-stream;1" + ].createInstance(Ci.nsIStringInputStream); + + pageInputStream.setUTF8Data(page); + + let scriptInputStream = Cc[ + "@mozilla.org/io/string-input-stream;1" + ].createInstance(Ci.nsIStringInputStream); + + scriptInputStream.setUTF8Data(script); + + Services.cpmm.sendAsyncMessage(this.CACHE_RESPONSE_MESSAGE, { + pageInputStream, + scriptInputStream, + }); + }, + + _cacheWorker: null, + getOrCreateWorker() { + if (this._cacheWorker) { + return this._cacheWorker; + } + + this._cacheWorker = new BasePromiseWorker(CACHE_WORKER_URL); + return this._cacheWorker; + }, + + receiveMessage(message) { + if (message.name === this.CACHE_REQUEST_MESSAGE) { + let { state } = message.data; + this.constructAndSendCache(state); + } + }, + + reportUsageResult(success) { + Services.cpmm.sendAsyncMessage(this.CACHE_USAGE_RESULT_MESSAGE, { + success, + }); + }, + + observe(subject, topic, data) { + if (topic === "memory-pressure" && this._cacheWorker) { + this._cacheWorker.terminate(); + this._cacheWorker = null; + } + }, + + /** + * Transitions the AboutHomeStartupCacheChild from one state + * to the next, where each state is defined in this.STATES. + * + * States can only be transitioned in increasing order, otherwise + * an error is logged. + */ + setState(state) { + if (state > this._state) { + this._state = state; + } else { + console.error( + "AboutHomeStartupCacheChild could not transition from state " + + `${this._state} to ${state}`, + new Error().stack + ); + } + }, +}; + +/** + * This is an abstract base class for the nsIAboutNewTabService + * implementations that has some common methods and properties. + */ +class BaseAboutNewTabService { + constructor() { + if (!AppConstants.RELEASE_OR_BETA) { + XPCOMUtils.defineLazyPreferenceGetter( + this, + "activityStreamDebug", + PREF_ACTIVITY_STREAM_DEBUG, + false + ); + } else { + this.activityStreamDebug = false; + } + + XPCOMUtils.defineLazyPreferenceGetter( + this, + "isAboutWelcomePrefEnabled", + PREF_ABOUT_WELCOME_ENABLED, + false + ); + + XPCOMUtils.defineLazyPreferenceGetter( + this, + "privilegedAboutProcessEnabled", + PREF_SEPARATE_PRIVILEGEDABOUT_CONTENT_PROCESS, + false + ); + + this.classID = Components.ID("{cb36c925-3adc-49b3-b720-a5cc49d8a40e}"); + this.QueryInterface = ChromeUtils.generateQI([ + "nsIAboutNewTabService", + "nsIObserver", + ]); + } + + /** + * Returns the default URL. + * + * This URL depends on various activity stream prefs. Overriding + * the newtab page has no effect on the result of this function. + */ + get defaultURL() { + // Generate the desired activity stream resource depending on state, e.g., + // "resource://activity-stream/prerendered/activity-stream.html" + // "resource://activity-stream/prerendered/activity-stream-debug.html" + // "resource://activity-stream/prerendered/activity-stream-noscripts.html" + return [ + "resource://activity-stream/prerendered/", + "activity-stream", + // Debug version loads dev scripts but noscripts separately loads scripts + this.activityStreamDebug && !this.privilegedAboutProcessEnabled + ? "-debug" + : "", + this.privilegedAboutProcessEnabled ? "-noscripts" : "", + ".html", + ].join(""); + } + + get welcomeURL() { + /* + * Returns the about:welcome URL + * + * This is calculated in the same way the default URL is. + */ + + if ( + this.isAboutWelcomePrefEnabled && + // about:welcome should be enabled by default if no experiment exists. + ExperimentAPI.isFeatureEnabled("aboutwelcome", true) + ) { + return ABOUT_WELCOME_URL; + } + return this.defaultURL; + } + + aboutHomeChannel(uri, loadInfo) { + throw Components.Exception( + "AboutHomeChannel not implemented for this process.", + Cr.NS_ERROR_NOT_IMPLEMENTED + ); + } +} + +/** + * The child-process implementation of nsIAboutNewTabService, + * which also does the work of redirecting about:home loads to + * the about:home startup cache if its available. + */ +class AboutNewTabChildService extends BaseAboutNewTabService { + aboutHomeChannel(uri, loadInfo) { + if (IS_PRIVILEGED_PROCESS) { + let cacheChannel = AboutHomeStartupCacheChild.maybeGetCachedPageChannel( + uri, + loadInfo + ); + if (cacheChannel) { + return cacheChannel; + } + } + + let pageURI = Services.io.newURI(this.defaultURL); + let fileChannel = Services.io.newChannelFromURIWithLoadInfo( + pageURI, + loadInfo + ); + fileChannel.originalURI = uri; + return fileChannel; + } +} + +/** + * The AboutNewTabStubService is a function called in both the main and + * content processes when trying to get at the nsIAboutNewTabService. This + * function does the job of choosing the appropriate implementation of + * nsIAboutNewTabService for the process type. + */ +function AboutNewTabStubService() { + if (Services.appinfo.processType === Services.appinfo.PROCESS_TYPE_DEFAULT) { + return new BaseAboutNewTabService(); + } + return new AboutNewTabChildService(); +} diff --git a/browser/components/newtab/aboutwelcome/AboutWelcomeChild.jsm b/browser/components/newtab/aboutwelcome/AboutWelcomeChild.jsm new file mode 100644 index 0000000000..459603c379 --- /dev/null +++ b/browser/components/newtab/aboutwelcome/AboutWelcomeChild.jsm @@ -0,0 +1,386 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const EXPORTED_SYMBOLS = ["AboutWelcomeChild"]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + DEFAULT_SITES: "resource://activity-stream/lib/DefaultSites.jsm", + ExperimentAPI: "resource://messaging-system/experiments/ExperimentAPI.jsm", + shortURL: "resource://activity-stream/lib/ShortURL.jsm", + TippyTopProvider: "resource://activity-stream/lib/TippyTopProvider.jsm", +}); + +XPCOMUtils.defineLazyGetter(this, "log", () => { + const { Logger } = ChromeUtils.import( + "resource://messaging-system/lib/Logger.jsm" + ); + return new Logger("AboutWelcomeChild"); +}); + +XPCOMUtils.defineLazyGetter(this, "tippyTopProvider", () => + (async () => { + const provider = new TippyTopProvider(); + await provider.init(); + return provider; + })() +); + +function _parseOverrideContent(value) { + let result = {}; + try { + result = value ? JSON.parse(value) : {}; + } catch (e) { + Cu.reportError(e); + } + return result; +} + +XPCOMUtils.defineLazyPreferenceGetter( + this, + "multiStageAboutWelcomeContent", + "browser.aboutwelcome.overrideContent", + "", + null, + _parseOverrideContent +); + +const SEARCH_REGION_PREF = "browser.search.region"; + +XPCOMUtils.defineLazyPreferenceGetter( + this, + "searchRegion", + SEARCH_REGION_PREF, + "" +); + +/** + * Lazily get importable sites from parent or reuse cached ones. + */ +function getImportableSites(child) { + return ( + getImportableSites.cache ?? + (getImportableSites.cache = (async () => { + // Use tippy top to get packaged rich icons + const tippyTop = await tippyTopProvider; + // Remove duplicate entries if they would appear the same + return `[${[ + ...new Set( + (await child.sendQuery("AWPage:IMPORTABLE_SITES")).map(url => { + // Get both rich icon and short name and save for deduping + const site = { url }; + tippyTop.processSite(site, "*"); + return JSON.stringify({ + icon: site.tippyTopIcon, + label: shortURL(site), + }); + }) + ), + ]}]`; + })()) + ); +} + +async function getDefaultSites(child) { + // Get default TopSites by region + let sites = DEFAULT_SITES.get( + DEFAULT_SITES.has(searchRegion) ? searchRegion : "" + ); + + // Use tippy top to get packaged rich icons + const tippyTop = await tippyTopProvider; + let defaultSites = sites.split(",").map(link => { + let site = { url: link }; + tippyTop.processSite(site); + return { + icon: site.tippyTopIcon, + title: shortURL(site), + }; + }); + return Cu.cloneInto(defaultSites, child.contentWindow); +} + +async function getSelectedTheme(child) { + let activeThemeId = await child.sendQuery("AWPage:GET_SELECTED_THEME"); + return activeThemeId; +} + +class AboutWelcomeChild extends JSWindowActorChild { + actorCreated() { + this.exportFunctions(); + this.initWebProgressListener(); + } + + initWebProgressListener() { + const webProgress = this.manager.browsingContext.top.docShell + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebProgress); + + const listener = { + QueryInterface: ChromeUtils.generateQI([ + "nsIWebProgressListener", + "nsISupportsWeakReference", + ]), + }; + + listener.onLocationChange = (aWebProgress, aRequest, aLocation, aFlags) => { + // Exit if actor 'AboutWelcome' has already been destroyed or + // content window doesn't exist + if (!this.manager || !this.contentWindow) { + return; + } + log.debug(`onLocationChange handled: ${aWebProgress.DOMWindow}`); + this.AWSendToParent("LOCATION_CHANGED"); + }; + + webProgress.addProgressListener( + listener, + Ci.nsIWebProgress.NOTIFY_LOCATION + ); + } + + /** + * Send event that can be handled by the page + * @param {{type: string, data?: any}} action + */ + sendToPage(action) { + log.debug(`Sending to page: ${action.type}`); + const win = this.document.defaultView; + const event = new win.CustomEvent("AboutWelcomeChromeToContent", { + detail: Cu.cloneInto(action, win), + }); + win.dispatchEvent(event); + } + + /** + * Export functions that can be called by page js + */ + exportFunctions() { + let window = this.contentWindow; + + Cu.exportFunction(this.AWGetExperimentData.bind(this), window, { + defineAs: "AWGetExperimentData", + }); + + Cu.exportFunction(this.AWGetAttributionData.bind(this), window, { + defineAs: "AWGetAttributionData", + }); + + // For local dev, checks for JSON content inside pref browser.aboutwelcome.overrideContent + // that is used to override default welcome UI + Cu.exportFunction(this.AWGetWelcomeOverrideContent.bind(this), window, { + defineAs: "AWGetWelcomeOverrideContent", + }); + + Cu.exportFunction(this.AWGetFxAMetricsFlowURI.bind(this), window, { + defineAs: "AWGetFxAMetricsFlowURI", + }); + + Cu.exportFunction(this.AWGetImportableSites.bind(this), window, { + defineAs: "AWGetImportableSites", + }); + + Cu.exportFunction(this.AWGetDefaultSites.bind(this), window, { + defineAs: "AWGetDefaultSites", + }); + + Cu.exportFunction(this.AWGetSelectedTheme.bind(this), window, { + defineAs: "AWGetSelectedTheme", + }); + + Cu.exportFunction(this.AWGetRegion.bind(this), window, { + defineAs: "AWGetRegion", + }); + + Cu.exportFunction(this.AWSelectTheme.bind(this), window, { + defineAs: "AWSelectTheme", + }); + + Cu.exportFunction(this.AWSendEventTelemetry.bind(this), window, { + defineAs: "AWSendEventTelemetry", + }); + + Cu.exportFunction(this.AWSendToParent.bind(this), window, { + defineAs: "AWSendToParent", + }); + + Cu.exportFunction(this.AWWaitForMigrationClose.bind(this), window, { + defineAs: "AWWaitForMigrationClose", + }); + } + + /** + * Wrap a promise so content can use Promise methods. + */ + wrapPromise(promise) { + return new this.contentWindow.Promise((resolve, reject) => + promise.then(resolve, reject) + ); + } + + /** + * Send multistage welcome JSON data read from aboutwelcome.overrideConetent pref to page + */ + AWGetWelcomeOverrideContent() { + return Cu.cloneInto( + multiStageAboutWelcomeContent || {}, + this.contentWindow + ); + } + + AWSelectTheme(data) { + return this.wrapPromise( + this.sendQuery("AWPage:SELECT_THEME", data.toUpperCase()) + ); + } + + async getAddonInfo(attrbObj) { + let { content, source } = attrbObj; + try { + if (!content || source !== "addons.mozilla.org") { + return null; + } + // Attribution data can be double encoded + while (content.includes("%")) { + try { + const result = decodeURIComponent(content); + if (result === content) { + break; + } + content = result; + } catch (e) { + break; + } + } + return await this.sendQuery("AWPage:GET_ADDON_FROM_REPOSITORY", content); + } catch (e) { + Cu.reportError( + "Failed to get the latest add-on version for Return to AMO" + ); + return null; + } + } + + hasAMOAttribution(attributionData) { + return ( + attributionData && + attributionData.campaign === "non-fx-button" && + attributionData.source === "addons.mozilla.org" + ); + } + + async formatAttributionData(attribution) { + let result = {}; + if (this.hasAMOAttribution(attribution)) { + let extraProps = await this.getAddonInfo(attribution); + if (extraProps) { + result = { + template: "return_to_amo", + extraProps, + }; + } + } + return result; + } + + async getAttributionData() { + return Cu.cloneInto( + await this.formatAttributionData( + await this.sendQuery("AWPage:GET_ATTRIBUTION_DATA") + ), + this.contentWindow + ); + } + + AWGetAttributionData() { + return this.wrapPromise(this.getAttributionData()); + } + + /** + * Send initial data to page including experiment information + */ + AWGetExperimentData() { + let experimentData; + try { + // Note that we specifically don't wait for experiments to be loaded from disk so if + // about:welcome loads outside of the "FirstStartup" scenario this will likely not be ready + experimentData = ExperimentAPI.getExperiment({ + featureId: "aboutwelcome", + // Telemetry handled in AboutNewTabService.jsm + sendExposurePing: false, + }); + } catch (e) { + Cu.reportError(e); + } + + if (experimentData?.slug) { + log.debug( + `Loading about:welcome with experiment: ${experimentData.slug}` + ); + } else { + log.debug("Loading about:welcome without experiment"); + } + return Cu.cloneInto(experimentData || {}, this.contentWindow); + } + + AWGetFxAMetricsFlowURI() { + return this.wrapPromise(this.sendQuery("AWPage:FXA_METRICS_FLOW_URI")); + } + + AWGetImportableSites() { + return this.wrapPromise(getImportableSites(this)); + } + + AWGetDefaultSites() { + return this.wrapPromise(getDefaultSites(this)); + } + + AWGetSelectedTheme() { + return this.wrapPromise(getSelectedTheme(this)); + } + + /** + * Send Event Telemetry + * @param {object} eventData + */ + AWSendEventTelemetry(eventData) { + this.AWSendToParent("TELEMETRY_EVENT", { + ...eventData, + event_context: { + ...eventData.event_context, + page: "about:welcome", + }, + }); + } + + /** + * Send message that can be handled by AboutWelcomeParent.jsm + * @param {string} type + * @param {any=} data + */ + AWSendToParent(type, data) { + this.sendAsyncMessage(`AWPage:${type}`, data); + } + + AWWaitForMigrationClose() { + return this.wrapPromise(this.sendQuery("AWPage:WAIT_FOR_MIGRATION_CLOSE")); + } + + AWGetRegion() { + return this.wrapPromise(this.sendQuery("AWPage:GET_REGION")); + } + + /** + * @param {{type: string, detail?: any}} event + * @override + */ + handleEvent(event) { + log.debug(`Received page event ${event.type}`); + } +} diff --git a/browser/components/newtab/aboutwelcome/AboutWelcomeParent.jsm b/browser/components/newtab/aboutwelcome/AboutWelcomeParent.jsm new file mode 100644 index 0000000000..37e32ac1d7 --- /dev/null +++ b/browser/components/newtab/aboutwelcome/AboutWelcomeParent.jsm @@ -0,0 +1,310 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const EXPORTED_SYMBOLS = ["AboutWelcomeParent"]; +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + AddonManager: "resource://gre/modules/AddonManager.jsm", + AddonRepository: "resource://gre/modules/addons/AddonRepository.jsm", + FxAccounts: "resource://gre/modules/FxAccounts.jsm", + MigrationUtils: "resource:///modules/MigrationUtils.jsm", + OS: "resource://gre/modules/osfile.jsm", + SpecialMessageActions: + "resource://messaging-system/lib/SpecialMessageActions.jsm", + AboutWelcomeTelemetry: + "resource://activity-stream/aboutwelcome/lib/AboutWelcomeTelemetry.jsm", + AttributionCode: "resource:///modules/AttributionCode.jsm", + PromiseUtils: "resource://gre/modules/PromiseUtils.jsm", + Region: "resource://gre/modules/Region.jsm", +}); + +XPCOMUtils.defineLazyGetter(this, "log", () => { + const { Logger } = ChromeUtils.import( + "resource://messaging-system/lib/Logger.jsm" + ); + return new Logger("AboutWelcomeParent"); +}); + +XPCOMUtils.defineLazyGetter( + this, + "Telemetry", + () => new AboutWelcomeTelemetry() +); + +const DID_SEE_ABOUT_WELCOME_PREF = "trailhead.firstrun.didSeeAboutWelcome"; +const AWTerminate = { + UNKNOWN: "unknown", + WINDOW_CLOSED: "welcome-window-closed", + TAB_CLOSED: "welcome-tab-closed", + APP_SHUT_DOWN: "app-shut-down", + ADDRESS_BAR_NAVIGATED: "address-bar-navigated", +}; +const LIGHT_WEIGHT_THEMES = { + DARK: "firefox-compact-dark@mozilla.org", + LIGHT: "firefox-compact-light@mozilla.org", + AUTOMATIC: "default-theme@mozilla.org", + ALPENGLOW: "firefox-alpenglow@mozilla.org", +}; + +async function getImportableSites() { + const sites = []; + + // Just handle these chromium-based browsers for now + for (const browserId of ["chrome", "chromium-edge", "chromium"]) { + // Skip if there's no profile data. + const migrator = await MigrationUtils.getMigrator(browserId); + if (!migrator) { + continue; + } + + // Check each profile for top sites + const dataPath = await migrator.wrappedJSObject._getChromeUserDataPathIfExists(); + for (const profile of await migrator.getSourceProfiles()) { + let path = OS.Path.join(dataPath, profile.id, "Top Sites"); + // Skip if top sites data is missing + if (!(await OS.File.exists(path))) { + Cu.reportError(`Missing file at ${path}`); + continue; + } + + try { + for (const row of await MigrationUtils.getRowsFromDBWithoutLocks( + path, + `Importable ${browserId} top sites`, + `SELECT url + FROM top_sites + ORDER BY url_rank` + )) { + sites.push(row.getString(0)); + } + } catch (ex) { + Cu.reportError( + `Failed to get importable top sites from ${browserId} ${ex}` + ); + } + } + } + return sites; +} + +class AboutWelcomeObserver { + constructor() { + Services.obs.addObserver(this, "quit-application"); + + this.win = Services.focus.activeWindow; + if (!this.win) { + return; + } + + this.terminateReason = AWTerminate.UNKNOWN; + + this.onWindowClose = () => { + this.terminateReason = AWTerminate.WINDOW_CLOSED; + }; + + this.onTabClose = () => { + this.terminateReason = AWTerminate.TAB_CLOSED; + }; + + this.win.addEventListener("TabClose", this.onTabClose, { once: true }); + this.win.addEventListener("unload", this.onWindowClose, { once: true }); + } + + observe(aSubject, aTopic, aData) { + switch (aTopic) { + case "quit-application": + this.terminateReason = AWTerminate.APP_SHUT_DOWN; + break; + } + } + + // Added for testing + get AWTerminate() { + return AWTerminate; + } + + stop() { + log.debug(`Terminate reason is ${this.terminateReason}`); + Services.obs.removeObserver(this, "quit-application"); + if (!this.win) { + return; + } + this.win.removeEventListener("TabClose", this.onTabClose); + this.win.removeEventListener("unload", this.onWindowClose); + this.win = null; + } +} + +class RegionHomeObserver { + observe(aSubject, aTopic, aData) { + switch (aTopic) { + case Region.REGION_TOPIC: + if (aData === Region.REGION_UPDATED) { + Services.obs.removeObserver(this, Region.REGION_TOPIC); + this.regionHomeDeferred.resolve(Region.home); + this.regionHomeDeferred = null; + } + break; + } + } + + promiseRegionHome() { + // Add observer and create promise that should be resolved + // with region or rejected inside didDestroy if user exits + // before region is available + if (!this.regionHomeDeferred) { + Services.obs.addObserver(this, Region.REGION_TOPIC); + this.regionHomeDeferred = PromiseUtils.defer(); + } + return this.regionHomeDeferred.promise; + } + + stop() { + if (this.regionHomeDeferred) { + Services.obs.removeObserver(this, Region.REGION_TOPIC); + // Reject unresolved deferred promise on exit + this.regionHomeDeferred.reject( + new Error("Unresolved region home promise") + ); + this.regionHomeDeferred = null; + } + } +} + +class AboutWelcomeParent extends JSWindowActorParent { + constructor() { + super(); + this.AboutWelcomeObserver = new AboutWelcomeObserver(this); + } + + didDestroy() { + if (this.AboutWelcomeObserver) { + this.AboutWelcomeObserver.stop(); + } + this.RegionHomeObserver?.stop(); + + Telemetry.sendTelemetry({ + event: "SESSION_END", + event_context: { + reason: this.AboutWelcomeObserver.terminateReason, + page: "about:welcome", + }, + message_id: this.AWMessageId, + id: "ABOUT_WELCOME", + }); + } + + /** + * Handle messages from AboutWelcomeChild.jsm + * + * @param {string} type + * @param {any=} data + * @param {Browser} browser + * @param {Window} window + */ + async onContentMessage(type, data, browser, window) { + log.debug(`Received content event: ${type}`); + switch (type) { + case "AWPage:SET_WELCOME_MESSAGE_SEEN": + this.AWMessageId = data; + try { + Services.prefs.setBoolPref(DID_SEE_ABOUT_WELCOME_PREF, true); + } catch (e) { + log.debug(`Fails to set ${DID_SEE_ABOUT_WELCOME_PREF}.`); + } + break; + case "AWPage:SPECIAL_ACTION": + SpecialMessageActions.handleAction(data, browser); + break; + case "AWPage:FXA_METRICS_FLOW_URI": + return FxAccounts.config.promiseMetricsFlowURI("aboutwelcome"); + case "AWPage:GET_ATTRIBUTION_DATA": + return AttributionCode.getAttrDataAsync(); + case "AWPage:IMPORTABLE_SITES": + return getImportableSites(); + case "AWPage:TELEMETRY_EVENT": + Telemetry.sendTelemetry(data); + break; + case "AWPage:LOCATION_CHANGED": + this.AboutWelcomeObserver.terminateReason = + AWTerminate.ADDRESS_BAR_NAVIGATED; + break; + case "AWPage:GET_ADDON_FROM_REPOSITORY": + const [addonInfo] = await AddonRepository.getAddonsByIDs([data]); + if (addonInfo.sourceURI.scheme !== "https") { + return null; + } + return { + name: addonInfo.name, + url: addonInfo.sourceURI.spec, + iconURL: addonInfo.icons["64"] || addonInfo.icons["32"], + }; + case "AWPage:SELECT_THEME": + return AddonManager.getAddonByID( + LIGHT_WEIGHT_THEMES[data] + ).then(addon => addon.enable()); + case "AWPage:GET_SELECTED_THEME": + let themes = await AddonManager.getAddonsByTypes(["theme"]); + let activeTheme = themes.find(addon => addon.isActive); + + // convert this to the short form name that the front end code + // expects + let themeShortName = Object.keys(LIGHT_WEIGHT_THEMES).find( + key => LIGHT_WEIGHT_THEMES[key] === activeTheme?.id + ); + return themeShortName?.toLowerCase(); + case "AWPage:GET_REGION": + if (Region.home !== null) { + return Region.home; + } + if (!this.RegionHomeObserver) { + this.RegionHomeObserver = new RegionHomeObserver(this); + } + return this.RegionHomeObserver.promiseRegionHome(); + case "AWPage:WAIT_FOR_MIGRATION_CLOSE": + return new Promise(resolve => + Services.ww.registerNotification(function observer(subject, topic) { + if ( + topic === "domwindowclosed" && + subject.document.documentURI === + "chrome://browser/content/migration/migration.xhtml" + ) { + Services.ww.unregisterNotification(observer); + resolve(); + } + }) + ); + default: + log.debug(`Unexpected event ${type} was not handled.`); + } + + return undefined; + } + + /** + * @param {{name: string, data?: any}} message + * @override + */ + receiveMessage(message) { + const { name, data } = message; + let browser; + let window; + + if (this.manager.rootFrameLoader) { + browser = this.manager.rootFrameLoader.ownerElement; + window = browser.ownerGlobal; + return this.onContentMessage(name, data, browser, window); + } + + log.warn(`Not handling ${name} because the browser doesn't exist.`); + return null; + } +} diff --git a/browser/components/newtab/aboutwelcome/content/aboutwelcome.bundle.js b/browser/components/newtab/aboutwelcome/content/aboutwelcome.bundle.js new file mode 100644 index 0000000000..deed7d749f --- /dev/null +++ b/browser/components/newtab/aboutwelcome/content/aboutwelcome.bundle.js @@ -0,0 +1,1501 @@ +/*! + * + * NOTE: This file is generated by webpack from aboutwelcome.jsx + * using the npm bundle task. + * + */ +/******/ (function(modules) { // webpackBootstrap +/******/ // The module cache +/******/ var installedModules = {}; +/******/ +/******/ // The require function +/******/ function __webpack_require__(moduleId) { +/******/ +/******/ // Check if module is in cache +/******/ if(installedModules[moduleId]) { +/******/ return installedModules[moduleId].exports; +/******/ } +/******/ // Create a new module (and put it into the cache) +/******/ var module = installedModules[moduleId] = { +/******/ i: moduleId, +/******/ l: false, +/******/ exports: {} +/******/ }; +/******/ +/******/ // Execute the module function +/******/ modules[moduleId].call(module.exports, module, module.exports, __webpack_require__); +/******/ +/******/ // Flag the module as loaded +/******/ module.l = true; +/******/ +/******/ // Return the exports of the module +/******/ return module.exports; +/******/ } +/******/ +/******/ +/******/ // expose the modules object (__webpack_modules__) +/******/ __webpack_require__.m = modules; +/******/ +/******/ // expose the module cache +/******/ __webpack_require__.c = installedModules; +/******/ +/******/ // define getter function for harmony exports +/******/ __webpack_require__.d = function(exports, name, getter) { +/******/ if(!__webpack_require__.o(exports, name)) { +/******/ Object.defineProperty(exports, name, { enumerable: true, get: getter }); +/******/ } +/******/ }; +/******/ +/******/ // define __esModule on exports +/******/ __webpack_require__.r = function(exports) { +/******/ if(typeof Symbol !== 'undefined' && Symbol.toStringTag) { +/******/ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' }); +/******/ } +/******/ Object.defineProperty(exports, '__esModule', { value: true }); +/******/ }; +/******/ +/******/ // create a fake namespace object +/******/ // mode & 1: value is a module id, require it +/******/ // mode & 2: merge all properties of value into the ns +/******/ // mode & 4: return value when already ns object +/******/ // mode & 8|1: behave like require +/******/ __webpack_require__.t = function(value, mode) { +/******/ if(mode & 1) value = __webpack_require__(value); +/******/ if(mode & 8) return value; +/******/ if((mode & 4) && typeof value === 'object' && value && value.__esModule) return value; +/******/ var ns = Object.create(null); +/******/ __webpack_require__.r(ns); +/******/ Object.defineProperty(ns, 'default', { enumerable: true, value: value }); +/******/ if(mode & 2 && typeof value != 'string') for(var key in value) __webpack_require__.d(ns, key, function(key) { return value[key]; }.bind(null, key)); +/******/ return ns; +/******/ }; +/******/ +/******/ // getDefaultExport function for compatibility with non-harmony modules +/******/ __webpack_require__.n = function(module) { +/******/ var getter = module && module.__esModule ? +/******/ function getDefault() { return module['default']; } : +/******/ function getModuleExports() { return module; }; +/******/ __webpack_require__.d(getter, 'a', getter); +/******/ return getter; +/******/ }; +/******/ +/******/ // Object.prototype.hasOwnProperty.call +/******/ __webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); }; +/******/ +/******/ // __webpack_public_path__ +/******/ __webpack_require__.p = ""; +/******/ +/******/ +/******/ // Load entry module and return exports +/******/ return __webpack_require__(__webpack_require__.s = 0); +/******/ }) +/************************************************************************/ +/******/ ([ +/* 0 */ +/***/ (function(module, __webpack_exports__, __webpack_require__) { + +"use strict"; +__webpack_require__.r(__webpack_exports__); +/* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(1); +/* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_0__); +/* harmony import */ var react_dom__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(2); +/* harmony import */ var react_dom__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(react_dom__WEBPACK_IMPORTED_MODULE_1__); +/* harmony import */ var _components_MultiStageAboutWelcome__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(3); +/* harmony import */ var _components_SimpleAboutWelcome__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(8); +/* harmony import */ var _components_ReturnToAMO__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(12); +/* harmony import */ var _lib_aboutwelcome_utils__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(6); +function _extends() { _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return _extends.apply(this, arguments); } + +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + + + + + + + +class AboutWelcome extends react__WEBPACK_IMPORTED_MODULE_0___default.a.PureComponent { + constructor(props) { + super(props); + this.state = { + metricsFlowUri: null + }; + this.fetchFxAFlowUri = this.fetchFxAFlowUri.bind(this); + this.handleStartBtnClick = this.handleStartBtnClick.bind(this); + } + + async fetchFxAFlowUri() { + this.setState({ + metricsFlowUri: await window.AWGetFxAMetricsFlowURI() + }); + } + + componentDidMount() { + this.fetchFxAFlowUri(); // Record impression with performance data after allowing the page to load + + const recordImpression = domState => { + const { + domComplete, + domInteractive + } = performance.getEntriesByType("navigation").pop(); + window.AWSendEventTelemetry({ + event: "IMPRESSION", + event_context: { + domComplete, + domInteractive, + mountStart: performance.getEntriesByName("mount").pop().startTime, + domState, + source: this.props.UTMTerm, + page: "about:welcome" + }, + message_id: this.props.messageId + }); + }; + + if (document.readyState === "complete") { + // Page might have already triggered a load event because it waited for async data, + // e.g., attribution, so the dom load timing could be of a empty content + // with domState in telemetry captured as 'complete' + recordImpression(document.readyState); + } else { + window.addEventListener("load", () => recordImpression("load"), { + once: true + }); + } // Captures user has seen about:welcome by setting + // firstrun.didSeeAboutWelcome pref to true and capturing welcome UI unique messageId + + + window.AWSendToParent("SET_WELCOME_MESSAGE_SEEN", this.props.messageId); + } + + handleStartBtnClick() { + _lib_aboutwelcome_utils__WEBPACK_IMPORTED_MODULE_5__["AboutWelcomeUtils"].handleUserAction(this.props.startButton.action); + const ping = { + event: "CLICK_BUTTON", + event_context: { + source: this.props.startButton.message_id, + page: "about:welcome" + }, + message_id: this.props.messageId, + id: "ABOUT_WELCOME" + }; + window.AWSendEventTelemetry(ping); + } + + render() { + const { + props + } = this; + + if (props.template === "simplified") { + return react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement(_components_SimpleAboutWelcome__WEBPACK_IMPORTED_MODULE_3__["SimpleAboutWelcome"], { + metricsFlowUri: this.state.metricsFlowUri, + message_id: props.messageId, + utm_term: props.UTMTerm, + title: props.title, + subtitle: props.subtitle, + cards: props.cards, + startButton: props.startButton, + handleStartBtnClick: this.handleStartBtnClick + }); + } else if (props.template === "return_to_amo") { + return react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement(_components_ReturnToAMO__WEBPACK_IMPORTED_MODULE_4__["ReturnToAMO"], { + message_id: props.messageId, + name: props.name, + url: props.url, + iconURL: props.iconURL + }); + } + + return react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement(_components_MultiStageAboutWelcome__WEBPACK_IMPORTED_MODULE_2__["MultiStageAboutWelcome"], { + screens: props.screens, + metricsFlowUri: this.state.metricsFlowUri, + message_id: props.messageId, + utm_term: props.UTMTerm + }); + } + +} + +AboutWelcome.defaultProps = _lib_aboutwelcome_utils__WEBPACK_IMPORTED_MODULE_5__["DEFAULT_WELCOME_CONTENT"]; // Computes messageId and UTMTerm info used in telemetry + +function ComputeTelemetryInfo(welcomeContent, experimentId, branchId) { + let messageId = welcomeContent.template === "return_to_amo" ? "RTAMO_DEFAULT_WELCOME" : "DEFAULT_ABOUTWELCOME"; + let UTMTerm = "default"; + + if (welcomeContent.id) { + messageId = welcomeContent.id.toUpperCase(); + } + + if (experimentId && branchId) { + UTMTerm = `${experimentId}-${branchId}`.toLowerCase(); + } + + return { + messageId, + UTMTerm + }; +} + +async function retrieveRenderContent() { + var _aboutWelcomeProps; + + // Check for override content in pref browser.aboutwelcome.overrideContent + let aboutWelcomeProps = await window.AWGetWelcomeOverrideContent(); + + if ((_aboutWelcomeProps = aboutWelcomeProps) === null || _aboutWelcomeProps === void 0 ? void 0 : _aboutWelcomeProps.template) { + let { + messageId, + UTMTerm + } = ComputeTelemetryInfo(aboutWelcomeProps); + return { + aboutWelcomeProps, + messageId, + UTMTerm + }; + } // Check for experiment and retrieve content + + + const { + slug, + branch + } = await window.AWGetExperimentData(); + aboutWelcomeProps = (branch === null || branch === void 0 ? void 0 : branch.feature) ? branch.feature.value : {}; // Check if there is any attribution data, this could take a while to await in series + // especially when there is an add-on that requires remote lookup + // Moving RTAMO as part of another screen of multistage is one option to fix the delay + // as it will allow the initial page to be fast while we fetch attribution data in parallel for a later screen. + + const attribution = await window.AWGetAttributionData(); + + if (attribution === null || attribution === void 0 ? void 0 : attribution.template) { + var _aboutWelcomeProps2; + + aboutWelcomeProps = { ...aboutWelcomeProps, + // If part of an experiment, render experiment template + template: ((_aboutWelcomeProps2 = aboutWelcomeProps) === null || _aboutWelcomeProps2 === void 0 ? void 0 : _aboutWelcomeProps2.template) ? aboutWelcomeProps.template : attribution.template, + ...attribution.extraProps + }; + } + + let { + messageId, + UTMTerm + } = ComputeTelemetryInfo(aboutWelcomeProps, slug, branch && branch.slug); + return { + aboutWelcomeProps, + messageId, + UTMTerm + }; +} + +async function mount() { + let { + aboutWelcomeProps, + messageId, + UTMTerm + } = await retrieveRenderContent(); + react_dom__WEBPACK_IMPORTED_MODULE_1___default.a.render(react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement(AboutWelcome, _extends({ + messageId: messageId, + UTMTerm: UTMTerm + }, aboutWelcomeProps)), document.getElementById("root")); +} + +performance.mark("mount"); +mount(); + +/***/ }), +/* 1 */ +/***/ (function(module, exports) { + +module.exports = React; + +/***/ }), +/* 2 */ +/***/ (function(module, exports) { + +module.exports = ReactDOM; + +/***/ }), +/* 3 */ +/***/ (function(module, __webpack_exports__, __webpack_require__) { + +"use strict"; +__webpack_require__.r(__webpack_exports__); +/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "MultiStageAboutWelcome", function() { return MultiStageAboutWelcome; }); +/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "WelcomeScreen", function() { return WelcomeScreen; }); +/* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(1); +/* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_0__); +/* harmony import */ var _MSLocalized__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(4); +/* harmony import */ var _Zap__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(5); +/* harmony import */ var _lib_aboutwelcome_utils__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(6); +/* harmony import */ var _asrouter_templates_FirstRun_addUtmParams__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(7); +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + + + + + +const MultiStageAboutWelcome = props => { + const [index, setScreenIndex] = Object(react__WEBPACK_IMPORTED_MODULE_0__["useState"])(0); + Object(react__WEBPACK_IMPORTED_MODULE_0__["useEffect"])(() => { + // Send impression ping when respective screen first renders + props.screens.forEach(screen => { + if (index === screen.order) { + _lib_aboutwelcome_utils__WEBPACK_IMPORTED_MODULE_3__["AboutWelcomeUtils"].sendImpressionTelemetry(`${props.message_id}_${screen.id}`); + } + }); // Remember that a new screen has loaded for browser navigation + + if (index > window.history.state) { + window.history.pushState(index, ""); + } + }, [index]); + Object(react__WEBPACK_IMPORTED_MODULE_0__["useEffect"])(() => { + // Switch to the screen tracked in state (null for initial state) + const handler = ({ + state + }) => setScreenIndex(Number(state)); // Handle page load, e.g., going back to about:welcome from about:home + + + handler(window.history); // Watch for browser back/forward button navigation events + + window.addEventListener("popstate", handler); + return () => window.removeEventListener("popstate", handler); + }, []); + const [flowParams, setFlowParams] = Object(react__WEBPACK_IMPORTED_MODULE_0__["useState"])(null); + const { + metricsFlowUri + } = props; + Object(react__WEBPACK_IMPORTED_MODULE_0__["useEffect"])(() => { + (async () => { + if (metricsFlowUri) { + setFlowParams((await _lib_aboutwelcome_utils__WEBPACK_IMPORTED_MODULE_3__["AboutWelcomeUtils"].fetchFlowParams(metricsFlowUri))); + } + })(); + }, [metricsFlowUri]); // Transition to next screen, opening about:home on last screen button CTA + + const handleTransition = index < props.screens.length - 1 ? () => setScreenIndex(prevState => prevState + 1) : () => _lib_aboutwelcome_utils__WEBPACK_IMPORTED_MODULE_3__["AboutWelcomeUtils"].handleUserAction({ + type: "OPEN_ABOUT_PAGE", + data: { + args: "home", + where: "current" + } + }); // Update top sites with default sites by region when region is available + + const [region, setRegion] = Object(react__WEBPACK_IMPORTED_MODULE_0__["useState"])(null); + Object(react__WEBPACK_IMPORTED_MODULE_0__["useEffect"])(() => { + (async () => { + setRegion((await window.AWGetRegion())); + })(); + }, []); // Get the active theme so the rendering code can make it selected + // by default. + + const [activeTheme, setActiveTheme] = Object(react__WEBPACK_IMPORTED_MODULE_0__["useState"])(null); + const [initialTheme, setInitialTheme] = Object(react__WEBPACK_IMPORTED_MODULE_0__["useState"])(null); + Object(react__WEBPACK_IMPORTED_MODULE_0__["useEffect"])(() => { + (async () => { + let theme = await window.AWGetSelectedTheme(); + setInitialTheme(theme); + setActiveTheme(theme); + })(); + }, []); + const useImportable = props.message_id.includes("IMPORTABLE"); // Track whether we have already sent the importable sites impression telemetry + + const importTelemetrySent = Object(react__WEBPACK_IMPORTED_MODULE_0__["useRef"])(false); + const [topSites, setTopSites] = Object(react__WEBPACK_IMPORTED_MODULE_0__["useState"])([]); + Object(react__WEBPACK_IMPORTED_MODULE_0__["useEffect"])(() => { + (async () => { + let DEFAULT_SITES = await window.AWGetDefaultSites(); + const importable = JSON.parse((await window.AWGetImportableSites())); + const showImportable = useImportable && importable.length >= 5; + + if (!importTelemetrySent.current) { + _lib_aboutwelcome_utils__WEBPACK_IMPORTED_MODULE_3__["AboutWelcomeUtils"].sendImpressionTelemetry(`${props.message_id}_SITES`, { + display: showImportable ? "importable" : "static", + importable: importable.length + }); + importTelemetrySent.current = true; + } + + setTopSites(showImportable ? { + data: importable, + showImportable + } : { + data: DEFAULT_SITES, + showImportable + }); + })(); + }, [useImportable, region]); + return react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement(react__WEBPACK_IMPORTED_MODULE_0___default.a.Fragment, null, react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("div", { + className: `outer-wrapper onboardingContainer` + }, props.screens.map(screen => { + return index === screen.order ? react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement(WelcomeScreen, { + key: screen.id, + id: screen.id, + totalNumberOfScreens: props.screens.length, + order: screen.order, + content: screen.content, + navigate: handleTransition, + topSites: topSites, + messageId: `${props.message_id}_${screen.id}`, + UTMTerm: props.utm_term, + flowParams: flowParams, + activeTheme: activeTheme, + initialTheme: initialTheme, + setActiveTheme: setActiveTheme + }) : null; + }))); +}; +class WelcomeScreen extends react__WEBPACK_IMPORTED_MODULE_0___default.a.PureComponent { + constructor(props) { + super(props); + this.handleAction = this.handleAction.bind(this); + } + + handleOpenURL(action, flowParams, UTMTerm) { + let { + type, + data + } = action; + + if (type === "SHOW_FIREFOX_ACCOUNTS") { + let params = { ..._asrouter_templates_FirstRun_addUtmParams__WEBPACK_IMPORTED_MODULE_4__["BASE_PARAMS"], + utm_term: `aboutwelcome-${UTMTerm}-screen` + }; + + if (action.addFlowParams && flowParams) { + params = { ...params, + ...flowParams + }; + } + + data = { ...data, + extraParams: params + }; + } else if (type === "OPEN_URL") { + let url = new URL(data.args); + Object(_asrouter_templates_FirstRun_addUtmParams__WEBPACK_IMPORTED_MODULE_4__["addUtmParams"])(url, `aboutwelcome-${UTMTerm}-screen`); + + if (action.addFlowParams && flowParams) { + url.searchParams.append("device_id", flowParams.deviceId); + url.searchParams.append("flow_id", flowParams.flowId); + url.searchParams.append("flow_begin_time", flowParams.flowBeginTime); + } + + data = { ...data, + args: url.toString() + }; + } + + _lib_aboutwelcome_utils__WEBPACK_IMPORTED_MODULE_3__["AboutWelcomeUtils"].handleUserAction({ + type, + data + }); + } + + async handleAction(event) { + let { + props + } = this; + let targetContent = props.content[event.currentTarget.value] || props.content.tiles; + + if (!(targetContent && targetContent.action)) { + return; + } // Send telemetry before waiting on actions + + + _lib_aboutwelcome_utils__WEBPACK_IMPORTED_MODULE_3__["AboutWelcomeUtils"].sendActionTelemetry(props.messageId, event.currentTarget.value); + let { + action + } = targetContent; + + if (["OPEN_URL", "SHOW_FIREFOX_ACCOUNTS"].includes(action.type)) { + this.handleOpenURL(action, props.flowParams, props.UTMTerm); + } else if (action.type) { + _lib_aboutwelcome_utils__WEBPACK_IMPORTED_MODULE_3__["AboutWelcomeUtils"].handleUserAction(action); // Wait until migration closes to complete the action + + if (action.type === "SHOW_MIGRATION_WIZARD") { + await window.AWWaitForMigrationClose(); + _lib_aboutwelcome_utils__WEBPACK_IMPORTED_MODULE_3__["AboutWelcomeUtils"].sendActionTelemetry(props.messageId, "migrate_close"); + } + } // A special tiles.action.theme value indicates we should use the event's value vs provided value. + + + if (action.theme) { + let themeToUse = action.theme === "" ? event.currentTarget.value : this.props.initialTheme || action.theme; + this.props.setActiveTheme(themeToUse); + window.AWSelectTheme(themeToUse); + } + + if (action.navigate) { + props.navigate(); + } + } + + renderSecondaryCTA(className) { + return react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("div", { + className: className ? `secondary-cta ${className}` : `secondary-cta` + }, react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement(_MSLocalized__WEBPACK_IMPORTED_MODULE_1__["Localized"], { + text: this.props.content.secondary_button.text + }, react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("span", null)), react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement(_MSLocalized__WEBPACK_IMPORTED_MODULE_1__["Localized"], { + text: this.props.content.secondary_button.label + }, react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("button", { + className: "secondary", + value: "secondary_button", + onClick: this.handleAction + }))); + } + + renderTiles() { + switch (this.props.content.tiles.type) { + case "topsites": + return this.props.topSites && this.props.topSites.data ? react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("div", { + className: `tiles-container ${this.props.content.tiles.info ? "info" : ""}` + }, react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("div", { + className: "tiles-topsites-section", + name: "topsites-section", + id: "topsites-section", + "aria-labelledby": "helptext", + role: "region" + }, this.props.topSites.data.slice(0, 5).map(({ + icon, + label, + title + }) => react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("div", { + className: "site", + key: icon + label, + "aria-label": title ? title : label, + role: "img" + }, react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("div", { + className: "icon", + style: icon ? { + backgroundColor: "transparent", + backgroundImage: `url(${icon})` + } : {} + }, icon ? "" : label && label[0].toUpperCase()), this.props.content.tiles.showTitles && react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("div", { + className: "host" + }, title || label))))) : null; + + case "theme": + return this.props.content.tiles.data ? react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("div", { + className: "tiles-theme-container" + }, react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("div", null, react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("fieldset", { + className: "tiles-theme-section" + }, react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement(_MSLocalized__WEBPACK_IMPORTED_MODULE_1__["Localized"], { + text: this.props.content.subtitle + }, react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("legend", { + className: "sr-only" + })), this.props.content.tiles.data.map(({ + theme, + label, + tooltip, + description + }) => react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement(_MSLocalized__WEBPACK_IMPORTED_MODULE_1__["Localized"], { + key: theme + label, + text: typeof tooltip === "object" ? tooltip : {} + }, react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("label", { + className: `theme${theme === this.props.activeTheme ? " selected" : ""}`, + title: theme + label + }, react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement(_MSLocalized__WEBPACK_IMPORTED_MODULE_1__["Localized"], { + text: typeof description === "object" ? description : {} + }, react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("input", { + type: "radio", + value: theme, + name: "theme", + checked: theme === this.props.activeTheme, + className: "sr-only input", + onClick: this.handleAction, + "data-l10n-attrs": "aria-description" + })), react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("div", { + className: `icon ${theme}` + }), label && react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement(_MSLocalized__WEBPACK_IMPORTED_MODULE_1__["Localized"], { + text: label + }, react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("div", { + className: "text" + })))))))) : null; + + case "video": + return this.props.content.tiles.source ? react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("div", { + className: `tiles-media-section ${this.props.content.tiles.media_type}` + }, react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("div", { + className: "fade" + }), react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("video", { + className: "media", + autoPlay: "true", + loop: "true", + muted: "true", + src: _lib_aboutwelcome_utils__WEBPACK_IMPORTED_MODULE_3__["AboutWelcomeUtils"].hasDarkMode() ? this.props.content.tiles.source.dark : this.props.content.tiles.source.default + })) : null; + + case "image": + return this.props.content.tiles.source ? react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("div", { + className: `${this.props.content.tiles.media_type}` + }, react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("img", { + src: _lib_aboutwelcome_utils__WEBPACK_IMPORTED_MODULE_3__["AboutWelcomeUtils"].hasDarkMode() && this.props.content.tiles.source.dark ? this.props.content.tiles.source.dark : this.props.content.tiles.source.default, + role: "presentation", + alt: "" + })) : null; + } + + return null; + } + + renderStepsIndicator() { + let steps = []; + + for (let i = 0; i < this.props.totalNumberOfScreens; i++) { + let className = i === this.props.order ? "current" : ""; + steps.push(react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("div", { + key: i, + className: `indicator ${className}` + })); + } + + return steps; + } + + renderHelpText() { + return react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement(_MSLocalized__WEBPACK_IMPORTED_MODULE_1__["Localized"], { + text: this.props.content.help_text.text + }, react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("p", { + id: "helptext", + className: `helptext ${this.props.content.help_text.position}` + })); + } + + render() { + const { + content, + topSites + } = this.props; + const hasSecondaryTopCTA = content.secondary_button && content.secondary_button.position === "top"; + const showImportableSitesDisclaimer = content.tiles && content.tiles.type === "topsites" && topSites && topSites.showImportable; + return react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("main", { + className: `screen ${this.props.id}` + }, hasSecondaryTopCTA ? this.renderSecondaryCTA("top") : null, react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("div", { + className: `brand-logo ${hasSecondaryTopCTA ? "cta-top" : ""}` + }), react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("div", { + className: "welcome-text" + }, react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement(_Zap__WEBPACK_IMPORTED_MODULE_2__["Zap"], { + hasZap: content.zap, + text: content.title + }), react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement(_MSLocalized__WEBPACK_IMPORTED_MODULE_1__["Localized"], { + text: content.subtitle + }, react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("h2", null))), content.tiles ? this.renderTiles() : null, react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("div", null, react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement(_MSLocalized__WEBPACK_IMPORTED_MODULE_1__["Localized"], { + text: content.primary_button ? content.primary_button.label : null + }, react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("button", { + className: "primary", + value: "primary_button", + onClick: this.handleAction + }))), content.secondary_button && content.secondary_button.position !== "top" ? this.renderSecondaryCTA() : null, content.help_text && content.help_text.position === "default" ? this.renderHelpText() : null, react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("nav", { + className: content.help_text && content.help_text.position === "footer" || showImportableSitesDisclaimer ? "steps has-helptext" : "steps", + "data-l10n-id": "onboarding-welcome-steps-indicator", + "data-l10n-args": `{"current": ${parseInt(this.props.order, 10) + 1}, "total": ${this.props.totalNumberOfScreens}}` + }, react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("br", null), react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("p", null), this.renderStepsIndicator()), content.help_text && content.help_text.position === "footer" || showImportableSitesDisclaimer ? this.renderHelpText() : null); + } + +} + +/***/ }), +/* 4 */ +/***/ (function(module, __webpack_exports__, __webpack_require__) { + +"use strict"; +__webpack_require__.r(__webpack_exports__); +/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "Localized", function() { return Localized; }); +/* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(1); +/* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_0__); +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const MS_STRING_PROP = "string_id"; +/** + * Based on the .text prop, localizes an inner element if a string_id + * is provided, OR renders plain text, OR hides it if nothing is provided. + * + * Examples: + * + * Localized text + * ftl: + * title = Welcome + * jsx: + *

+ * output: + *

Welcome

+ * + * Unlocalized text + * jsx: + *

+ * output: + *

Welcome

+ */ + +const Localized = ({ + text, + children +}) => { + if (!text) { + return null; + } + + let props = children ? children.props : {}; + let textNode; + + if (typeof text === "object" && text[MS_STRING_PROP]) { + props = { ...props + }; + props["data-l10n-id"] = text[MS_STRING_PROP]; + } else if (typeof text === "string") { + textNode = text; + } + + if (!children) { + return react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("span", props, textNode); + } else if (textNode) { + return react__WEBPACK_IMPORTED_MODULE_0___default.a.cloneElement(children, props, textNode); + } + + return react__WEBPACK_IMPORTED_MODULE_0___default.a.cloneElement(children, props); +}; + +/***/ }), +/* 5 */ +/***/ (function(module, __webpack_exports__, __webpack_require__) { + +"use strict"; +__webpack_require__.r(__webpack_exports__); +/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "Zap", function() { return Zap; }); +/* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(1); +/* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_0__); +/* harmony import */ var _MSLocalized__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(4); +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + + +const MS_STRING_PROP = "string_id"; +const ZAP_SIZE_THRESHOLD = 160; + +function calculateZapLength() { + let span = document.querySelector(".zap"); + + if (!span) { + return; + } + + let rect = span.getBoundingClientRect(); + + if (rect && rect.width > ZAP_SIZE_THRESHOLD) { + span.classList.add("long"); + } else { + span.classList.add("short"); + } +} + +const Zap = props => { + Object(react__WEBPACK_IMPORTED_MODULE_0__["useEffect"])(() => { + requestAnimationFrame(() => calculateZapLength()); + }); + + if (!props.text) { + return null; + } + + if (props.hasZap) { + if (typeof props.text === "object" && props.text[MS_STRING_PROP]) { + return react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement(_MSLocalized__WEBPACK_IMPORTED_MODULE_1__["Localized"], { + text: props.text + }, react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("h1", { + className: "welcomeZap" + }, react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("span", { + "data-l10n-name": "zap", + className: "zap" + }))); + } else if (typeof props.text === "string") { + // Parse string to zap style last word of the props.text + let titleArray = props.text.split(" "); + let lastWord = `${titleArray.pop()}`; + return react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("h1", { + className: "welcomeZap" + }, titleArray.join(" ").concat(" "), react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("span", { + className: "zap" + }, lastWord)); + } + } else { + return react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement(_MSLocalized__WEBPACK_IMPORTED_MODULE_1__["Localized"], { + text: props.text + }, react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("h1", null)); + } + + return null; +}; + +/***/ }), +/* 6 */ +/***/ (function(module, __webpack_exports__, __webpack_require__) { + +"use strict"; +__webpack_require__.r(__webpack_exports__); +/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "AboutWelcomeUtils", function() { return AboutWelcomeUtils; }); +/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "DEFAULT_RTAMO_CONTENT", function() { return DEFAULT_RTAMO_CONTENT; }); +/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "DEFAULT_WELCOME_CONTENT", function() { return DEFAULT_WELCOME_CONTENT; }); +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ +const AboutWelcomeUtils = { + handleUserAction(action) { + window.AWSendToParent("SPECIAL_ACTION", action); + }, + + sendImpressionTelemetry(messageId, context) { + window.AWSendEventTelemetry({ + event: "IMPRESSION", + event_context: context, + message_id: messageId + }); + }, + + sendActionTelemetry(messageId, elementId) { + const ping = { + event: "CLICK_BUTTON", + event_context: { + source: elementId, + page: "about:welcome" + }, + message_id: messageId + }; + window.AWSendEventTelemetry(ping); + }, + + async fetchFlowParams(metricsFlowUri) { + let flowParams; + + try { + const response = await fetch(metricsFlowUri, { + credentials: "omit" + }); + + if (response.status === 200) { + const { + deviceId, + flowId, + flowBeginTime + } = await response.json(); + flowParams = { + deviceId, + flowId, + flowBeginTime + }; + } else { + console.error("Non-200 response", response); // eslint-disable-line no-console + } + } catch (e) { + flowParams = null; + } + + return flowParams; + }, + + sendEvent(type, detail) { + document.dispatchEvent(new CustomEvent(`AWPage:${type}`, { + bubbles: true, + detail + })); + }, + + hasDarkMode() { + return document.body.hasAttribute("lwt-newtab-brighttext"); + } + +}; +const DEFAULT_RTAMO_CONTENT = { + template: "return_to_amo", + content: { + header: { + string_id: "onboarding-welcome-header" + }, + subtitle: { + string_id: "return-to-amo-subtitle" + }, + text: { + string_id: "return-to-amo-addon-title" + }, + primary_button: { + label: { + string_id: "return-to-amo-add-extension-label" + }, + action: { + type: "INSTALL_ADDON_FROM_URL", + data: { + url: null, + telemetrySource: "rtamo" + } + } + }, + startButton: { + label: { + string_id: "onboarding-not-now-button-label" + }, + message_id: "RTAMO_START_BROWSING_BUTTON", + action: { + type: "OPEN_AWESOME_BAR" + } + } + } +}; +const DEFAULT_WELCOME_CONTENT = { + template: "multistage", + screens: [{ + id: "AW_GET_STARTED", + order: 0, + content: { + zap: true, + title: { + string_id: "onboarding-multistage-welcome-header" + }, + subtitle: { + string_id: "onboarding-multistage-welcome-subtitle" + }, + primary_button: { + label: { + string_id: "onboarding-multistage-welcome-primary-button-label" + }, + action: { + navigate: true + } + }, + secondary_button: { + text: { + string_id: "onboarding-multistage-welcome-secondary-button-text" + }, + label: { + string_id: "onboarding-multistage-welcome-secondary-button-label" + }, + position: "top", + action: { + type: "SHOW_FIREFOX_ACCOUNTS", + addFlowParams: true, + data: { + entrypoint: "activity-stream-firstrun" + } + } + } + } + }, { + id: "AW_IMPORT_SETTINGS", + order: 1, + content: { + zap: true, + help_text: { + text: { + string_id: "onboarding-import-sites-disclaimer" + } + }, + title: { + string_id: "onboarding-multistage-import-header" + }, + subtitle: { + string_id: "onboarding-multistage-import-subtitle" + }, + tiles: { + type: "topsites", + showTitles: true + }, + primary_button: { + label: { + string_id: "onboarding-multistage-import-primary-button-label" + }, + action: { + type: "SHOW_MIGRATION_WIZARD", + navigate: true + } + }, + secondary_button: { + label: { + string_id: "onboarding-multistage-import-secondary-button-label" + }, + action: { + navigate: true + } + } + } + }, { + id: "AW_CHOOSE_THEME", + order: 2, + content: { + zap: true, + title: { + string_id: "onboarding-multistage-theme-header" + }, + subtitle: { + string_id: "onboarding-multistage-theme-subtitle" + }, + tiles: { + type: "theme", + action: { + theme: "" + }, + data: [{ + theme: "automatic", + label: { + string_id: "onboarding-multistage-theme-label-automatic" + }, + tooltip: { + string_id: "onboarding-multistage-theme-tooltip-automatic-2" + }, + description: { + string_id: "onboarding-multistage-theme-description-automatic-2" + } + }, { + theme: "light", + label: { + string_id: "onboarding-multistage-theme-label-light" + }, + tooltip: { + string_id: "onboarding-multistage-theme-tooltip-light-2" + }, + description: { + string_id: "onboarding-multistage-theme-description-light" + } + }, { + theme: "dark", + label: { + string_id: "onboarding-multistage-theme-label-dark" + }, + tooltip: { + string_id: "onboarding-multistage-theme-tooltip-dark-2" + }, + description: { + string_id: "onboarding-multistage-theme-description-dark" + } + }, { + theme: "alpenglow", + label: { + string_id: "onboarding-multistage-theme-label-alpenglow" + }, + tooltip: { + string_id: "onboarding-multistage-theme-tooltip-alpenglow-2" + }, + description: { + string_id: "onboarding-multistage-theme-description-alpenglow" + } + }] + }, + primary_button: { + label: { + string_id: "onboarding-multistage-theme-primary-button-label" + }, + action: { + navigate: true + } + }, + secondary_button: { + label: { + string_id: "onboarding-multistage-theme-secondary-button-label" + }, + action: { + theme: "automatic", + navigate: true + } + } + } + }] +}; + +/***/ }), +/* 7 */ +/***/ (function(module, __webpack_exports__, __webpack_require__) { + +"use strict"; +__webpack_require__.r(__webpack_exports__); +/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "BASE_PARAMS", function() { return BASE_PARAMS; }); +/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "addUtmParams", function() { return addUtmParams; }); +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * BASE_PARAMS keys/values can be modified from outside this file + */ +const BASE_PARAMS = { + utm_source: "activity-stream", + utm_campaign: "firstrun", + utm_medium: "referral" +}; +/** + * Takes in a url as a string or URL object and returns a URL object with the + * utm_* parameters added to it. If a URL object is passed in, the paraemeters + * are added to it (the return value can be ignored in that case as it's the + * same object). + */ + +function addUtmParams(url, utmTerm) { + let returnUrl = url; + + if (typeof returnUrl === "string") { + returnUrl = new URL(url); + } + + Object.keys(BASE_PARAMS).forEach(key => { + returnUrl.searchParams.append(key, BASE_PARAMS[key]); + }); + returnUrl.searchParams.append("utm_term", utmTerm); + return returnUrl; +} + +/***/ }), +/* 8 */ +/***/ (function(module, __webpack_exports__, __webpack_require__) { + +"use strict"; +__webpack_require__.r(__webpack_exports__); +/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "SimpleAboutWelcome", function() { return SimpleAboutWelcome; }); +/* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(1); +/* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_0__); +/* harmony import */ var _HeroText__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(9); +/* harmony import */ var _FxCards__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(10); +/* harmony import */ var _MSLocalized__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(4); +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + + + + +class SimpleAboutWelcome extends react__WEBPACK_IMPORTED_MODULE_0___default.a.PureComponent { + render() { + const { + props + } = this; + return react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("div", { + className: "outer-wrapper welcomeContainer" + }, react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("div", { + className: "welcomeContainerInner" + }, react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("main", null, react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement(_HeroText__WEBPACK_IMPORTED_MODULE_1__["HeroText"], { + title: props.title, + subtitle: props.subtitle + }), react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement(_FxCards__WEBPACK_IMPORTED_MODULE_2__["FxCards"], { + cards: props.cards, + metricsFlowUri: this.props.metricsFlowUri, + sendTelemetry: window.AWSendEventTelemetry, + utm_term: this.props.UTMTerm + }), react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement(_MSLocalized__WEBPACK_IMPORTED_MODULE_3__["Localized"], { + text: props.startButton.label + }, react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("button", { + className: "start-button", + onClick: this.props.handleStartBtnClick + }))))); + } + +} + +/***/ }), +/* 9 */ +/***/ (function(module, __webpack_exports__, __webpack_require__) { + +"use strict"; +__webpack_require__.r(__webpack_exports__); +/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "HeroText", function() { return HeroText; }); +/* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(1); +/* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_0__); +/* harmony import */ var _MSLocalized__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(4); +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + + +const HeroText = props => { + return react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement(react__WEBPACK_IMPORTED_MODULE_0___default.a.Fragment, null, react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement(_MSLocalized__WEBPACK_IMPORTED_MODULE_1__["Localized"], { + text: props.title + }, react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("h1", { + className: "welcome-title" + })), react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement(_MSLocalized__WEBPACK_IMPORTED_MODULE_1__["Localized"], { + text: props.subtitle + }, react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("h2", { + className: "welcome-subtitle" + }))); +}; + +/***/ }), +/* 10 */ +/***/ (function(module, __webpack_exports__, __webpack_require__) { + +"use strict"; +__webpack_require__.r(__webpack_exports__); +/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "FxCards", function() { return FxCards; }); +/* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(1); +/* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_0__); +/* harmony import */ var _asrouter_templates_FirstRun_addUtmParams__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(7); +/* harmony import */ var _asrouter_templates_OnboardingMessage_OnboardingMessage__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(11); +/* harmony import */ var _lib_aboutwelcome_utils__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(6); +function _extends() { _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return _extends.apply(this, arguments); } + +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + + + + +class FxCards extends react__WEBPACK_IMPORTED_MODULE_0___default.a.PureComponent { + constructor(props) { + super(props); + this.state = { + flowParams: null + }; + this.fetchFxAFlowParams = this.fetchFxAFlowParams.bind(this); + this.onCardAction = this.onCardAction.bind(this); + } + + componentDidUpdate() { + this.fetchFxAFlowParams(); + } + + componentDidMount() { + this.fetchFxAFlowParams(); + } + + async fetchFxAFlowParams() { + if (this.state.flowParams || !this.props.metricsFlowUri) { + return; + } + + const flowParams = await _lib_aboutwelcome_utils__WEBPACK_IMPORTED_MODULE_3__["AboutWelcomeUtils"].fetchFlowParams(this.props.metricsFlowUri); + this.setState({ + flowParams + }); + } + + onCardAction(action) { + let { + type, + data + } = action; + let UTMTerm = `aboutwelcome-${this.props.utm_term}-card`; + + if (action.type === "OPEN_URL") { + let url = new URL(action.data.args); + Object(_asrouter_templates_FirstRun_addUtmParams__WEBPACK_IMPORTED_MODULE_1__["addUtmParams"])(url, UTMTerm); + + if (action.addFlowParams && this.state.flowParams) { + url.searchParams.append("device_id", this.state.flowParams.deviceId); + url.searchParams.append("flow_id", this.state.flowParams.flowId); + url.searchParams.append("flow_begin_time", this.state.flowParams.flowBeginTime); + } + + data = { ...data, + args: url.toString() + }; + } + + _lib_aboutwelcome_utils__WEBPACK_IMPORTED_MODULE_3__["AboutWelcomeUtils"].handleUserAction({ + type, + data + }); + } + + render() { + const { + props + } = this; + return react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement(react__WEBPACK_IMPORTED_MODULE_0___default.a.Fragment, null, react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("div", { + className: `welcomeCardGrid show` + }, props.cards.map(card => react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement(_asrouter_templates_OnboardingMessage_OnboardingMessage__WEBPACK_IMPORTED_MODULE_2__["OnboardingCard"], _extends({ + key: card.id, + message: card, + className: "welcomeCard", + sendUserActionTelemetry: props.sendTelemetry, + onAction: this.onCardAction, + UISurface: "ABOUT_WELCOME" + }, card))))); + } + +} + +/***/ }), +/* 11 */ +/***/ (function(module, __webpack_exports__, __webpack_require__) { + +"use strict"; +__webpack_require__.r(__webpack_exports__); +/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "OnboardingCard", function() { return OnboardingCard; }); +/* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(1); +/* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_0__); +/* harmony import */ var _aboutwelcome_components_MSLocalized__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(4); +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + + +class OnboardingCard extends react__WEBPACK_IMPORTED_MODULE_0___default.a.PureComponent { + constructor(props) { + super(props); + this.onClick = this.onClick.bind(this); + } + + onClick() { + const { + props + } = this; + const ping = { + event: "CLICK_BUTTON", + message_id: props.id, + id: props.UISurface + }; + props.sendUserActionTelemetry(ping); + props.onAction(props.content.primary_button.action, props.message); + } + + render() { + const { + content + } = this.props; + const className = this.props.className || "onboardingMessage"; + return react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("div", { + className: className + }, react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("div", { + className: `onboardingMessageImage ${content.icon}` + }), react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("div", { + className: "onboardingContent" + }, react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("span", null, react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement(_aboutwelcome_components_MSLocalized__WEBPACK_IMPORTED_MODULE_1__["Localized"], { + text: content.title + }, react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("h2", { + className: "onboardingTitle" + })), react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement(_aboutwelcome_components_MSLocalized__WEBPACK_IMPORTED_MODULE_1__["Localized"], { + text: content.text + }, react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("p", { + className: "onboardingText" + }))), react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("span", { + className: "onboardingButtonContainer" + }, react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement(_aboutwelcome_components_MSLocalized__WEBPACK_IMPORTED_MODULE_1__["Localized"], { + text: content.primary_button.label + }, react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("button", { + className: "button onboardingButton", + onClick: this.onClick + }))))); + } + +} + +/***/ }), +/* 12 */ +/***/ (function(module, __webpack_exports__, __webpack_require__) { + +"use strict"; +__webpack_require__.r(__webpack_exports__); +/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "ReturnToAMO", function() { return ReturnToAMO; }); +/* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(1); +/* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_0__); +/* harmony import */ var _lib_aboutwelcome_utils__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(6); +/* harmony import */ var _MSLocalized__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(4); +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + + + +class ReturnToAMO extends react__WEBPACK_IMPORTED_MODULE_0___default.a.PureComponent { + constructor(props) { + super(props); + this.onClickAddExtension = this.onClickAddExtension.bind(this); + this.handleStartBtnClick = this.handleStartBtnClick.bind(this); + } + + onClickAddExtension() { + var _content$primary_butt, _content$primary_butt2; + + const { + content, + message_id, + url + } = this.props; + + if (!(content === null || content === void 0 ? void 0 : (_content$primary_butt = content.primary_button) === null || _content$primary_butt === void 0 ? void 0 : (_content$primary_butt2 = _content$primary_butt.action) === null || _content$primary_butt2 === void 0 ? void 0 : _content$primary_butt2.data)) { + return; + } // Set add-on url in action.data.url property from JSON + + + content.primary_button.action.data.url = url; + _lib_aboutwelcome_utils__WEBPACK_IMPORTED_MODULE_1__["AboutWelcomeUtils"].handleUserAction(content.primary_button.action); + const ping = { + event: "INSTALL", + event_context: { + source: "ADD_EXTENSION_BUTTON", + page: "about:welcome" + }, + message_id + }; + window.AWSendEventTelemetry(ping); + } + + handleStartBtnClick() { + const { + content, + message_id + } = this.props; + _lib_aboutwelcome_utils__WEBPACK_IMPORTED_MODULE_1__["AboutWelcomeUtils"].handleUserAction(content.startButton.action); + const ping = { + event: "CLICK_BUTTON", + event_context: { + source: content.startButton.message_id, + page: "about:welcome" + }, + message_id + }; + window.AWSendEventTelemetry(ping); + } + + render() { + const { + content + } = this.props; + + if (!content) { + return null; + } // For experiments, when needed below rendered UI allows settings hard coded strings + // directly inside JSON except for ReturnToAMOText which picks add-on name and icon from fluent string + + + return react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("div", { + className: "outer-wrapper onboardingContainer" + }, react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("main", { + className: "screen" + }, react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("div", { + className: "brand-logo" + }), react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("div", { + className: "welcome-text" + }, react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement(_MSLocalized__WEBPACK_IMPORTED_MODULE_2__["Localized"], { + text: content.subtitle + }, react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("h1", null)), react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement(_MSLocalized__WEBPACK_IMPORTED_MODULE_2__["Localized"], { + text: content.text + }, react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("h2", { + "data-l10n-args": this.props.name ? JSON.stringify({ + "addon-name": this.props.name + }) : null + }, react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("img", { + "data-l10n-name": "icon", + src: this.props.iconURL, + role: "presentation", + alt: "" + }))), react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement(_MSLocalized__WEBPACK_IMPORTED_MODULE_2__["Localized"], { + text: content.primary_button.label + }, react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("button", { + onClick: this.onClickAddExtension, + className: "primary" + })), react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement(_MSLocalized__WEBPACK_IMPORTED_MODULE_2__["Localized"], { + text: content.startButton.label + }, react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("button", { + onClick: this.handleStartBtnClick, + className: "secondary" + }))))); + } + +} +ReturnToAMO.defaultProps = _lib_aboutwelcome_utils__WEBPACK_IMPORTED_MODULE_1__["DEFAULT_RTAMO_CONTENT"]; + +/***/ }) +/******/ ]); \ No newline at end of file diff --git a/browser/components/newtab/aboutwelcome/content/aboutwelcome.css b/browser/components/newtab/aboutwelcome/content/aboutwelcome.css new file mode 100644 index 0000000000..5cb0e4a79f --- /dev/null +++ b/browser/components/newtab/aboutwelcome/content/aboutwelcome.css @@ -0,0 +1,538 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +html { + box-sizing: border-box; } + +*, +*::before, +*::after { + box-sizing: inherit; } + +*::-moz-focus-inner { + border: 0; } + +body { + margin: 0; } + +button, +input { + background-color: inherit; + color: inherit; + font-family: inherit; + font-size: inherit; } + +[hidden] { + display: none !important; } + +.onboardingMessageImage.addons { + background-image: url("chrome://activity-stream/content/data/content/assets/illustration-addons@2x.png"); } + +.onboardingMessageImage.privatebrowsing { + background-image: url("chrome://activity-stream/content/data/content/assets/illustration-privatebrowsing@2x.png"); } + +.onboardingMessageImage.screenshots { + background-image: url("chrome://activity-stream/content/data/content/assets/illustration-screenshots@2x.png"); } + +.onboardingMessageImage.gift { + background-image: url("chrome://activity-stream/content/data/content/assets/illustration-gift@2x.png"); } + +.onboardingMessageImage.sync { + background-image: url("chrome://activity-stream/content/data/content/assets/illustration-sync@2x.png"); } + +.onboardingMessageImage.devices { + background-image: url("chrome://activity-stream/content/data/content/assets/trailhead/card-illo-devices.svg"); } + +.onboardingMessageImage.fbcont { + background-image: url("chrome://activity-stream/content/data/content/assets/trailhead/card-illo-fbcont.svg"); } + +.onboardingMessageImage.import { + background-image: url("chrome://activity-stream/content/data/content/assets/trailhead/card-illo-import.svg"); } + +.onboardingMessageImage.ffmonitor { + background-image: url("chrome://activity-stream/content/data/content/assets/trailhead/card-illo-ffmonitor.svg"); } + +.onboardingMessageImage.ffsend { + background-image: url("chrome://activity-stream/content/data/content/assets/trailhead/card-illo-ffsend.svg"); } + +.onboardingMessageImage.lockwise { + background-image: url("chrome://activity-stream/content/data/content/assets/trailhead/card-illo-lockwise.svg"); } + +.onboardingMessageImage.mobile { + background-image: url("chrome://activity-stream/content/data/content/assets/trailhead/card-illo-mobile.svg"); } + +.onboardingMessageImage.pledge { + background-image: url("chrome://activity-stream/content/data/content/assets/trailhead/card-illo-pledge.svg"); } + +.onboardingMessageImage.pocket { + background-image: url("chrome://activity-stream/content/data/content/assets/trailhead/card-illo-pocket.svg"); } + +.onboardingMessageImage.private { + background-image: url("chrome://activity-stream/content/data/content/assets/trailhead/card-illo-private.svg"); } + +.onboardingMessageImage.sendtab { + background-image: url("chrome://activity-stream/content/data/content/assets/trailhead/card-illo-sendtab.svg"); } + +.onboardingMessageImage.tracking { + background-image: url("chrome://activity-stream/content/data/content/assets/trailhead/card-illo-tracking.svg"); } + +html { + height: 100%; } + +body { + --grey-subtitle: #4A4A4F; + --grey-subtitle-1: #696977; + --newtab-background-color: #EDEDF0; + --newtab-background-color-1: #F9F9FA; + --newtab-text-primary-color: #0C0C0D; + --newtab-text-conditional-color: #4A4A4F; + --newtab-button-primary-color: #0060DF; + --newtab-button-secondary-color: #0060DF; + --newtab-card-background-color: #FFF; + --newtab-card-shadow: 0 1px 4px 0 rgba(12, 12, 13, 0.4); + --tiles-theme-section-border-width: 1px; + --welcome-header-text-color: #2B2156; + --welcome-header-text-color-1: #20133A; + --welcome-card-button-background-color: rgba(12, 12, 13, 0.1); + --welcome-card-button-background-hover-color: rgba(12, 12, 13, 0.2); + --welcome-card-button-background-active-color: rgba(12, 12, 13, 0.3); + --welcome-button-box-shadow-color: #0A84FF; + --welcome-button-box-shadow-inset-color: rgba(10, 132, 255, 0.3); + --welcome-button-text-color: #FFF; + --welcome-button-background-hover-color: #003EAA; + --welcome-button-background-active-color: #002275; + --about-welcome-media-fade: linear-gradient(transparent, transparent 35%, #F9F9FA, #F9F9FA); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Ubuntu', 'Helvetica Neue', sans-serif; + font-size: 16px; + position: relative; + /* these two rules fix test failures in + "browser_ext_themes_ntp_colors" & "browser_ext_themes_ntp_colors_perwindow".*/ + color: var(--newtab-text-primary-color); + background-color: var(--newtab-background-color); } + body[lwt-newtab-brighttext] { + --newtab-background-color: #2A2A2E; + --newtab-background-color-1: #1D1133; + --newtab-text-primary-color: #F9F9FA; + --newtab-text-conditional-color: #F9F9FA; + --grey-subtitle-1: #FFF; + --newtab-button-primary-color: #0060DF; + --newtab-button-secondary-color: #FFF; + --newtab-card-background-color: #38383D; + --newtab-card-shadow: 0 1px 8px 0 rgba(12, 12, 13, 0.4); + --welcome-header-text-color: rgba(255, 255, 255, 0.6); + --welcome-header-text-color-1: #7542E5; + --welcome-card-button-background-color: rgba(12, 12, 13, 0.3); + --welcome-card-button-background-hover-color: rgba(12, 12, 13, 0.5); + --welcome-card-button-background-active-color: rgba(12, 12, 13, 0.7); + --welcome-button-box-shadow-color: #0A84FF; + --about-welcome-media-fade: linear-gradient(transparent, transparent 35%, #1D1133, #1D1133); } + +.welcomeCardGrid { + margin: 0; + margin-top: 32px; + display: grid; + grid-gap: 32px; + transition: opacity 0.4s; + transition-delay: 0.1s; + grid-auto-rows: 1fr; } + @media (min-width: 610px) { + .welcomeCardGrid { + grid-template-columns: repeat(auto-fit, 224px); } } + @media (min-width: 1122px) { + .welcomeCardGrid { + grid-template-columns: repeat(auto-fit, 309px); } } + +.welcomeContainer { + text-align: center; } + @media (min-width: 610px) { + .welcomeContainer { + max-height: 1000px; } } + .welcomeContainer h1 { + font-size: 36px; + font-weight: 200; + margin: 0 0 40px; + color: var(--welcome-header-text-color); } + .welcomeContainer .welcome-title { + margin-bottom: 5px; + line-height: 52px; } + .welcomeContainer .welcome-subtitle { + font-size: 28px; + font-weight: 200; + margin: 6px 0 0; + color: var(--grey-subtitle); + line-height: 42px; } + +.welcomeContainerInner { + margin: auto; + padding: 40px 25px; } + @media (min-width: 610px) { + .welcomeContainerInner { + width: 530px; } } + @media (min-width: 866px) { + .welcomeContainerInner { + width: 786px; } } + @media (min-width: 1122px) { + .welcomeContainerInner { + width: 1042px; } } + +.welcomeCard { + position: relative; + background: var(--newtab-card-background-color); + border-radius: 4px; + box-shadow: var(--newtab-card-shadow); + font-size: 13px; + padding: 20px 20px 60px; } + @media (max-width: 866px) { + .welcomeCard { + padding: 20px; } } + @media (min-width: 1122px) { + .welcomeCard { + font-size: 15px; } } + +.welcomeCard .onboardingTitle { + font-weight: normal; + color: var(--newtab-text-primary-color); + margin: 10px 0 4px; + font-size: 15px; } + @media (min-width: 1122px) { + .welcomeCard .onboardingTitle { + font-size: 18px; } } + +.welcomeCard .onboardingText { + margin: 0 0 60px; + color: var(--newtab-text-conditional-color); + line-height: 1.5; + font-weight: 200; } + +.welcomeCard .onboardingButton { + color: var(--newtab-text-conditional-color); + background: var(--welcome-card-button-background-color); + border: 0; + border-radius: 4px; + margin: 14px; + min-width: 70%; + padding: 6px 14px; + white-space: pre-wrap; + cursor: pointer; } + .welcomeCard .onboardingButton:focus, .welcomeCard .onboardingButton:hover { + box-shadow: none; + background: var(--welcome-card-button-background-hover-color); } + .welcomeCard .onboardingButton:focus { + outline: dotted 1px; } + .welcomeCard .onboardingButton:active { + background: var(--welcome-card-button-background-active-color); } + +.welcomeCard .onboardingButtonContainer { + position: absolute; + bottom: 16px; + left: 0; + width: 100%; + text-align: center; } + +.onboardingMessageImage { + height: 112px; + width: 180px; + background-size: auto 140px; + background-position: center center; + background-repeat: no-repeat; + display: inline-block; } + @media (max-width: 866px) { + .onboardingMessageImage { + height: 75px; + min-width: 80px; + background-size: 140px; } } + +.start-button { + border: 0; + font-size: 15px; + font-family: inherit; + font-weight: 200; + margin-inline-start: 12px; + margin: 30px 0 25px; + padding: 8px 16px; + white-space: nowrap; + background-color: var(--newtab-button-primary-color); + color: var(--welcome-button-text-color); + cursor: pointer; + border-radius: 2px; } + .start-button:focus { + background: var(--welcome-button-background-hover-color); + box-shadow: 0 0 0 1px var(--welcome-button-box-shadow-inset-color) inset, 0 0 0 1px var(--welcome-button-box-shadow-inset-color), 0 0 0 4px var(--welcome-button-box-shadow-color); } + .start-button:hover { + background: var(--welcome-button-background-hover-color); } + .start-button:active { + background: var(--welcome-button-background-active-color); } + +.onboardingContainer { + text-align: center; + overflow-x: auto; + height: 100vh; + background-color: var(--newtab-background-color-1); } + .onboardingContainer .screen { + display: flex; + flex-flow: column nowrap; + height: 100%; } + .onboardingContainer .brand-logo { + background: url("chrome://branding/content/about-logo.svg") top center/112px no-repeat; + padding: 112px 0 20px; + margin-top: 60px; } + .onboardingContainer .brand-logo.cta-top { + margin-top: 25px; } + .onboardingContainer .welcomeZap span { + position: relative; + z-index: 1; + white-space: nowrap; } + .onboardingContainer .welcomeZap .zap::after { + display: block; + background-repeat: no-repeat; + background-size: 100% 100%; + content: ''; + position: absolute; + top: calc(100% - 0.15em); + width: 100%; + height: 0.3em; + left: 0; + z-index: -1; } + .onboardingContainer .welcomeZap .zap.short::after { + background-image: url("chrome://activity-stream/content/data/content/assets/short-zap.svg"); } + .onboardingContainer .welcomeZap .zap.long::after { + background-image: url("chrome://activity-stream/content/data/content/assets/long-zap.svg"); } + .onboardingContainer .welcome-text { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + margin-bottom: 20px; } + .onboardingContainer .welcome-text h1, + .onboardingContainer .welcome-text h2 { + width: 860px; } + @media (max-width: 866px) { + .onboardingContainer .welcome-text h1, + .onboardingContainer .welcome-text h2 { + width: 530px; } } + @media (max-width: 610px) { + .onboardingContainer .welcome-text h1, + .onboardingContainer .welcome-text h2 { + width: 430px; } } + .onboardingContainer .welcome-text h1 { + font-size: 48px; + line-height: 56px; + font-weight: bold; + margin: 0 6px; + color: var(--welcome-header-text-color-1); } + .onboardingContainer .welcome-text h2 { + font-size: 18px; + font-weight: normal; + margin: 10px 6px 0; + color: var(--grey-subtitle-1); + line-height: 28px; + max-width: 750px; + letter-spacing: -0.01em; } + .onboardingContainer .welcome-text img { + margin-inline: 2px; + width: 20px; + height: 20px; } + .onboardingContainer .tiles-theme-container { + margin: 10px auto; + border: 0; } + .onboardingContainer .sr-only { + opacity: 0; + overflow: hidden; + position: absolute; } + .onboardingContainer .sr-only.input { + height: 1px; + width: 1px; } + .onboardingContainer .tiles-theme-section { + display: grid; + grid-gap: 21px; + grid-template-columns: repeat(4, auto); + /* --newtab-background-color-1 will be invisible, but it's necessary to + * keep the content from jumping around when it gets focus-within and + * does sprout a dotted border. This way it keeps a 1 pixel wide border + * either way so things don't change position. + */ + border: var(--tiles-theme-section-border-width) solid var(--newtab-background-color-1); } + @media (max-width: 610px) { + .onboardingContainer .tiles-theme-section { + grid-template-columns: repeat(2, auto); } } + .onboardingContainer .tiles-theme-section:focus-within { + border: var(--tiles-theme-section-border-width) dotted; } + .onboardingContainer .tiles-theme-section .theme { + display: flex; + flex-direction: column; + padding: 0; + width: 180px; + height: 145px; + color: #000; + background-color: #FFF; + box-shadow: 0 1px 4px 0 rgba(12, 12, 13, 0.4); + border-radius: 4px; + cursor: pointer; } + .onboardingContainer .tiles-theme-section .theme .icon { + background-size: cover; + border-top-left-radius: 4px; + border-top-right-radius: 4px; + height: 91px; } + .onboardingContainer .tiles-theme-section .theme .icon:dir(rtl) { + transform: scaleX(-1); } + .onboardingContainer .tiles-theme-section .theme .icon.light { + background-image: url("chrome://mozapps/content/extensions/firefox-compact-light.svg"); } + .onboardingContainer .tiles-theme-section .theme .icon.dark { + background-image: url("chrome://mozapps/content/extensions/firefox-compact-dark.svg"); } + .onboardingContainer .tiles-theme-section .theme .icon.automatic { + background-image: url("chrome://mozapps/content/extensions/default-theme.svg"); } + .onboardingContainer .tiles-theme-section .theme .icon.alpenglow { + background-image: url("chrome://mozapps/content/extensions/firefox-alpenglow.svg"); } + .onboardingContainer .tiles-theme-section .theme .text { + display: flex; + font-size: 14px; + font-weight: bold; + line-height: 22px; + margin-inline-start: 12px; + margin-top: 9px; } + .onboardingContainer .tiles-theme-section .theme.selected { + outline: 4px solid #0090ED; + outline-offset: -4px; } + .onboardingContainer .tiles-theme-section .theme:focus, .onboardingContainer .tiles-theme-section .theme:active { + outline: 4px solid #0090ED; + outline-offset: -4px; } + .onboardingContainer .tiles-container { + margin: 10px auto; } + .onboardingContainer .tiles-container.info { + padding: 6px 12px 12px; } + .onboardingContainer .tiles-container.info:hover, .onboardingContainer .tiles-container.info:focus { + background-color: rgba(217, 217, 227, 0.3); + border-radius: 4px; } + .onboardingContainer .tiles-topsites-section { + display: grid; + grid-gap: 24px; + grid-template-columns: repeat(5, auto); } + @media (max-width: 610px) { + .onboardingContainer .tiles-topsites-section { + grid-template-columns: repeat(3, auto); } } + .onboardingContainer .tiles-topsites-section .site { + width: 96px; } + .onboardingContainer .tiles-topsites-section .icon { + background-size: cover; + border-radius: 4px; + box-shadow: var(--newtab-card-shadow); + color: rgba(255, 255, 255, 0.5); + font-size: 24px; + font-weight: bold; + height: 96px; + line-height: 96px; } + .onboardingContainer .tiles-topsites-section .host { + font-size: 12px; + line-height: 36px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } + .onboardingContainer .tiles-topsites-section .site:nth-child(1) .icon { + background-color: #7542E5; } + .onboardingContainer .tiles-topsites-section .site:nth-child(2) .icon { + background-color: #952BB9; } + .onboardingContainer .tiles-topsites-section .site:nth-child(3) .icon { + background-color: #E31587; } + .onboardingContainer .tiles-topsites-section .site:nth-child(4) .icon { + background-color: #E25920; } + .onboardingContainer .tiles-topsites-section .site:nth-child(5) .icon { + background-color: #0250BB; } + .onboardingContainer .tiles-media-section { + align-self: center; + position: relative; + margin-top: -12px; + margin-bottom: -155px; } + .onboardingContainer .tiles-media-section .fade { + height: 390px; + width: 800px; + position: absolute; + background-image: var(--about-welcome-media-fade); } + .onboardingContainer .tiles-media-section .media { + height: 390px; + width: 800px; } + .onboardingContainer .tiles-media-section.privacy { + background: top no-repeat url("chrome://activity-stream/content/data/content/assets/firefox-protections.svg"); + height: 200px; + width: 800px; + margin: 0; } + .onboardingContainer .tiles-media-section.privacy.media { + opacity: 0; } + .onboardingContainer button { + font-family: inherit; + cursor: pointer; + border: 0; + border-radius: 4px; } + .onboardingContainer button.primary { + font-size: 16px; + margin-inline-start: 12px; + margin: 20px 0 0; + padding: 12px 20px; + white-space: nowrap; + background-color: var(--newtab-button-primary-color); + color: var(--welcome-button-text-color); + fill: currentColor; + position: relative; + z-index: 1; + border: 1px solid transparent; } + .onboardingContainer button.primary:focus { + background: var(--welcome-button-background-hover-color); + box-shadow: 0 0 0 4px var(--welcome-button-box-shadow-color); } + .onboardingContainer button.primary:hover { + background: var(--welcome-button-background-hover-color); } + .onboardingContainer button.primary:active { + background: var(--welcome-button-background-active-color); } + .onboardingContainer button.secondary { + background-color: initial; + text-decoration: underline; + display: block; + padding: 0; + width: auto; + color: var(--newtab-button-secondary-color); + margin-top: 14px; } + .onboardingContainer button.secondary:hover, .onboardingContainer button.secondary:active { + background-color: initial; } + .onboardingContainer .secondary-cta { + display: flex; + flex-direction: row; + justify-content: center; + font-size: 14px; } + .onboardingContainer .secondary-cta.top { + justify-content: end; + align-items: end; + padding-inline-end: 30px; + padding-top: 4px; } + @media (max-width: 610px) { + .onboardingContainer .secondary-cta.top { + justify-content: center; } } + .onboardingContainer .secondary-cta span { + color: var(--grey-subtitle-1); + margin: 0 4px; } + .onboardingContainer .helptext { + padding: 1em; + text-align: center; + color: var(--grey-subtitle-1); + font-size: 12px; + line-height: 18px; } + .onboardingContainer .helptext.default { + align-self: center; + max-width: 40%; } + .onboardingContainer .steps { + display: flex; + flex-direction: row; + justify-content: center; + margin-top: auto; + padding: 32px 0 66px; + z-index: 1; } + .onboardingContainer .steps.has-helptext { + padding-bottom: 0; } + .onboardingContainer .steps .indicator { + width: 60px; + height: 4px; + margin-inline-end: 4px; + margin-inline-start: 4px; + background: var(--grey-subtitle-1); + border-radius: 5px; + border: 1px solid transparent; + opacity: 0.25; } + .onboardingContainer .steps .indicator.current { + opacity: 1; } diff --git a/browser/components/newtab/aboutwelcome/content/aboutwelcome.html b/browser/components/newtab/aboutwelcome/content/aboutwelcome.html new file mode 100644 index 0000000000..39d2b88f97 --- /dev/null +++ b/browser/components/newtab/aboutwelcome/content/aboutwelcome.html @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/browser/components/newtab/aboutwelcome/lib/AboutWelcomeTelemetry.jsm b/browser/components/newtab/aboutwelcome/lib/AboutWelcomeTelemetry.jsm new file mode 100644 index 0000000000..8c103dd280 --- /dev/null +++ b/browser/components/newtab/aboutwelcome/lib/AboutWelcomeTelemetry.jsm @@ -0,0 +1,115 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +const EXPORTED_SYMBOLS = ["AboutWelcomeTelemetry"]; +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + PingCentre: "resource:///modules/PingCentre.jsm", + ClientID: "resource://gre/modules/ClientID.jsm", + Services: "resource://gre/modules/Services.jsm", + TelemetrySession: "resource://gre/modules/TelemetrySession.jsm", + AttributionCode: "resource:///modules/AttributionCode.jsm", +}); +XPCOMUtils.defineLazyServiceGetters(this, { + gUUIDGenerator: ["@mozilla.org/uuid-generator;1", "nsIUUIDGenerator"], +}); +XPCOMUtils.defineLazyPreferenceGetter( + this, + "structuredIngestionEndpointBase", + "browser.newtabpage.activity-stream.telemetry.structuredIngestion.endpoint", + "" +); +XPCOMUtils.defineLazyGetter(this, "telemetryClientId", () => + ClientID.getClientID() +); +XPCOMUtils.defineLazyGetter( + this, + "browserSessionId", + () => TelemetrySession.getMetadata("").sessionId +); +const TELEMETRY_TOPIC = "about:welcome"; +const PING_TYPE = "onboarding"; +const PING_VERSION = "1"; +const STRUCTURED_INGESTION_NAMESPACE_MS = "messaging-system"; + +class AboutWelcomeTelemetry { + constructor() { + XPCOMUtils.defineLazyPreferenceGetter( + this, + "telemetryEnabled", + "browser.newtabpage.activity-stream.telemetry", + false + ); + } + + /** + * Lazily initialize PingCentre for Activity Stream to send pings + */ + get pingCentre() { + Object.defineProperty(this, "pingCentre", { + value: new PingCentre({ topic: TELEMETRY_TOPIC }), + }); + return this.pingCentre; + } + + _generateStructuredIngestionEndpoint() { + const uuid = gUUIDGenerator.generateUUID().toString(); + // Structured Ingestion does not support the UUID generated by gUUIDGenerator, + // because it contains leading and trailing braces. Need to trim them first. + const docID = uuid.slice(1, -1); + const extension = `${STRUCTURED_INGESTION_NAMESPACE_MS}/${PING_TYPE}/${PING_VERSION}/${docID}`; + return `${structuredIngestionEndpointBase}/${extension}`; + } + + /** + * Attach browser attribution data to a ping payload. + * + * It intentionally queries the *cached* attribution data other than calling + * `getAttrDataAsync()` in order to minimize the overhead here. + * For the same reason, we are not querying the attribution data from + * `TelemetryEnvironment.currentEnvironment.settings`. + * + * In practice, it's very likely that the attribution data is already read + * and cached at some point by `AboutWelcomeParent`, so it should be able to + * read the cached results for the most if not all of the pings. + */ + _maybeAttachAttribution(ping) { + const attribution = AttributionCode.getCachedAttributionData(); + if (attribution && Object.keys(attribution).length) { + ping.attribution = attribution; + } + return ping; + } + + async _createPing(event) { + if (event.event_context && typeof event.event_context === "object") { + event.event_context = JSON.stringify(event.event_context); + } + let ping = { + ...event, + addon_version: Services.appinfo.appBuildID, + locale: Services.locale.appLocaleAsBCP47, + client_id: await telemetryClientId, + browser_session_id: browserSessionId, + }; + + return this._maybeAttachAttribution(ping); + } + + async sendTelemetry(event) { + if (!this.telemetryEnabled) { + return; + } + + const ping = await this._createPing(event); + this.pingCentre.sendStructuredIngestionPing( + ping, + this._generateStructuredIngestionEndpoint() + ); + } +} diff --git a/browser/components/newtab/actors/ASRouterChild.jsm b/browser/components/newtab/actors/ASRouterChild.jsm new file mode 100644 index 0000000000..62b3a756d3 --- /dev/null +++ b/browser/components/newtab/actors/ASRouterChild.jsm @@ -0,0 +1,109 @@ +/* vim: set ts=2 sw=2 sts=2 et tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +const EXPORTED_SYMBOLS = ["ASRouterChild"]; + +const { MESSAGE_TYPE_LIST, MESSAGE_TYPE_HASH: msg } = ChromeUtils.import( + "resource://activity-stream/common/ActorConstants.jsm" +); +const VALID_TYPES = new Set(MESSAGE_TYPE_LIST); + +class ASRouterChild extends JSWindowActorChild { + constructor() { + super(); + this.observers = new Set(); + } + + didDestroy() { + this.observers.clear(); + } + + handleEvent(event) { + switch (event.type) { + case "DOMWindowCreated": { + const window = this.contentWindow; + Cu.exportFunction(this.asRouterMessage.bind(this), window, { + defineAs: "ASRouterMessage", + }); + Cu.exportFunction(this.addParentListener.bind(this), window, { + defineAs: "ASRouterAddParentListener", + }); + Cu.exportFunction(this.removeParentListener.bind(this), window, { + defineAs: "ASRouterRemoveParentListener", + }); + break; + } + } + } + + addParentListener(listener) { + this.observers.add(listener); + } + + removeParentListener(listener) { + this.observers.delete(listener); + } + + receiveMessage({ name, data }) { + switch (name) { + case "EnterSnippetsPreviewMode": + case "UpdateAdminState": + case "ClearProviders": + case "ClearMessages": { + this.observers.forEach(listener => { + let result = Cu.cloneInto( + { + type: name, + data, + }, + this.contentWindow + ); + listener(result); + }); + break; + } + } + } + + wrapPromise(promise) { + return new this.contentWindow.Promise((resolve, reject) => + promise.then(resolve, reject) + ); + } + + sendQuery(aName, aData = null) { + return this.wrapPromise( + new Promise(resolve => { + super.sendQuery(aName, aData).then(result => { + resolve(Cu.cloneInto(result, this.contentWindow)); + }); + }) + ); + } + + asRouterMessage({ type, data }) { + if (VALID_TYPES.has(type)) { + switch (type) { + case msg.DISABLE_PROVIDER: + case msg.ENABLE_PROVIDER: + case msg.EXPIRE_QUERY_CACHE: + case msg.FORCE_WHATSNEW_PANEL: + case msg.CLOSE_WHATSNEW_PANEL: + case msg.IMPRESSION: + case msg.RESET_PROVIDER_PREF: + case msg.SET_PROVIDER_USER_PREF: + case msg.USER_ACTION: { + return this.sendAsyncMessage(type, data); + } + default: { + // these messages need a response + return this.sendQuery(type, data); + } + } + } + throw new Error(`Unexpected type "${type}"`); + } +} diff --git a/browser/components/newtab/actors/ASRouterParent.jsm b/browser/components/newtab/actors/ASRouterParent.jsm new file mode 100644 index 0000000000..358c532b23 --- /dev/null +++ b/browser/components/newtab/actors/ASRouterParent.jsm @@ -0,0 +1,115 @@ +/* vim: set ts=2 sw=2 sts=2 et tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const EXPORTED_SYMBOLS = ["ASRouterParent", "ASRouterTabs"]; + +const { + MESSAGE_TYPE_HASH: { BLOCK_MESSAGE_BY_ID }, +} = ChromeUtils.import("resource://activity-stream/common/ActorConstants.jsm"); +const { ASRouterNewTabHook } = ChromeUtils.import( + "resource://activity-stream/lib/ASRouterNewTabHook.jsm" +); +const { ASRouterDefaultConfig } = ChromeUtils.import( + "resource://activity-stream/lib/ASRouterDefaultConfig.jsm" +); + +class ASRouterTabs { + constructor({ asRouterNewTabHook }) { + this.actors = new Set(); + this.destroy = () => {}; + asRouterNewTabHook.createInstance(ASRouterDefaultConfig()); + this.loadingMessageHandler = asRouterNewTabHook + .getInstance() + .then(initializer => { + const parentProcessMessageHandler = initializer.connect({ + clearChildMessages: ids => this.messageAll("ClearMessages", ids), + clearChildProviders: ids => this.messageAll("ClearProviders", ids), + updateAdminState: state => this.messageAll("UpdateAdminState", state), + }); + this.destroy = () => { + initializer.disconnect(); + }; + return parentProcessMessageHandler; + }); + } + + get size() { + return this.actors.size; + } + + messageAll(message, data) { + return Promise.all( + [...this.actors].map(a => a.sendAsyncMessage(message, data)) + ); + } + + messagePreloaded(message, data) { + return Promise.all( + [...this.actors] + .filter(a => + a.browsingContext.embedderElement.getAttribute("preloaded") + ) + .map(a => a.sendAsyncMessage(message, data)) + ); + } + + registerActor(actor) { + this.actors.add(actor); + } + + unregisterActor(actor) { + this.actors.delete(actor); + } +} + +const defaultTabsFactory = () => + new ASRouterTabs({ asRouterNewTabHook: ASRouterNewTabHook }); + +class ASRouterParent extends JSWindowActorParent { + static tabs = null; + + static nextTabId = 0; + + constructor({ tabsFactory } = { tabsFactory: defaultTabsFactory }) { + super(); + this.tabsFactory = tabsFactory; + } + + actorCreated() { + ASRouterParent.tabs = ASRouterParent.tabs || this.tabsFactory(); + this.tabsFactory = null; + this.tabId = ++ASRouterParent.nextTabId; + ASRouterParent.tabs.registerActor(this); + } + + didDestroy() { + ASRouterParent.tabs.unregisterActor(this); + if (ASRouterParent.tabs.size < 1) { + ASRouterParent.tabs.destroy(); + ASRouterParent.tabs = null; + } + } + + getTab() { + return { + id: this.tabId, + browser: this.browsingContext.embedderElement, + }; + } + + receiveMessage({ name, data }) { + return ASRouterParent.tabs.loadingMessageHandler.then(handler => { + if (name === BLOCK_MESSAGE_BY_ID && data.preloadedOnly) { + return Promise.all([ + handler.handleMessage(name, data, this.getTab()), + ASRouterParent.tabs.messagePreloaded("ClearMessages", [data.id]), + ]).then(() => false); + } + return handler.handleMessage(name, data, this.getTab()); + }); + } +} diff --git a/browser/components/newtab/bin/render-activity-stream-html.js b/browser/components/newtab/bin/render-activity-stream-html.js new file mode 100644 index 0000000000..3c31255b7d --- /dev/null +++ b/browser/components/newtab/bin/render-activity-stream-html.js @@ -0,0 +1,130 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* eslint-disable no-console */ +const fs = require("fs"); +const { mkdir } = require("shelljs"); +const path = require("path"); + +// Note: DEFAULT_OPTIONS.baseUrl should match BASE_URL in aboutNewTabService.js +// in mozilla-central. +const DEFAULT_OPTIONS = { + addonPath: "..", + baseUrl: "resource://activity-stream/", +}; + +/** + * templateHTML - Generates HTML for activity stream, given some options and + * prerendered HTML if necessary. + * + * @param {obj} options + * {str} options.baseUrl The base URL for all local assets + * {bool} options.debug Should we use dev versions of JS libraries? + * {bool} options.noscripts Should we include scripts in the prerendered files? + * @return {str} An HTML document as a string + */ +function templateHTML(options) { + const debugString = options.debug ? "-dev" : ""; + // This list must match any similar ones in AboutNewTabService.jsm. + const scripts = [ + "chrome://browser/content/contentSearchUI.js", + "chrome://browser/content/contentSearchHandoffUI.js", + "chrome://browser/content/contentTheme.js", + `${options.baseUrl}vendor/react${debugString}.js`, + `${options.baseUrl}vendor/react-dom${debugString}.js`, + `${options.baseUrl}vendor/prop-types.js`, + `${options.baseUrl}vendor/redux.js`, + `${options.baseUrl}vendor/react-redux.js`, + `${options.baseUrl}vendor/react-transition-group.js`, + `${options.baseUrl}data/content/activity-stream.bundle.js`, + `${options.baseUrl}data/content/newtab-render.js`, + ]; + + // Add spacing and script tags + const scriptRender = `\n${scripts + .map(script => ` `) + .join("\n")}`; + + return ` + + + + + + + + + + + + + + + + +
+ ${ + options.noscripts ? "" : scriptRender + } + + +`.trimLeft(); +} + +/** + * writeFiles - Writes to the desired files the result of a template given + * various prerendered data and options. + * + * @param {string} destPath Path to write the files to + * @param {Map} filesMap Mapping of a string file name to templater + * @param {Object} options Various options for the templater + */ +function writeFiles(destPath, filesMap, options) { + for (const [file, templater] of filesMap) { + console.log("\x1b[32m", `✓ ${file}`, "\x1b[0m"); + fs.writeFileSync(path.join(destPath, file), templater({ options })); + } +} + +const STATIC_FILES = new Map([ + ["activity-stream.html", ({ options }) => templateHTML(options)], + [ + "activity-stream-debug.html", + ({ options }) => templateHTML(Object.assign({}, options, { debug: true })), + ], + [ + "activity-stream-noscripts.html", + ({ options }) => + templateHTML(Object.assign({}, options, { noscripts: true })), + ], +]); + +/** + * main - Parses command line arguments, generates html and js with templates, + * and writes files to their specified locations. + */ +function main() { + // eslint-disable-line max-statements + // This code parses command line arguments passed to this script. + // Note: process.argv.slice(2) is necessary because the first two items in + // process.argv are paths + const args = require("minimist")(process.argv.slice(2), { + alias: { + addonPath: "a", + baseUrl: "b", + }, + }); + + const options = Object.assign({ debug: false }, DEFAULT_OPTIONS, args || {}); + const addonPath = path.resolve(__dirname, options.addonPath); + const prerenderedPath = path.join(addonPath, "prerendered"); + console.log(`Writing prerendered files to ${prerenderedPath}:`); + + mkdir("-p", prerenderedPath); + writeFiles(prerenderedPath, STATIC_FILES, options); +} + +main(); diff --git a/browser/components/newtab/bin/try-runner.js b/browser/components/newtab/bin/try-runner.js new file mode 100644 index 0000000000..d2e9404db6 --- /dev/null +++ b/browser/components/newtab/bin/try-runner.js @@ -0,0 +1,179 @@ +/* eslint-disable no-console */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at . */ + +/* + * A small test runner/reporter for node-based tests, + * which are run via taskcluster node(debugger). + * + * Forked from + * https://searchfox.org/mozilla-central/source/devtools/client/debugger/bin/try-runner.js + */ + +const { execFileSync } = require("child_process"); +const { readFileSync } = require("fs"); +const path = require("path"); + +function logErrors(tool, errors) { + for (const error of errors) { + console.log(`TEST-UNEXPECTED-FAIL ${tool} | ${error}`); + } + return errors; +} + +function execOut(...args) { + let exitCode = 0; + let out; + let err; + + try { + out = execFileSync(...args, { + silent: false, + }); + } catch (e) { + // For debugging on (eg) try server... + // + // if (e) { + // logErrors("execOut", ["execFileSync returned exception: ", e]); + // } + + out = e && e.stdout; + err = e && e.stderr; + exitCode = e && e.status; + } + return { exitCode, out: out && out.toString(), err: err && err.toString() }; +} + +function logStart(name) { + console.log(`TEST START | ${name}`); +} + +function checkBundles() { + logStart("checkBundles"); + + const ASbundle = path.join("data", "content", "activity-stream.bundle.js"); + const AWbundle = path.join( + "aboutwelcome", + "content", + "aboutwelcome.bundle.js" + ); + let errors = []; + + let ASbefore = readFileSync(ASbundle, "utf8"); + let AWbefore = readFileSync(AWbundle, "utf8"); + + execOut("npm", ["run", "bundle"]); + + let ASafter = readFileSync(ASbundle, "utf8"); + let AWafter = readFileSync(AWbundle, "utf8"); + + if (ASbefore !== ASafter) { + errors.push("Activity Stream bundle out of date"); + } + + if (AWbefore !== AWafter) { + errors.push("About:welcome bundle out of date"); + } + + logErrors("checkBundles", errors); + return errors.length === 0; +} + +function karma() { + logStart("karma"); + + const errors = []; + const { exitCode, out } = execOut("npm", [ + "run", + "testmc:unit", + // , "--", "--log-level", "--verbose", + // to debug the karma integration, uncomment the above line + ]); + + // karma spits everything to stdout, not stderr, so if nothing came back on + // stdout, give up now. + if (!out) { + return false; + } + + // Detect mocha failures + let jsonContent; + try { + // Note that this will be overwritten at each run, but that shouldn't + // matter. + jsonContent = readFileSync(path.join("logs", "karma-run-results.json")); + } catch (ex) { + console.error("exception reading karma-run-results.json: ", ex); + return false; + } + const results = JSON.parse(jsonContent); + // eslint-disable-next-line guard-for-in + for (let testArray in results.result) { + let failedTests = Array.from(results.result[testArray]).filter( + test => !test.success && !test.skipped + ); + + errors.push( + ...failedTests.map( + test => `${test.suite.join(":")} ${test.description}: ${test.log[0]}` + ) + ); + } + + // Detect istanbul failures (coverage thresholds set in karma config) + const coverage = out.match(/ERROR.+coverage-istanbul.+/g); + if (coverage) { + errors.push(...coverage.map(line => line.match(/Coverage.+/)[0])); + } + + logErrors("karma", errors); + + // Pass if there's no detected errors and nothing unexpected. + return errors.length === 0 && !exitCode; +} + +function sasslint() { + logStart("sasslint"); + const { exitCode, out } = execOut("npm", [ + "run", + "--silent", + "lint:sasslint", + "--", + "--format", + "json", + ]); + + // Successful exit and no output means sasslint passed. + if (!exitCode && !out.length) { + return true; + } + + let fileObjects = JSON.parse(out); + let filesWithIssues = fileObjects.filter( + file => file.warningCount || file.errorCount + ); + + let errs = []; + let errorString; + filesWithIssues.forEach(file => { + file.messages.forEach(messageObj => { + errorString = `${file.filePath}(${messageObj.line}, ${messageObj.column}): ${messageObj.message} (${messageObj.ruleId})`; + errs.push(errorString); + }); + }); + + const errors = logErrors("sasslint", errs); + + // Pass if there's no detected errors and nothing unexpected. + return errors.length === 0 && !exitCode; +} + +const tests = {}; +const success = [checkBundles, karma, sasslint].every( + t => (tests[t.name] = t()) +); +console.log(tests); + +process.exitCode = success ? 0 : 1; +console.log("CODE", process.exitCode); diff --git a/browser/components/newtab/bin/vendor.js b/browser/components/newtab/bin/vendor.js new file mode 100644 index 0000000000..3d929dcf4b --- /dev/null +++ b/browser/components/newtab/bin/vendor.js @@ -0,0 +1,38 @@ +#!/usr/bin/env node +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* eslint-disable no-console */ + +const { cp, set } = require("shelljs"); +const path = require("path"); + +const filesToVendor = { + // XXX currently these two licenses are identical. Perhaps we should check + // in case that changes at some point in the future. + "react/LICENSE": "REACT_AND_REACT_DOM_LICENSE", + "react/umd/react.production.min.js": "react.js", + "react/umd/react.development.js": "react-dev.js", + "react-dom/umd/react-dom.production.min.js": "react-dom.js", + "react-dom/umd/react-dom.development.js": "react-dom-dev.js", + "react-dom/umd/react-dom-server.browser.production.min.js": + "react-dom-server.js", + "react-redux/LICENSE.md": "REACT_REDUX_LICENSE", + "react-redux/dist/react-redux.min.js": "react-redux.js", + "react-transition-group/dist/react-transition-group.min.js": + "react-transition-group.js", + "react-transition-group/LICENSE": "REACT_TRANSITION_GROUP_LICENSE", +}; + +set("-v"); // Echo all the copy commands so the user can see what's going on +for (let srcPath of Object.keys(filesToVendor)) { + cp( + path.join("node_modules", srcPath), + path.join("vendor", filesToVendor[srcPath]) + ); +} + +console.log(` +Check to see if any license files have changed, and, if so, be sure to update +https://searchfox.org/mozilla-central/source/toolkit/content/license.html`); diff --git a/browser/components/newtab/common/Actions.jsm b/browser/components/newtab/common/Actions.jsm new file mode 100644 index 0000000000..ce108b415e --- /dev/null +++ b/browser/components/newtab/common/Actions.jsm @@ -0,0 +1,451 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +this.MAIN_MESSAGE_TYPE = "ActivityStream:Main"; +this.CONTENT_MESSAGE_TYPE = "ActivityStream:Content"; +this.PRELOAD_MESSAGE_TYPE = "ActivityStream:PreloadedBrowser"; +this.UI_CODE = 1; +this.BACKGROUND_PROCESS = 2; + +/** + * globalImportContext - Are we in UI code (i.e. react, a dom) or some kind of background process? + * Use this in action creators if you need different logic + * for ui/background processes. + */ +const globalImportContext = + typeof Window === "undefined" ? BACKGROUND_PROCESS : UI_CODE; +// Export for tests +this.globalImportContext = globalImportContext; + +// Create an object that avoids accidental differing key/value pairs: +// { +// INIT: "INIT", +// UNINIT: "UNINIT" +// } +const actionTypes = {}; +for (const type of [ + "ABOUT_SPONSORED_TOP_SITES", + "ADDONS_INFO_REQUEST", + "ADDONS_INFO_RESPONSE", + "ARCHIVE_FROM_POCKET", + "AS_ROUTER_INITIALIZED", + "AS_ROUTER_PREF_CHANGED", + "AS_ROUTER_TARGETING_UPDATE", + "AS_ROUTER_TELEMETRY_USER_EVENT", + "BLOCK_URL", + "BOOKMARK_URL", + "CLEAR_PREF", + "COPY_DOWNLOAD_LINK", + "DELETE_BOOKMARK_BY_ID", + "DELETE_FROM_POCKET", + "DELETE_HISTORY_URL", + "DIALOG_CANCEL", + "DIALOG_OPEN", + "DISCOVERY_STREAM_COLLECTION_DISMISSIBLE_TOGGLE", + "DISCOVERY_STREAM_CONFIG_CHANGE", + "DISCOVERY_STREAM_CONFIG_RESET", + "DISCOVERY_STREAM_CONFIG_RESET_DEFAULTS", + "DISCOVERY_STREAM_CONFIG_SETUP", + "DISCOVERY_STREAM_CONFIG_SET_VALUE", + "DISCOVERY_STREAM_DEV_EXPIRE_CACHE", + "DISCOVERY_STREAM_DEV_IDLE_DAILY", + "DISCOVERY_STREAM_DEV_SYNC_RS", + "DISCOVERY_STREAM_DEV_SYSTEM_TICK", + "DISCOVERY_STREAM_FEEDS_UPDATE", + "DISCOVERY_STREAM_FEED_UPDATE", + "DISCOVERY_STREAM_IMPRESSION_STATS", + "DISCOVERY_STREAM_LAYOUT_RESET", + "DISCOVERY_STREAM_LAYOUT_UPDATE", + "DISCOVERY_STREAM_LINK_BLOCKED", + "DISCOVERY_STREAM_LOADED_CONTENT", + "DISCOVERY_STREAM_PERSONALIZATION_INIT", + "DISCOVERY_STREAM_PERSONALIZATION_LAST_UPDATED", + "DISCOVERY_STREAM_PERSONALIZATION_VERSION", + "DISCOVERY_STREAM_PERSONALIZATION_VERSION_TOGGLE", + "DISCOVERY_STREAM_RETRY_FEED", + "DISCOVERY_STREAM_SPOCS_CAPS", + "DISCOVERY_STREAM_SPOCS_ENDPOINT", + "DISCOVERY_STREAM_SPOCS_PLACEMENTS", + "DISCOVERY_STREAM_SPOCS_UPDATE", + "DISCOVERY_STREAM_SPOC_BLOCKED", + "DISCOVERY_STREAM_SPOC_IMPRESSION", + "DOWNLOAD_CHANGED", + "FAKE_FOCUS_SEARCH", + "FILL_SEARCH_TERM", + "HANDOFF_SEARCH_TO_AWESOMEBAR", + "HIDE_PRIVACY_INFO", + "HIDE_SEARCH", + "INIT", + "NEW_TAB_INIT", + "NEW_TAB_INITIAL_STATE", + "NEW_TAB_LOAD", + "NEW_TAB_REHYDRATED", + "NEW_TAB_STATE_REQUEST", + "NEW_TAB_UNLOAD", + "OPEN_DOWNLOAD_FILE", + "OPEN_LINK", + "OPEN_NEW_WINDOW", + "OPEN_PRIVATE_WINDOW", + "OPEN_WEBEXT_SETTINGS", + "PARTNER_LINK_ATTRIBUTION", + "PLACES_BOOKMARK_ADDED", + "PLACES_BOOKMARK_REMOVED", + "PLACES_HISTORY_CLEARED", + "PLACES_LINKS_CHANGED", + "PLACES_LINK_BLOCKED", + "PLACES_LINK_DELETED", + "PLACES_SAVED_TO_POCKET", + "POCKET_CTA", + "POCKET_LINK_DELETED_OR_ARCHIVED", + "POCKET_LOGGED_IN", + "POCKET_WAITING_FOR_SPOC", + "PREFS_INITIAL_VALUES", + "PREF_CHANGED", + "PREVIEW_REQUEST", + "PREVIEW_REQUEST_CANCEL", + "PREVIEW_RESPONSE", + "REMOVE_DOWNLOAD_FILE", + "RICH_ICON_MISSING", + "SAVE_SESSION_PERF_DATA", + "SAVE_TO_POCKET", + "SCREENSHOT_UPDATED", + "SECTION_DEREGISTER", + "SECTION_DISABLE", + "SECTION_ENABLE", + "SECTION_MOVE", + "SECTION_OPTIONS_CHANGED", + "SECTION_REGISTER", + "SECTION_UPDATE", + "SECTION_UPDATE_CARD", + "SETTINGS_CLOSE", + "SETTINGS_OPEN", + "SET_PREF", + "SHOW_DOWNLOAD_FILE", + "SHOW_FIREFOX_ACCOUNTS", + "SHOW_PRIVACY_INFO", + "SHOW_SEARCH", + "SKIPPED_SIGNIN", + "SNIPPETS_BLOCKLIST_CLEARED", + "SNIPPETS_BLOCKLIST_UPDATED", + "SNIPPETS_DATA", + "SNIPPETS_PREVIEW_MODE", + "SNIPPETS_RESET", + "SNIPPET_BLOCKED", + "SUBMIT_EMAIL", + "SUBMIT_SIGNIN", + "SYSTEM_TICK", + "TELEMETRY_IMPRESSION_STATS", + "TELEMETRY_USER_EVENT", + "TOP_SITES_CANCEL_EDIT", + "TOP_SITES_CLOSE_SEARCH_SHORTCUTS_MODAL", + "TOP_SITES_EDIT", + "TOP_SITES_INSERT", + "TOP_SITES_OPEN_SEARCH_SHORTCUTS_MODAL", + "TOP_SITES_PIN", + "TOP_SITES_PREFS_UPDATED", + "TOP_SITES_UNPIN", + "TOP_SITES_UPDATED", + "TOTAL_BOOKMARKS_REQUEST", + "TOTAL_BOOKMARKS_RESPONSE", + "UNINIT", + "UPDATE_PINNED_SEARCH_SHORTCUTS", + "UPDATE_SEARCH_SHORTCUTS", + "UPDATE_SECTION_PREFS", + "WEBEXT_CLICK", + "WEBEXT_DISMISS", +]) { + actionTypes[type] = type; +} + +// Helper function for creating routed actions between content and main +// Not intended to be used by consumers +function _RouteMessage(action, options) { + const meta = action.meta ? { ...action.meta } : {}; + if (!options || !options.from || !options.to) { + throw new Error( + "Routed Messages must have options as the second parameter, and must at least include a .from and .to property." + ); + } + // For each of these fields, if they are passed as an option, + // add them to the action. If they are not defined, remove them. + ["from", "to", "toTarget", "fromTarget", "skipMain", "skipLocal"].forEach( + o => { + if (typeof options[o] !== "undefined") { + meta[o] = options[o]; + } else if (meta[o]) { + delete meta[o]; + } + } + ); + return { ...action, meta }; +} + +/** + * AlsoToMain - Creates a message that will be dispatched locally and also sent to the Main process. + * + * @param {object} action Any redux action (required) + * @param {object} options + * @param {bool} skipLocal Used by OnlyToMain to skip the main reducer + * @param {string} fromTarget The id of the content port from which the action originated. (optional) + * @return {object} An action with added .meta properties + */ +function AlsoToMain(action, fromTarget, skipLocal) { + return _RouteMessage(action, { + from: CONTENT_MESSAGE_TYPE, + to: MAIN_MESSAGE_TYPE, + fromTarget, + skipLocal, + }); +} + +/** + * OnlyToMain - Creates a message that will be sent to the Main process and skip the local reducer. + * + * @param {object} action Any redux action (required) + * @param {object} options + * @param {string} fromTarget The id of the content port from which the action originated. (optional) + * @return {object} An action with added .meta properties + */ +function OnlyToMain(action, fromTarget) { + return AlsoToMain(action, fromTarget, true); +} + +/** + * BroadcastToContent - Creates a message that will be dispatched to main and sent to ALL content processes. + * + * @param {object} action Any redux action (required) + * @return {object} An action with added .meta properties + */ +function BroadcastToContent(action) { + return _RouteMessage(action, { + from: MAIN_MESSAGE_TYPE, + to: CONTENT_MESSAGE_TYPE, + }); +} + +/** + * AlsoToOneContent - Creates a message that will be will be dispatched to the main store + * and also sent to a particular Content process. + * + * @param {object} action Any redux action (required) + * @param {string} target The id of a content port + * @param {bool} skipMain Used by OnlyToOneContent to skip the main process + * @return {object} An action with added .meta properties + */ +function AlsoToOneContent(action, target, skipMain) { + if (!target) { + throw new Error( + "You must provide a target ID as the second parameter of AlsoToOneContent. If you want to send to all content processes, use BroadcastToContent" + ); + } + return _RouteMessage(action, { + from: MAIN_MESSAGE_TYPE, + to: CONTENT_MESSAGE_TYPE, + toTarget: target, + skipMain, + }); +} + +/** + * OnlyToOneContent - Creates a message that will be sent to a particular Content process + * and skip the main reducer. + * + * @param {object} action Any redux action (required) + * @param {string} target The id of a content port + * @return {object} An action with added .meta properties + */ +function OnlyToOneContent(action, target) { + return AlsoToOneContent(action, target, true); +} + +/** + * AlsoToPreloaded - Creates a message that dispatched to the main reducer and also sent to the preloaded tab. + * + * @param {object} action Any redux action (required) + * @return {object} An action with added .meta properties + */ +function AlsoToPreloaded(action) { + return _RouteMessage(action, { + from: MAIN_MESSAGE_TYPE, + to: PRELOAD_MESSAGE_TYPE, + }); +} + +/** + * UserEvent - A telemetry ping indicating a user action. This should only + * be sent from the UI during a user session. + * + * @param {object} data Fields to include in the ping (source, etc.) + * @return {object} An AlsoToMain action + */ +function UserEvent(data) { + return AlsoToMain({ + type: actionTypes.TELEMETRY_USER_EVENT, + data, + }); +} + +/** + * ASRouterUserEvent - A telemetry ping indicating a user action from AS router. This should only + * be sent from the UI during a user session. + * + * @param {object} data Fields to include in the ping (source, etc.) + * @return {object} An AlsoToMain action + */ +function ASRouterUserEvent(data) { + return AlsoToMain({ + type: actionTypes.AS_ROUTER_TELEMETRY_USER_EVENT, + data, + }); +} + +/** + * ImpressionStats - A telemetry ping indicating an impression stats. + * + * @param {object} data Fields to include in the ping + * @param {int} importContext (For testing) Override the import context for testing. + * #return {object} An action. For UI code, a AlsoToMain action. + */ +function ImpressionStats(data, importContext = globalImportContext) { + const action = { + type: actionTypes.TELEMETRY_IMPRESSION_STATS, + data, + }; + return importContext === UI_CODE ? AlsoToMain(action) : action; +} + +/** + * DiscoveryStreamImpressionStats - A telemetry ping indicating an impression stats in Discovery Stream. + * + * @param {object} data Fields to include in the ping + * @param {int} importContext (For testing) Override the import context for testing. + * #return {object} An action. For UI code, a AlsoToMain action. + */ +function DiscoveryStreamImpressionStats( + data, + importContext = globalImportContext +) { + const action = { + type: actionTypes.DISCOVERY_STREAM_IMPRESSION_STATS, + data, + }; + return importContext === UI_CODE ? AlsoToMain(action) : action; +} + +/** + * DiscoveryStreamLoadedContent - A telemetry ping indicating a content gets loaded in Discovery Stream. + * + * @param {object} data Fields to include in the ping + * @param {int} importContext (For testing) Override the import context for testing. + * #return {object} An action. For UI code, a AlsoToMain action. + */ +function DiscoveryStreamLoadedContent( + data, + importContext = globalImportContext +) { + const action = { + type: actionTypes.DISCOVERY_STREAM_LOADED_CONTENT, + data, + }; + return importContext === UI_CODE ? AlsoToMain(action) : action; +} + +function SetPref(name, value, importContext = globalImportContext) { + const action = { type: actionTypes.SET_PREF, data: { name, value } }; + return importContext === UI_CODE ? AlsoToMain(action) : action; +} + +function WebExtEvent(type, data, importContext = globalImportContext) { + if (!data || !data.source) { + throw new Error( + 'WebExtEvent actions should include a property "source", the id of the webextension that should receive the event.' + ); + } + const action = { type, data }; + return importContext === UI_CODE ? AlsoToMain(action) : action; +} + +this.actionTypes = actionTypes; + +this.actionCreators = { + BroadcastToContent, + UserEvent, + ASRouterUserEvent, + ImpressionStats, + AlsoToOneContent, + OnlyToOneContent, + AlsoToMain, + OnlyToMain, + AlsoToPreloaded, + SetPref, + WebExtEvent, + DiscoveryStreamImpressionStats, + DiscoveryStreamLoadedContent, +}; + +// These are helpers to test for certain kinds of actions +this.actionUtils = { + isSendToMain(action) { + if (!action.meta) { + return false; + } + return ( + action.meta.to === MAIN_MESSAGE_TYPE && + action.meta.from === CONTENT_MESSAGE_TYPE + ); + }, + isBroadcastToContent(action) { + if (!action.meta) { + return false; + } + if (action.meta.to === CONTENT_MESSAGE_TYPE && !action.meta.toTarget) { + return true; + } + return false; + }, + isSendToOneContent(action) { + if (!action.meta) { + return false; + } + if (action.meta.to === CONTENT_MESSAGE_TYPE && action.meta.toTarget) { + return true; + } + return false; + }, + isSendToPreloaded(action) { + if (!action.meta) { + return false; + } + return ( + action.meta.to === PRELOAD_MESSAGE_TYPE && + action.meta.from === MAIN_MESSAGE_TYPE + ); + }, + isFromMain(action) { + if (!action.meta) { + return false; + } + return ( + action.meta.from === MAIN_MESSAGE_TYPE && + action.meta.to === CONTENT_MESSAGE_TYPE + ); + }, + getPortIdOfSender(action) { + return (action.meta && action.meta.fromTarget) || null; + }, + _RouteMessage, +}; + +const EXPORTED_SYMBOLS = [ + "actionTypes", + "actionCreators", + "actionUtils", + "globalImportContext", + "UI_CODE", + "BACKGROUND_PROCESS", + "MAIN_MESSAGE_TYPE", + "CONTENT_MESSAGE_TYPE", + "PRELOAD_MESSAGE_TYPE", +]; diff --git a/browser/components/newtab/common/ActorConstants.jsm b/browser/components/newtab/common/ActorConstants.jsm new file mode 100644 index 0000000000..edeb19bd3e --- /dev/null +++ b/browser/components/newtab/common/ActorConstants.jsm @@ -0,0 +1,45 @@ +/* vim: set ts=2 sw=2 sts=2 et tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +const MESSAGE_TYPE_LIST = [ + "BLOCK_MESSAGE_BY_ID", + "USER_ACTION", + "IMPRESSION", + "TRIGGER", + "NEWTAB_MESSAGE_REQUEST", + "DOORHANGER_TELEMETRY", + "TOOLBAR_BADGE_TELEMETRY", + "TOOLBAR_PANEL_TELEMETRY", + "MOMENTS_PAGE_TELEMETRY", + "INFOBAR_TELEMETRY", + "AS_ROUTER_TELEMETRY_USER_EVENT", + + // Admin types + "ADMIN_CONNECT_STATE", + "UNBLOCK_MESSAGE_BY_ID", + "UNBLOCK_ALL", + "BLOCK_BUNDLE", + "UNBLOCK_BUNDLE", + "DISABLE_PROVIDER", + "ENABLE_PROVIDER", + "EVALUATE_JEXL_EXPRESSION", + "EXPIRE_QUERY_CACHE", + "FORCE_ATTRIBUTION", + "FORCE_WHATSNEW_PANEL", + "CLOSE_WHATSNEW_PANEL", + "OVERRIDE_MESSAGE", + "MODIFY_MESSAGE_JSON", + "RESET_PROVIDER_PREF", + "SET_PROVIDER_USER_PREF", + "RESET_GROUPS_STATE", +]; + +const MESSAGE_TYPE_HASH = MESSAGE_TYPE_LIST.reduce((hash, value) => { + hash[value] = value; + return hash; +}, {}); + +const EXPORTED_SYMBOLS = ["MESSAGE_TYPE_LIST", "MESSAGE_TYPE_HASH"]; diff --git a/browser/components/newtab/common/Dedupe.jsm b/browser/components/newtab/common/Dedupe.jsm new file mode 100644 index 0000000000..280615ba6a --- /dev/null +++ b/browser/components/newtab/common/Dedupe.jsm @@ -0,0 +1,38 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +this.Dedupe = class Dedupe { + constructor(createKey) { + this.createKey = createKey || this.defaultCreateKey; + } + + defaultCreateKey(item) { + return item; + } + + /** + * Dedupe any number of grouped elements favoring those from earlier groups. + * + * @param {Array} groups Contains an arbitrary number of arrays of elements. + * @returns {Array} A matching array of each provided group deduped. + */ + group(...groups) { + const globalKeys = new Set(); + const result = []; + for (const values of groups) { + const valueMap = new Map(); + for (const value of values) { + const key = this.createKey(value); + if (!globalKeys.has(key) && !valueMap.has(key)) { + valueMap.set(key, value); + } + } + result.push(valueMap); + valueMap.forEach((value, key) => globalKeys.add(key)); + } + return result.map(m => Array.from(m.values())); + } +}; + +const EXPORTED_SYMBOLS = ["Dedupe"]; diff --git a/browser/components/newtab/common/Reducers.jsm b/browser/components/newtab/common/Reducers.jsm new file mode 100644 index 0000000000..b1cf3db2c5 --- /dev/null +++ b/browser/components/newtab/common/Reducers.jsm @@ -0,0 +1,834 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +const { actionTypes: at } = ChromeUtils.import( + "resource://activity-stream/common/Actions.jsm" +); +const { Dedupe } = ChromeUtils.import( + "resource://activity-stream/common/Dedupe.jsm" +); + +const TOP_SITES_DEFAULT_ROWS = 1; +const TOP_SITES_MAX_SITES_PER_ROW = 8; +const PREF_PERSONALIZATION_VERSION = "discoverystream.personalization.version"; +const PREF_COLLECTION_DISMISSIBLE = "discoverystream.isCollectionDismissible"; + +const dedupe = new Dedupe(site => site && site.url); + +const INITIAL_STATE = { + App: { + // Have we received real data from the app yet? + initialized: false, + locale: "", + }, + ASRouter: { initialized: false }, + Snippets: { initialized: false }, + TopSites: { + // Have we received real data from history yet? + initialized: false, + // The history (and possibly default) links + rows: [], + // Used in content only to dispatch action to TopSiteForm. + editForm: null, + // Used in content only to open the SearchShortcutsForm modal. + showSearchShortcutsForm: false, + // The list of available search shortcuts. + searchShortcuts: [], + }, + Prefs: { + initialized: false, + values: {}, + }, + Dialog: { + visible: false, + data: {}, + }, + Sections: [], + Pocket: { + isUserLoggedIn: null, + pocketCta: {}, + waitingForSpoc: true, + }, + // This is the new pocket configurable layout state. + DiscoveryStream: { + // This is a JSON-parsed copy of the discoverystream.config pref value. + config: { enabled: false, layout_endpoint: "" }, + layout: [], + lastUpdated: null, + isPrivacyInfoModalVisible: false, + isCollectionDismissible: false, + feeds: { + data: { + // "https://foo.com/feed1": {lastUpdated: 123, data: []} + }, + loaded: false, + }, + spocs: { + spocs_endpoint: "", + lastUpdated: null, + data: { + // "spocs": {title: "", context: "", items: []}, + // "placement1": {title: "", context: "", items: []}, + }, + loaded: false, + frequency_caps: [], + blocked: [], + placements: [], + }, + }, + Personalization: { + version: 1, + lastUpdated: null, + initialized: false, + }, + Search: { + // When search hand-off is enabled, we render a big button that is styled to + // look like a search textbox. If the button is clicked, we style + // the button as if it was a focused search box and show a fake cursor but + // really focus the awesomebar without the focus styles ("hidden focus"). + fakeFocus: false, + // Hide the search box after handing off to AwesomeBar and user starts typing. + hide: false, + }, +}; + +function App(prevState = INITIAL_STATE.App, action) { + switch (action.type) { + case at.INIT: + return Object.assign({}, prevState, action.data || {}, { + initialized: true, + }); + default: + return prevState; + } +} + +function ASRouter(prevState = INITIAL_STATE.ASRouter, action) { + switch (action.type) { + case at.AS_ROUTER_INITIALIZED: + return { ...action.data, initialized: true }; + default: + return prevState; + } +} + +/** + * insertPinned - Inserts pinned links in their specified slots + * + * @param {array} a list of links + * @param {array} a list of pinned links + * @return {array} resulting list of links with pinned links inserted + */ +function insertPinned(links, pinned) { + // Remove any pinned links + const pinnedUrls = pinned.map(link => link && link.url); + let newLinks = links.filter(link => + link ? !pinnedUrls.includes(link.url) : false + ); + newLinks = newLinks.map(link => { + if (link && link.isPinned) { + delete link.isPinned; + delete link.pinIndex; + } + return link; + }); + + // Then insert them in their specified location + pinned.forEach((val, index) => { + if (!val) { + return; + } + let link = Object.assign({}, val, { isPinned: true, pinIndex: index }); + if (index > newLinks.length) { + newLinks[index] = link; + } else { + newLinks.splice(index, 0, link); + } + }); + + return newLinks; +} + +function TopSites(prevState = INITIAL_STATE.TopSites, action) { + let hasMatch; + let newRows; + switch (action.type) { + case at.TOP_SITES_UPDATED: + if (!action.data || !action.data.links) { + return prevState; + } + return Object.assign( + {}, + prevState, + { initialized: true, rows: action.data.links }, + action.data.pref ? { pref: action.data.pref } : {} + ); + case at.TOP_SITES_PREFS_UPDATED: + return Object.assign({}, prevState, { pref: action.data.pref }); + case at.TOP_SITES_EDIT: + return Object.assign({}, prevState, { + editForm: { + index: action.data.index, + previewResponse: null, + }, + }); + case at.TOP_SITES_CANCEL_EDIT: + return Object.assign({}, prevState, { editForm: null }); + case at.TOP_SITES_OPEN_SEARCH_SHORTCUTS_MODAL: + return Object.assign({}, prevState, { showSearchShortcutsForm: true }); + case at.TOP_SITES_CLOSE_SEARCH_SHORTCUTS_MODAL: + return Object.assign({}, prevState, { showSearchShortcutsForm: false }); + case at.PREVIEW_RESPONSE: + if ( + !prevState.editForm || + action.data.url !== prevState.editForm.previewUrl + ) { + return prevState; + } + return Object.assign({}, prevState, { + editForm: { + index: prevState.editForm.index, + previewResponse: action.data.preview, + previewUrl: action.data.url, + }, + }); + case at.PREVIEW_REQUEST: + if (!prevState.editForm) { + return prevState; + } + return Object.assign({}, prevState, { + editForm: { + index: prevState.editForm.index, + previewResponse: null, + previewUrl: action.data.url, + }, + }); + case at.PREVIEW_REQUEST_CANCEL: + if (!prevState.editForm) { + return prevState; + } + return Object.assign({}, prevState, { + editForm: { + index: prevState.editForm.index, + previewResponse: null, + }, + }); + case at.SCREENSHOT_UPDATED: + newRows = prevState.rows.map(row => { + if (row && row.url === action.data.url) { + hasMatch = true; + return Object.assign({}, row, { screenshot: action.data.screenshot }); + } + return row; + }); + return hasMatch + ? Object.assign({}, prevState, { rows: newRows }) + : prevState; + case at.PLACES_BOOKMARK_ADDED: + if (!action.data) { + return prevState; + } + newRows = prevState.rows.map(site => { + if (site && site.url === action.data.url) { + const { bookmarkGuid, bookmarkTitle, dateAdded } = action.data; + return Object.assign({}, site, { + bookmarkGuid, + bookmarkTitle, + bookmarkDateCreated: dateAdded, + }); + } + return site; + }); + return Object.assign({}, prevState, { rows: newRows }); + case at.PLACES_BOOKMARK_REMOVED: + if (!action.data) { + return prevState; + } + newRows = prevState.rows.map(site => { + if (site && site.url === action.data.url) { + const newSite = Object.assign({}, site); + delete newSite.bookmarkGuid; + delete newSite.bookmarkTitle; + delete newSite.bookmarkDateCreated; + return newSite; + } + return site; + }); + return Object.assign({}, prevState, { rows: newRows }); + case at.PLACES_LINK_DELETED: + if (!action.data) { + return prevState; + } + newRows = prevState.rows.filter(site => action.data.url !== site.url); + return Object.assign({}, prevState, { rows: newRows }); + case at.UPDATE_SEARCH_SHORTCUTS: + return { ...prevState, searchShortcuts: action.data.searchShortcuts }; + case at.SNIPPETS_PREVIEW_MODE: + return { ...prevState, rows: [] }; + default: + return prevState; + } +} + +function Dialog(prevState = INITIAL_STATE.Dialog, action) { + switch (action.type) { + case at.DIALOG_OPEN: + return Object.assign({}, prevState, { visible: true, data: action.data }); + case at.DIALOG_CANCEL: + return Object.assign({}, prevState, { visible: false }); + case at.DELETE_HISTORY_URL: + return Object.assign({}, INITIAL_STATE.Dialog); + default: + return prevState; + } +} + +function Prefs(prevState = INITIAL_STATE.Prefs, action) { + let newValues; + switch (action.type) { + case at.PREFS_INITIAL_VALUES: + return Object.assign({}, prevState, { + initialized: true, + values: action.data, + }); + case at.PREF_CHANGED: + newValues = Object.assign({}, prevState.values); + newValues[action.data.name] = action.data.value; + return Object.assign({}, prevState, { values: newValues }); + default: + return prevState; + } +} + +function Sections(prevState = INITIAL_STATE.Sections, action) { + let hasMatch; + let newState; + switch (action.type) { + case at.SECTION_DEREGISTER: + return prevState.filter(section => section.id !== action.data); + case at.SECTION_REGISTER: + // If section exists in prevState, update it + newState = prevState.map(section => { + if (section && section.id === action.data.id) { + hasMatch = true; + return Object.assign({}, section, action.data); + } + return section; + }); + // Otherwise, append it + if (!hasMatch) { + const initialized = !!(action.data.rows && !!action.data.rows.length); + const section = Object.assign( + { title: "", rows: [], enabled: false }, + action.data, + { initialized } + ); + newState.push(section); + } + return newState; + case at.SECTION_UPDATE: + newState = prevState.map(section => { + if (section && section.id === action.data.id) { + // If the action is updating rows, we should consider initialized to be true. + // This can be overridden if initialized is defined in the action.data + const initialized = action.data.rows ? { initialized: true } : {}; + + // Make sure pinned cards stay at their current position when rows are updated. + // Disabling a section (SECTION_UPDATE with empty rows) does not retain pinned cards. + if ( + action.data.rows && + !!action.data.rows.length && + section.rows.find(card => card.pinned) + ) { + const rows = Array.from(action.data.rows); + section.rows.forEach((card, index) => { + if (card.pinned) { + // Only add it if it's not already there. + if (rows[index].guid !== card.guid) { + rows.splice(index, 0, card); + } + } + }); + return Object.assign( + {}, + section, + initialized, + Object.assign({}, action.data, { rows }) + ); + } + + return Object.assign({}, section, initialized, action.data); + } + return section; + }); + + if (!action.data.dedupeConfigurations) { + return newState; + } + + action.data.dedupeConfigurations.forEach(dedupeConf => { + newState = newState.map(section => { + if (section.id === dedupeConf.id) { + const dedupedRows = dedupeConf.dedupeFrom.reduce( + (rows, dedupeSectionId) => { + const dedupeSection = newState.find( + s => s.id === dedupeSectionId + ); + const [, newRows] = dedupe.group(dedupeSection.rows, rows); + return newRows; + }, + section.rows + ); + + return Object.assign({}, section, { rows: dedupedRows }); + } + + return section; + }); + }); + + return newState; + case at.SECTION_UPDATE_CARD: + return prevState.map(section => { + if (section && section.id === action.data.id && section.rows) { + const newRows = section.rows.map(card => { + if (card.url === action.data.url) { + return Object.assign({}, card, action.data.options); + } + return card; + }); + return Object.assign({}, section, { rows: newRows }); + } + return section; + }); + case at.PLACES_BOOKMARK_ADDED: + if (!action.data) { + return prevState; + } + return prevState.map(section => + Object.assign({}, section, { + rows: section.rows.map(item => { + // find the item within the rows that is attempted to be bookmarked + if (item.url === action.data.url) { + const { bookmarkGuid, bookmarkTitle, dateAdded } = action.data; + return Object.assign({}, item, { + bookmarkGuid, + bookmarkTitle, + bookmarkDateCreated: dateAdded, + type: "bookmark", + }); + } + return item; + }), + }) + ); + case at.PLACES_SAVED_TO_POCKET: + if (!action.data) { + return prevState; + } + return prevState.map(section => + Object.assign({}, section, { + rows: section.rows.map(item => { + if (item.url === action.data.url) { + return Object.assign({}, item, { + open_url: action.data.open_url, + pocket_id: action.data.pocket_id, + title: action.data.title, + type: "pocket", + }); + } + return item; + }), + }) + ); + case at.PLACES_BOOKMARK_REMOVED: + if (!action.data) { + return prevState; + } + return prevState.map(section => + Object.assign({}, section, { + rows: section.rows.map(item => { + // find the bookmark within the rows that is attempted to be removed + if (item.url === action.data.url) { + const newSite = Object.assign({}, item); + delete newSite.bookmarkGuid; + delete newSite.bookmarkTitle; + delete newSite.bookmarkDateCreated; + if (!newSite.type || newSite.type === "bookmark") { + newSite.type = "history"; + } + return newSite; + } + return item; + }), + }) + ); + case at.PLACES_LINK_DELETED: + case at.PLACES_LINK_BLOCKED: + if (!action.data) { + return prevState; + } + return prevState.map(section => + Object.assign({}, section, { + rows: section.rows.filter(site => site.url !== action.data.url), + }) + ); + case at.DELETE_FROM_POCKET: + case at.ARCHIVE_FROM_POCKET: + return prevState.map(section => + Object.assign({}, section, { + rows: section.rows.filter( + site => site.pocket_id !== action.data.pocket_id + ), + }) + ); + case at.SNIPPETS_PREVIEW_MODE: + return prevState.map(section => ({ ...section, rows: [] })); + default: + return prevState; + } +} + +function Snippets(prevState = INITIAL_STATE.Snippets, action) { + switch (action.type) { + case at.SNIPPETS_DATA: + return Object.assign({}, prevState, { initialized: true }, action.data); + case at.SNIPPET_BLOCKED: + return Object.assign({}, prevState, { + blockList: prevState.blockList.concat(action.data), + }); + case at.SNIPPETS_BLOCKLIST_CLEARED: + return Object.assign({}, prevState, { blockList: [] }); + case at.SNIPPETS_RESET: + return INITIAL_STATE.Snippets; + default: + return prevState; + } +} + +function Pocket(prevState = INITIAL_STATE.Pocket, action) { + switch (action.type) { + case at.POCKET_WAITING_FOR_SPOC: + return { ...prevState, waitingForSpoc: action.data }; + case at.POCKET_LOGGED_IN: + return { ...prevState, isUserLoggedIn: !!action.data }; + case at.POCKET_CTA: + return { + ...prevState, + pocketCta: { + ctaButton: action.data.cta_button, + ctaText: action.data.cta_text, + ctaUrl: action.data.cta_url, + useCta: action.data.use_cta, + }, + }; + default: + return prevState; + } +} + +function Personalization(prevState = INITIAL_STATE.Personalization, action) { + switch (action.type) { + case at.DISCOVERY_STREAM_PERSONALIZATION_VERSION: + return { + ...prevState, + version: action.data.version, + }; + case at.DISCOVERY_STREAM_PERSONALIZATION_LAST_UPDATED: + return { + ...prevState, + lastUpdated: action.data.lastUpdated, + }; + case at.DISCOVERY_STREAM_PERSONALIZATION_INIT: + return { + ...prevState, + initialized: true, + }; + case at.PREF_CHANGED: + if (action.data.name === PREF_PERSONALIZATION_VERSION) { + return { + ...prevState, + version: action.data.value, + }; + } + return prevState; + default: + return prevState; + } +} + +// eslint-disable-next-line complexity +function DiscoveryStream(prevState = INITIAL_STATE.DiscoveryStream, action) { + // Return if action data is empty, or spocs or feeds data is not loaded + const isNotReady = () => + !action.data || !prevState.spocs.loaded || !prevState.feeds.loaded; + + const handlePlacements = handleSites => { + const { data, placements } = prevState.spocs; + const result = {}; + + const forPlacement = placement => { + const placementSpocs = data[placement.name]; + + if ( + !placementSpocs || + !placementSpocs.items || + !placementSpocs.items.length + ) { + return; + } + + result[placement.name] = { + ...placementSpocs, + items: handleSites(placementSpocs.items), + }; + }; + + if (!placements || !placements.length) { + [{ name: "spocs" }].forEach(forPlacement); + } else { + placements.forEach(forPlacement); + } + return result; + }; + + const nextState = handleSites => ({ + ...prevState, + spocs: { + ...prevState.spocs, + data: handlePlacements(handleSites), + }, + feeds: { + ...prevState.feeds, + data: Object.keys(prevState.feeds.data).reduce( + (accumulator, feed_url) => { + accumulator[feed_url] = { + data: { + ...prevState.feeds.data[feed_url].data, + recommendations: handleSites( + prevState.feeds.data[feed_url].data.recommendations + ), + }, + }; + return accumulator; + }, + {} + ), + }, + }); + + switch (action.type) { + case at.DISCOVERY_STREAM_CONFIG_CHANGE: + // Fall through to a separate action is so it doesn't trigger a listener update on init + case at.DISCOVERY_STREAM_CONFIG_SETUP: + return { ...prevState, config: action.data || {} }; + case at.DISCOVERY_STREAM_LAYOUT_UPDATE: + return { + ...prevState, + lastUpdated: action.data.lastUpdated || null, + layout: action.data.layout || [], + }; + case at.DISCOVERY_STREAM_COLLECTION_DISMISSIBLE_TOGGLE: + return { + ...prevState, + isCollectionDismissible: action.data.value, + }; + case at.HIDE_PRIVACY_INFO: + return { + ...prevState, + isPrivacyInfoModalVisible: false, + }; + case at.SHOW_PRIVACY_INFO: + return { + ...prevState, + isPrivacyInfoModalVisible: true, + }; + case at.DISCOVERY_STREAM_LAYOUT_RESET: + return { ...INITIAL_STATE.DiscoveryStream, config: prevState.config }; + case at.DISCOVERY_STREAM_FEEDS_UPDATE: + return { + ...prevState, + feeds: { + ...prevState.feeds, + loaded: true, + }, + }; + case at.DISCOVERY_STREAM_FEED_UPDATE: + const newData = {}; + newData[action.data.url] = action.data.feed; + return { + ...prevState, + feeds: { + ...prevState.feeds, + data: { + ...prevState.feeds.data, + ...newData, + }, + }, + }; + case at.DISCOVERY_STREAM_SPOCS_CAPS: + return { + ...prevState, + spocs: { + ...prevState.spocs, + frequency_caps: [...prevState.spocs.frequency_caps, ...action.data], + }, + }; + case at.DISCOVERY_STREAM_SPOCS_ENDPOINT: + return { + ...prevState, + spocs: { + ...INITIAL_STATE.DiscoveryStream.spocs, + spocs_endpoint: + action.data.url || + INITIAL_STATE.DiscoveryStream.spocs.spocs_endpoint, + }, + }; + case at.DISCOVERY_STREAM_SPOCS_PLACEMENTS: + return { + ...prevState, + spocs: { + ...prevState.spocs, + placements: + action.data.placements || + INITIAL_STATE.DiscoveryStream.spocs.placements, + }, + }; + case at.DISCOVERY_STREAM_SPOCS_UPDATE: + if (action.data) { + return { + ...prevState, + spocs: { + ...prevState.spocs, + lastUpdated: action.data.lastUpdated, + data: action.data.spocs, + loaded: true, + }, + }; + } + return prevState; + case at.DISCOVERY_STREAM_SPOC_BLOCKED: + return { + ...prevState, + spocs: { + ...prevState.spocs, + blocked: [...prevState.spocs.blocked, action.data.url], + }, + }; + case at.DISCOVERY_STREAM_LINK_BLOCKED: + return isNotReady() + ? prevState + : nextState(items => + items.filter(item => item.url !== action.data.url) + ); + + case at.PLACES_SAVED_TO_POCKET: + const addPocketInfo = item => { + if (item.url === action.data.url) { + return Object.assign({}, item, { + open_url: action.data.open_url, + pocket_id: action.data.pocket_id, + context_type: "pocket", + }); + } + return item; + }; + return isNotReady() + ? prevState + : nextState(items => items.map(addPocketInfo)); + + case at.DELETE_FROM_POCKET: + case at.ARCHIVE_FROM_POCKET: + return isNotReady() + ? prevState + : nextState(items => + items.filter(item => item.pocket_id !== action.data.pocket_id) + ); + + case at.PLACES_BOOKMARK_ADDED: + const updateBookmarkInfo = item => { + if (item.url === action.data.url) { + const { bookmarkGuid, bookmarkTitle, dateAdded } = action.data; + return Object.assign({}, item, { + bookmarkGuid, + bookmarkTitle, + bookmarkDateCreated: dateAdded, + context_type: "bookmark", + }); + } + return item; + }; + return isNotReady() + ? prevState + : nextState(items => items.map(updateBookmarkInfo)); + + case at.PLACES_BOOKMARK_REMOVED: + const removeBookmarkInfo = item => { + if (item.url === action.data.url) { + const newSite = Object.assign({}, item); + delete newSite.bookmarkGuid; + delete newSite.bookmarkTitle; + delete newSite.bookmarkDateCreated; + if (!newSite.context_type || newSite.context_type === "bookmark") { + newSite.context_type = "removedBookmark"; + } + return newSite; + } + return item; + }; + return isNotReady() + ? prevState + : nextState(items => items.map(removeBookmarkInfo)); + case at.PREF_CHANGED: + if (action.data.name === PREF_COLLECTION_DISMISSIBLE) { + return { + ...prevState, + isCollectionDismissible: action.data.value, + }; + } + return prevState; + default: + return prevState; + } +} + +function Search(prevState = INITIAL_STATE.Search, action) { + switch (action.type) { + case at.HIDE_SEARCH: + return Object.assign({ ...prevState, hide: true }); + case at.FAKE_FOCUS_SEARCH: + return Object.assign({ ...prevState, fakeFocus: true }); + case at.SHOW_SEARCH: + return Object.assign({ ...prevState, hide: false, fakeFocus: false }); + default: + return prevState; + } +} + +this.INITIAL_STATE = INITIAL_STATE; +this.TOP_SITES_DEFAULT_ROWS = TOP_SITES_DEFAULT_ROWS; +this.TOP_SITES_MAX_SITES_PER_ROW = TOP_SITES_MAX_SITES_PER_ROW; + +this.reducers = { + TopSites, + App, + ASRouter, + Snippets, + Prefs, + Dialog, + Sections, + Pocket, + Personalization, + DiscoveryStream, + Search, +}; + +const EXPORTED_SYMBOLS = [ + "reducers", + "INITIAL_STATE", + "insertPinned", + "TOP_SITES_DEFAULT_ROWS", + "TOP_SITES_MAX_SITES_PER_ROW", +]; diff --git a/browser/components/newtab/components.conf b/browser/components/newtab/components.conf new file mode 100644 index 0000000000..25466d28e3 --- /dev/null +++ b/browser/components/newtab/components.conf @@ -0,0 +1,14 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Classes = [ + { + 'cid': '{dfcd2adc-7867-4d3a-ba70-17501f208142}', + 'contract_ids': ['@mozilla.org/browser/aboutnewtab-service;1'], + 'jsm': 'resource:///modules/AboutNewTabService.jsm', + 'constructor': 'AboutNewTabStubService', + }, +] diff --git a/browser/components/newtab/components/CustomElements/paragraph.js b/browser/components/newtab/components/CustomElements/paragraph.js new file mode 100644 index 0000000000..a8e3329801 --- /dev/null +++ b/browser/components/newtab/components/CustomElements/paragraph.js @@ -0,0 +1,72 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +// This is loaded into all XUL windows. Wrap in a block to prevent +// leaking to window scope. +{ + const { RemoteL10n } = ChromeUtils.import( + "resource://activity-stream/lib/RemoteL10n.jsm" + ); + class MozTextParagraph extends HTMLElement { + constructor() { + super(); + + this._content = null; + } + + get fluentAttributeValues() { + const attributes = {}; + for (let name of this.getAttributeNames()) { + if (name.startsWith("fluent-variable-")) { + let value = this.getAttribute(name); + // Attribute value is a string, in some cases that is not useful + // for example instantiating a Date object will fail. We try to + // convert all possible integers back. + if (value.match(/^\d+/)) { + value = parseInt(value, 10); + } + attributes[name.replace(/^fluent-variable-/, "")] = value; + } + } + + return attributes; + } + + render() { + if (this.getAttribute("fluent-remote-id") && this._content) { + RemoteL10n.l10n.setAttributes( + this._content, + this.getAttribute("fluent-remote-id"), + this.fluentAttributeValues + ); + } + } + + static get observedAttributes() { + return ["fluent-remote-id"]; + } + + attributeChangedCallback(name, oldValue, newValue) { + this.render(); + } + + connectedCallback() { + if (this.shadowRoot) { + this.render(); + return; + } + + const shadowRoot = this.attachShadow({ mode: "open" }); + this._content = document.createElement("span"); + shadowRoot.appendChild(this._content); + + this.render(); + RemoteL10n.l10n.translateFragment(this._content); + } + } + + customElements.define("remote-text", MozTextParagraph); +} diff --git a/browser/components/newtab/content-src/.eslintrc.js b/browser/components/newtab/content-src/.eslintrc.js new file mode 100644 index 0000000000..bf7904cc43 --- /dev/null +++ b/browser/components/newtab/content-src/.eslintrc.js @@ -0,0 +1,12 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* eslint-disable import/no-commonjs */ + +module.exports = { + rules: { + "import/no-commonjs": 2, + "react/jsx-no-bind": 0, + }, +}; diff --git a/browser/components/newtab/content-src/aboutwelcome/aboutwelcome.jsx b/browser/components/newtab/content-src/aboutwelcome/aboutwelcome.jsx new file mode 100644 index 0000000000..d9503584b9 --- /dev/null +++ b/browser/components/newtab/content-src/aboutwelcome/aboutwelcome.jsx @@ -0,0 +1,188 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React from "react"; +import ReactDOM from "react-dom"; +import { MultiStageAboutWelcome } from "./components/MultiStageAboutWelcome"; +import { SimpleAboutWelcome } from "./components/SimpleAboutWelcome"; +import { ReturnToAMO } from "./components/ReturnToAMO"; + +import { + AboutWelcomeUtils, + DEFAULT_WELCOME_CONTENT, +} from "../lib/aboutwelcome-utils"; + +class AboutWelcome extends React.PureComponent { + constructor(props) { + super(props); + this.state = { metricsFlowUri: null }; + this.fetchFxAFlowUri = this.fetchFxAFlowUri.bind(this); + this.handleStartBtnClick = this.handleStartBtnClick.bind(this); + } + + async fetchFxAFlowUri() { + this.setState({ metricsFlowUri: await window.AWGetFxAMetricsFlowURI() }); + } + + componentDidMount() { + this.fetchFxAFlowUri(); + + // Record impression with performance data after allowing the page to load + const recordImpression = domState => { + const { domComplete, domInteractive } = performance + .getEntriesByType("navigation") + .pop(); + window.AWSendEventTelemetry({ + event: "IMPRESSION", + event_context: { + domComplete, + domInteractive, + mountStart: performance.getEntriesByName("mount").pop().startTime, + domState, + source: this.props.UTMTerm, + page: "about:welcome", + }, + message_id: this.props.messageId, + }); + }; + if (document.readyState === "complete") { + // Page might have already triggered a load event because it waited for async data, + // e.g., attribution, so the dom load timing could be of a empty content + // with domState in telemetry captured as 'complete' + recordImpression(document.readyState); + } else { + window.addEventListener("load", () => recordImpression("load"), { + once: true, + }); + } + + // Captures user has seen about:welcome by setting + // firstrun.didSeeAboutWelcome pref to true and capturing welcome UI unique messageId + window.AWSendToParent("SET_WELCOME_MESSAGE_SEEN", this.props.messageId); + } + + handleStartBtnClick() { + AboutWelcomeUtils.handleUserAction(this.props.startButton.action); + const ping = { + event: "CLICK_BUTTON", + event_context: { + source: this.props.startButton.message_id, + page: "about:welcome", + }, + message_id: this.props.messageId, + id: "ABOUT_WELCOME", + }; + window.AWSendEventTelemetry(ping); + } + + render() { + const { props } = this; + if (props.template === "simplified") { + return ( + + ); + } else if (props.template === "return_to_amo") { + return ( + + ); + } + + return ( + + ); + } +} + +AboutWelcome.defaultProps = DEFAULT_WELCOME_CONTENT; + +// Computes messageId and UTMTerm info used in telemetry +function ComputeTelemetryInfo(welcomeContent, experimentId, branchId) { + let messageId = + welcomeContent.template === "return_to_amo" + ? "RTAMO_DEFAULT_WELCOME" + : "DEFAULT_ABOUTWELCOME"; + let UTMTerm = "default"; + + if (welcomeContent.id) { + messageId = welcomeContent.id.toUpperCase(); + } + + if (experimentId && branchId) { + UTMTerm = `${experimentId}-${branchId}`.toLowerCase(); + } + return { + messageId, + UTMTerm, + }; +} + +async function retrieveRenderContent() { + // Check for override content in pref browser.aboutwelcome.overrideContent + let aboutWelcomeProps = await window.AWGetWelcomeOverrideContent(); + if (aboutWelcomeProps?.template) { + let { messageId, UTMTerm } = ComputeTelemetryInfo(aboutWelcomeProps); + return { aboutWelcomeProps, messageId, UTMTerm }; + } + + // Check for experiment and retrieve content + const { slug, branch } = await window.AWGetExperimentData(); + aboutWelcomeProps = branch?.feature ? branch.feature.value : {}; + + // Check if there is any attribution data, this could take a while to await in series + // especially when there is an add-on that requires remote lookup + // Moving RTAMO as part of another screen of multistage is one option to fix the delay + // as it will allow the initial page to be fast while we fetch attribution data in parallel for a later screen. + const attribution = await window.AWGetAttributionData(); + if (attribution?.template) { + aboutWelcomeProps = { + ...aboutWelcomeProps, + // If part of an experiment, render experiment template + template: aboutWelcomeProps?.template + ? aboutWelcomeProps.template + : attribution.template, + ...attribution.extraProps, + }; + } + + let { messageId, UTMTerm } = ComputeTelemetryInfo( + aboutWelcomeProps, + slug, + branch && branch.slug + ); + return { aboutWelcomeProps, messageId, UTMTerm }; +} + +async function mount() { + let { aboutWelcomeProps, messageId, UTMTerm } = await retrieveRenderContent(); + ReactDOM.render( + , + document.getElementById("root") + ); +} + +performance.mark("mount"); +mount(); diff --git a/browser/components/newtab/content-src/aboutwelcome/aboutwelcome.scss b/browser/components/newtab/content-src/aboutwelcome/aboutwelcome.scss new file mode 100644 index 0000000000..501b57952f --- /dev/null +++ b/browser/components/newtab/content-src/aboutwelcome/aboutwelcome.scss @@ -0,0 +1,673 @@ +// sass-lint:disable no-css-comments +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +@import '../styles/normalize'; +@import '../styles/OnboardingImages'; + +$break-point-medium: 610px; +$break-point-large: 866px; +$break-point-widest: 1122px; +$logo-size: 112px; + +html { + height: 100%; +} + +body { + // sass-lint:disable no-color-literals + --grey-subtitle: #4A4A4F; + --grey-subtitle-1: #696977; + --newtab-background-color: #EDEDF0; + --newtab-background-color-1: #F9F9FA; + --newtab-text-primary-color: #0C0C0D; + --newtab-text-conditional-color: #4A4A4F; + --newtab-button-primary-color: #0060DF; + --newtab-button-secondary-color: #0060DF; + --newtab-card-background-color: #FFF; + --newtab-card-shadow: 0 1px 4px 0 rgba(12, 12, 13, 0.4); + --tiles-theme-section-border-width: 1px; + --welcome-header-text-color: #2B2156; + --welcome-header-text-color-1: #20133A; + --welcome-card-button-background-color: rgba(12, 12, 13, 0.1); + --welcome-card-button-background-hover-color: rgba(12, 12, 13, 0.2); + --welcome-card-button-background-active-color: rgba(12, 12, 13, 0.3); + --welcome-button-box-shadow-color: #0A84FF; + --welcome-button-box-shadow-inset-color: rgba(10, 132, 255, 0.3); + --welcome-button-text-color: #FFF; + --welcome-button-background-hover-color: #003EAA; + --welcome-button-background-active-color: #002275; + --about-welcome-media-fade: linear-gradient(transparent, transparent 35%, #F9F9FA, #F9F9FA); + + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Ubuntu', + 'Helvetica Neue', sans-serif; + font-size: 16px; + position: relative; + /* these two rules fix test failures in + "browser_ext_themes_ntp_colors" & "browser_ext_themes_ntp_colors_perwindow".*/ + color: var(--newtab-text-primary-color); + background-color: var(--newtab-background-color); + + &[lwt-newtab-brighttext] { + --newtab-background-color: #2A2A2E; + --newtab-background-color-1: #1D1133; + --newtab-text-primary-color: #F9F9FA; + --newtab-text-conditional-color: #F9F9FA; + --grey-subtitle-1: #FFF; + --newtab-button-primary-color: #0060DF; + --newtab-button-secondary-color: #FFF; + --newtab-card-background-color: #38383D; + --newtab-card-shadow: 0 1px 8px 0 rgba(12, 12, 13, 0.4); + --welcome-header-text-color: rgba(255, 255, 255, 0.6); + --welcome-header-text-color-1: #7542E5; + --welcome-card-button-background-color: rgba(12, 12, 13, 0.3); + --welcome-card-button-background-hover-color: rgba(12, 12, 13, 0.5); + --welcome-card-button-background-active-color: rgba(12, 12, 13, 0.7); + --welcome-button-box-shadow-color: #0A84FF; + --about-welcome-media-fade: linear-gradient(transparent, transparent 35%, #1D1133, #1D1133); + } +} + +.welcomeCardGrid { + margin: 0; + margin-top: 32px; + display: grid; + grid-gap: 32px; + transition: opacity 0.4s; + transition-delay: 0.1s; + grid-auto-rows: 1fr; + + @media (min-width: $break-point-medium) { + grid-template-columns: repeat(auto-fit, 224px); + } + + @media (min-width: $break-point-widest) { + grid-template-columns: repeat(auto-fit, 309px); + } +} + +.welcomeContainer { + text-align: center; + + @media (min-width: $break-point-medium) { + max-height: 1000px; + } + + h1 { + font-size: 36px; + font-weight: 200; + margin: 0 0 40px; + color: var(--welcome-header-text-color); + } + + .welcome-title { + margin-bottom: 5px; + line-height: 52px; + } + + .welcome-subtitle { + font-size: 28px; + font-weight: 200; + margin: 6px 0 0; + color: var(--grey-subtitle); + line-height: 42px; + } +} + +.welcomeContainerInner { + margin: auto; + padding: 40px 25px; + + @media (min-width: $break-point-medium) { + width: 530px; + } + + @media (min-width: $break-point-large) { + width: 786px; + } + + @media (min-width: $break-point-widest) { + width: 1042px; + } +} + +.welcomeCard { + position: relative; + background: var(--newtab-card-background-color); + border-radius: 4px; + box-shadow: var(--newtab-card-shadow); + font-size: 13px; + padding: 20px 20px 60px; + + @media (max-width: $break-point-large) { + padding: 20px; + } + + @media (min-width: $break-point-widest) { + font-size: 15px; + } +} + +.welcomeCard .onboardingTitle { + font-weight: normal; + color: var(--newtab-text-primary-color); + margin: 10px 0 4px; + font-size: 15px; + + @media (min-width: $break-point-widest) { + font-size: 18px; + } +} + +.welcomeCard .onboardingText { + margin: 0 0 60px; + color: var(--newtab-text-conditional-color); + line-height: 1.5; + font-weight: 200; +} + +.welcomeCard .onboardingButton { + color: var(--newtab-text-conditional-color); + background: var(--welcome-card-button-background-color); + border: 0; + border-radius: 4px; + margin: 14px; + min-width: 70%; + padding: 6px 14px; + white-space: pre-wrap; + cursor: pointer; + + &:focus, + &:hover { + box-shadow: none; + background: var(--welcome-card-button-background-hover-color); + } + + &:focus { + outline: dotted 1px; + } + + &:active { + background: var(--welcome-card-button-background-active-color); + } +} + +.welcomeCard .onboardingButtonContainer { + position: absolute; + bottom: 16px; + left: 0; + width: 100%; + text-align: center; +} + +.onboardingMessageImage { + height: 112px; + width: 180px; + background-size: auto 140px; + background-position: center center; + background-repeat: no-repeat; + display: inline-block; + + @media (max-width: $break-point-large) { + height: 75px; + min-width: 80px; + background-size: 140px; + } +} + +.start-button { + border: 0; + font-size: 15px; + font-family: inherit; + font-weight: 200; + margin-inline-start: 12px; + margin: 30px 0 25px; + padding: 8px 16px; + white-space: nowrap; + background-color: var(--newtab-button-primary-color); + color: var(--welcome-button-text-color); + cursor: pointer; + border-radius: 2px; + + &:focus { + background: var(--welcome-button-background-hover-color); + box-shadow: 0 0 0 1px var(--welcome-button-box-shadow-inset-color) inset, + 0 0 0 1px var(--welcome-button-box-shadow-inset-color), + 0 0 0 4px var(--welcome-button-box-shadow-color); + } + + &:hover { + background: var(--welcome-button-background-hover-color); + } + + &:active { + background: var(--welcome-button-background-active-color); + } +} + + +.onboardingContainer { + text-align: center; + overflow-x: auto; + height: 100vh; + background-color: var(--newtab-background-color-1); + + .screen { + display: flex; + flex-flow: column nowrap; + height: 100%; + } + + .brand-logo { + background: url('chrome://branding/content/about-logo.svg') top + center / $logo-size no-repeat; + padding: $logo-size 0 20px; + margin-top: 60px; + + &.cta-top { + margin-top: 25px; + } + } + + .welcomeZap { + span { + position: relative; + z-index: 1; + white-space: nowrap; + } + + .zap { + &::after { + display: block; + background-repeat: no-repeat; + background-size: 100% 100%; + content: ''; + position: absolute; + top: calc(100% - 0.15em); + width: 100%; + height: 0.3em; + left: 0; + z-index: -1; + } + + &.short::after { + background-image: url('chrome://activity-stream/content/data/content/assets/short-zap.svg'); + } + + &.long::after { + background-image: url('chrome://activity-stream/content/data/content/assets/long-zap.svg'); + } + } + + + } + + .welcome-text { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + margin-bottom: 20px; + + h1, + h2 { + width: 860px; + @media (max-width: $break-point-large) { + width: 530px; + } + + @media (max-width: $break-point-medium) { + width: 430px; + } + } + + h1 { + font-size: 48px; + line-height: 56px; + font-weight: bold; + margin: 0 6px; + color: var(--welcome-header-text-color-1); + } + + h2 { + font-size: 18px; + font-weight: normal; + margin: 10px 6px 0; + color: var(--grey-subtitle-1); + line-height: 28px; + max-width: 750px; + letter-spacing: -0.01em; + } + + img { + margin-inline: 2px; + width: 20px; + height: 20px; + } + } + + .tiles-theme-container { + margin: 10px auto; + border: 0; + } + + .sr-only { + opacity: 0; + overflow: hidden; + position: absolute; + + &.input { + height: 1px; + width: 1px; + } + } + + .tiles-theme-section { + display: grid; + grid-gap: 21px; + grid-template-columns: repeat(4, auto); + + /* --newtab-background-color-1 will be invisible, but it's necessary to + * keep the content from jumping around when it gets focus-within and + * does sprout a dotted border. This way it keeps a 1 pixel wide border + * either way so things don't change position. + */ + border: var(--tiles-theme-section-border-width) + solid + var(--newtab-background-color-1); + + @media (max-width: $break-point-medium) { + grid-template-columns: repeat(2, auto); + } + + &:focus-within { + border: var(--tiles-theme-section-border-width) dotted; + } + + .theme { + display: flex; + flex-direction: column; + padding: 0; + width: 180px; + height: 145px; + color: #000; + background-color: #FFF; + box-shadow: 0 1px 4px 0 rgba(12, 12, 13, 0.4); + border-radius: 4px; + cursor: pointer; + + .icon { + background-size: cover; + border-top-left-radius: 4px; + border-top-right-radius: 4px; + height: 91px; + + &:dir(rtl) { + transform: scaleX(-1); + } + + &.light { + background-image: url('chrome://mozapps/content/extensions/firefox-compact-light.svg'); + } + + &.dark { + background-image: url('chrome://mozapps/content/extensions/firefox-compact-dark.svg'); + } + + &.automatic { + background-image: url('chrome://mozapps/content/extensions/default-theme.svg'); + } + + &.alpenglow { + background-image: url('chrome://mozapps/content/extensions/firefox-alpenglow.svg'); + } + } + + .text { + display: flex; + font-size: 14px; + font-weight: bold; + line-height: 22px; + margin-inline-start: 12px; + margin-top: 9px; + } + + &.selected { + outline: 4px solid #0090ED; + outline-offset: -4px; + } + + &:focus, + &:active { + outline: 4px solid #0090ED; + outline-offset: -4px; + } + } + } + + .tiles-container { + margin: 10px auto; + + &.info { + padding: 6px 12px 12px; + + &:hover, + &:focus { + background-color: rgba(217, 217, 227, 0.3); + border-radius: 4px; + } + } + } + + .tiles-topsites-section { + $host-size: 12px; + $tile-size: 96px; + + display: grid; + grid-gap: $tile-size / 4; + grid-template-columns: repeat(5, auto); + + @media (max-width: $break-point-medium) { + grid-template-columns: repeat(3, auto); + } + + .site { + width: $tile-size; + } + + .icon { + background-size: cover; + border-radius: 4px; + box-shadow: var(--newtab-card-shadow); + color: rgba(255, 255, 255, 0.5); + font-size: $host-size * 2; + font-weight: bold; + height: $tile-size; + line-height: $tile-size; + } + + .host { + font-size: $host-size; + line-height: $host-size * 3; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .site:nth-child(1) .icon { + background-color: #7542E5; + } + + .site:nth-child(2) .icon { + background-color: #952BB9; + } + + .site:nth-child(3) .icon { + background-color: #E31587; + } + + .site:nth-child(4) .icon { + background-color: #E25920; + } + + .site:nth-child(5) .icon { + background-color: #0250BB; + } + } + + // "tiles-media-section" styles here will support tiles of type + // "video" when the screen JSON it set in the below format: + + // "tiles": { + // "type": "video", + // "source": { + // "default" : "", + // "dark" : "" + // } + // } + + .tiles-media-section { + align-self: center; + position: relative; + margin-top: -12px; + margin-bottom: -155px; + + .fade { + height: 390px; + width: 800px; + position: absolute; + background-image: var(--about-welcome-media-fade); + } + + .media { + height: 390px; + width: 800px; + } + + &.privacy { + background: top no-repeat url('chrome://activity-stream/content/data/content/assets/firefox-protections.svg'); + height: 200px; + width: 800px; + margin: 0; + + &.media { + opacity: 0; + } + } + } + + button { + font-family: inherit; + cursor: pointer; + border: 0; + border-radius: 4px; + + &.primary { + font-size: 16px; + margin-inline-start: 12px; + margin: 20px 0 0; + padding: 12px 20px; + white-space: nowrap; + background-color: var(--newtab-button-primary-color); + color: var(--welcome-button-text-color); + fill: currentColor; + position: relative; + z-index: 1; + // This transparent border will show up in Windows High Contrast Mode to improve accessibility. + border: 1px solid transparent; + + &:focus { + background: var(--welcome-button-background-hover-color); + box-shadow: 0 0 0 4px var(--welcome-button-box-shadow-color); + } + + &:hover { + background: var(--welcome-button-background-hover-color); + } + + &:active { + background: var(--welcome-button-background-active-color); + } + } + + &.secondary { + background-color: initial; + text-decoration: underline; + display: block; + padding: 0; + width: auto; + color: var(--newtab-button-secondary-color); + margin-top: 14px; + + &:hover, + &:active { + background-color: initial; + } + } + } + + .secondary-cta { + display: flex; + flex-direction: row; + justify-content: center; + font-size: 14px; + + &.top { + justify-content: end; + align-items: end; + padding-inline-end: 30px; + padding-top: 4px; + + @media (max-width: $break-point-medium) { + justify-content: center; + } + } + + span { + color: var(--grey-subtitle-1); + margin: 0 4px; + } + } + + .helptext { + padding: 1em; + text-align: center; + color: var(--grey-subtitle-1); + font-size: 12px; + line-height: 18px; + + &.default { + align-self: center; + max-width: 40%; + } + } + + .steps { + display: flex; + flex-direction: row; + justify-content: center; + margin-top: auto; + padding: 32px 0 66px; + z-index: 1; + + &.has-helptext { + padding-bottom: 0; + } + + .indicator { + width: 60px; + height: 4px; + margin-inline-end: 4px; + margin-inline-start: 4px; + background: var(--grey-subtitle-1); + border-radius: 5px; + // This transparent border will show up in Windows High Contrast Mode to improve accessibility. + border: 1px solid transparent; + opacity: 0.25; + + &.current { + opacity: 1; + } + } + } +} diff --git a/browser/components/newtab/content-src/aboutwelcome/components/FxCards.jsx b/browser/components/newtab/content-src/aboutwelcome/components/FxCards.jsx new file mode 100644 index 0000000000..4275c59c8f --- /dev/null +++ b/browser/components/newtab/content-src/aboutwelcome/components/FxCards.jsx @@ -0,0 +1,83 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React from "react"; +import { addUtmParams } from "../../asrouter/templates/FirstRun/addUtmParams"; +import { OnboardingCard } from "../../asrouter/templates/OnboardingMessage/OnboardingMessage"; +import { AboutWelcomeUtils } from "../../lib/aboutwelcome-utils"; + +export class FxCards extends React.PureComponent { + constructor(props) { + super(props); + + this.state = { flowParams: null }; + + this.fetchFxAFlowParams = this.fetchFxAFlowParams.bind(this); + this.onCardAction = this.onCardAction.bind(this); + } + + componentDidUpdate() { + this.fetchFxAFlowParams(); + } + + componentDidMount() { + this.fetchFxAFlowParams(); + } + + async fetchFxAFlowParams() { + if (this.state.flowParams || !this.props.metricsFlowUri) { + return; + } + + const flowParams = await AboutWelcomeUtils.fetchFlowParams( + this.props.metricsFlowUri + ); + + this.setState({ flowParams }); + } + + onCardAction(action) { + let { type, data } = action; + let UTMTerm = `aboutwelcome-${this.props.utm_term}-card`; + + if (action.type === "OPEN_URL") { + let url = new URL(action.data.args); + addUtmParams(url, UTMTerm); + + if (action.addFlowParams && this.state.flowParams) { + url.searchParams.append("device_id", this.state.flowParams.deviceId); + url.searchParams.append("flow_id", this.state.flowParams.flowId); + url.searchParams.append( + "flow_begin_time", + this.state.flowParams.flowBeginTime + ); + } + + data = { ...data, args: url.toString() }; + } + + AboutWelcomeUtils.handleUserAction({ type, data }); + } + + render() { + const { props } = this; + return ( + +
+ {props.cards.map(card => ( + + ))} +
+
+ ); + } +} diff --git a/browser/components/newtab/content-src/aboutwelcome/components/HeroText.jsx b/browser/components/newtab/content-src/aboutwelcome/components/HeroText.jsx new file mode 100644 index 0000000000..bb3a296d54 --- /dev/null +++ b/browser/components/newtab/content-src/aboutwelcome/components/HeroText.jsx @@ -0,0 +1,19 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React from "react"; +import { Localized } from "./MSLocalized"; + +export const HeroText = props => { + return ( + + +

+ + +

+ + + ); +}; diff --git a/browser/components/newtab/content-src/aboutwelcome/components/MSLocalized.jsx b/browser/components/newtab/content-src/aboutwelcome/components/MSLocalized.jsx new file mode 100644 index 0000000000..694a294028 --- /dev/null +++ b/browser/components/newtab/content-src/aboutwelcome/components/MSLocalized.jsx @@ -0,0 +1,50 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React from "react"; +const MS_STRING_PROP = "string_id"; + +/** + * Based on the .text prop, localizes an inner element if a string_id + * is provided, OR renders plain text, OR hides it if nothing is provided. + * + * Examples: + * + * Localized text + * ftl: + * title = Welcome + * jsx: + *

+ * output: + *

Welcome

+ * + * Unlocalized text + * jsx: + *

+ * output: + *

Welcome

+ */ + +export const Localized = ({ text, children }) => { + if (!text) { + return null; + } + + let props = children ? children.props : {}; + let textNode; + + if (typeof text === "object" && text[MS_STRING_PROP]) { + props = { ...props }; + props["data-l10n-id"] = text[MS_STRING_PROP]; + } else if (typeof text === "string") { + textNode = text; + } + + if (!children) { + return React.createElement("span", props, textNode); + } else if (textNode) { + return React.cloneElement(children, props, textNode); + } + return React.cloneElement(children, props); +}; diff --git a/browser/components/newtab/content-src/aboutwelcome/components/MultiStageAboutWelcome.jsx b/browser/components/newtab/content-src/aboutwelcome/components/MultiStageAboutWelcome.jsx new file mode 100644 index 0000000000..fde65d3673 --- /dev/null +++ b/browser/components/newtab/content-src/aboutwelcome/components/MultiStageAboutWelcome.jsx @@ -0,0 +1,445 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React, { useState, useEffect, useRef } from "react"; +import { Localized } from "./MSLocalized"; +import { Zap } from "./Zap"; +import { AboutWelcomeUtils } from "../../lib/aboutwelcome-utils"; +import { + BASE_PARAMS, + addUtmParams, +} from "../../asrouter/templates/FirstRun/addUtmParams"; + +export const MultiStageAboutWelcome = props => { + const [index, setScreenIndex] = useState(0); + useEffect(() => { + // Send impression ping when respective screen first renders + props.screens.forEach(screen => { + if (index === screen.order) { + AboutWelcomeUtils.sendImpressionTelemetry( + `${props.message_id}_${screen.id}` + ); + } + }); + + // Remember that a new screen has loaded for browser navigation + if (index > window.history.state) { + window.history.pushState(index, ""); + } + }, [index]); + + useEffect(() => { + // Switch to the screen tracked in state (null for initial state) + const handler = ({ state }) => setScreenIndex(Number(state)); + + // Handle page load, e.g., going back to about:welcome from about:home + handler(window.history); + + // Watch for browser back/forward button navigation events + window.addEventListener("popstate", handler); + return () => window.removeEventListener("popstate", handler); + }, []); + + const [flowParams, setFlowParams] = useState(null); + const { metricsFlowUri } = props; + useEffect(() => { + (async () => { + if (metricsFlowUri) { + setFlowParams(await AboutWelcomeUtils.fetchFlowParams(metricsFlowUri)); + } + })(); + }, [metricsFlowUri]); + + // Transition to next screen, opening about:home on last screen button CTA + const handleTransition = + index < props.screens.length - 1 + ? () => setScreenIndex(prevState => prevState + 1) + : () => + AboutWelcomeUtils.handleUserAction({ + type: "OPEN_ABOUT_PAGE", + data: { args: "home", where: "current" }, + }); + + // Update top sites with default sites by region when region is available + const [region, setRegion] = useState(null); + useEffect(() => { + (async () => { + setRegion(await window.AWGetRegion()); + })(); + }, []); + + // Get the active theme so the rendering code can make it selected + // by default. + const [activeTheme, setActiveTheme] = useState(null); + const [initialTheme, setInitialTheme] = useState(null); + useEffect(() => { + (async () => { + let theme = await window.AWGetSelectedTheme(); + setInitialTheme(theme); + setActiveTheme(theme); + })(); + }, []); + + const useImportable = props.message_id.includes("IMPORTABLE"); + // Track whether we have already sent the importable sites impression telemetry + const importTelemetrySent = useRef(false); + const [topSites, setTopSites] = useState([]); + useEffect(() => { + (async () => { + let DEFAULT_SITES = await window.AWGetDefaultSites(); + const importable = JSON.parse(await window.AWGetImportableSites()); + const showImportable = useImportable && importable.length >= 5; + if (!importTelemetrySent.current) { + AboutWelcomeUtils.sendImpressionTelemetry(`${props.message_id}_SITES`, { + display: showImportable ? "importable" : "static", + importable: importable.length, + }); + importTelemetrySent.current = true; + } + setTopSites( + showImportable + ? { data: importable, showImportable } + : { data: DEFAULT_SITES, showImportable } + ); + })(); + }, [useImportable, region]); + + return ( + +
+ {props.screens.map(screen => { + return index === screen.order ? ( + + ) : null; + })} +
+
+ ); +}; + +export class WelcomeScreen extends React.PureComponent { + constructor(props) { + super(props); + this.handleAction = this.handleAction.bind(this); + } + + handleOpenURL(action, flowParams, UTMTerm) { + let { type, data } = action; + if (type === "SHOW_FIREFOX_ACCOUNTS") { + let params = { + ...BASE_PARAMS, + utm_term: `aboutwelcome-${UTMTerm}-screen`, + }; + if (action.addFlowParams && flowParams) { + params = { + ...params, + ...flowParams, + }; + } + data = { ...data, extraParams: params }; + } else if (type === "OPEN_URL") { + let url = new URL(data.args); + addUtmParams(url, `aboutwelcome-${UTMTerm}-screen`); + if (action.addFlowParams && flowParams) { + url.searchParams.append("device_id", flowParams.deviceId); + url.searchParams.append("flow_id", flowParams.flowId); + url.searchParams.append("flow_begin_time", flowParams.flowBeginTime); + } + data = { ...data, args: url.toString() }; + } + AboutWelcomeUtils.handleUserAction({ type, data }); + } + + async handleAction(event) { + let { props } = this; + + let targetContent = + props.content[event.currentTarget.value] || props.content.tiles; + if (!(targetContent && targetContent.action)) { + return; + } + + // Send telemetry before waiting on actions + AboutWelcomeUtils.sendActionTelemetry( + props.messageId, + event.currentTarget.value + ); + + let { action } = targetContent; + + if (["OPEN_URL", "SHOW_FIREFOX_ACCOUNTS"].includes(action.type)) { + this.handleOpenURL(action, props.flowParams, props.UTMTerm); + } else if (action.type) { + AboutWelcomeUtils.handleUserAction(action); + // Wait until migration closes to complete the action + if (action.type === "SHOW_MIGRATION_WIZARD") { + await window.AWWaitForMigrationClose(); + AboutWelcomeUtils.sendActionTelemetry(props.messageId, "migrate_close"); + } + } + + // A special tiles.action.theme value indicates we should use the event's value vs provided value. + if (action.theme) { + let themeToUse = + action.theme === "" + ? event.currentTarget.value + : this.props.initialTheme || action.theme; + + this.props.setActiveTheme(themeToUse); + window.AWSelectTheme(themeToUse); + } + + if (action.navigate) { + props.navigate(); + } + } + + renderSecondaryCTA(className) { + return ( +
+ + + + +
+ ); + } + + renderTiles() { + switch (this.props.content.tiles.type) { + case "topsites": + return this.props.topSites && this.props.topSites.data ? ( +
+
+ {this.props.topSites.data + .slice(0, 5) + .map(({ icon, label, title }) => ( +
+
+ {icon ? "" : label && label[0].toUpperCase()} +
+ {this.props.content.tiles.showTitles && ( +
{title || label}
+ )} +
+ ))} +
+
+ ) : null; + case "theme": + return this.props.content.tiles.data ? ( +
+
+
+ + + + {this.props.content.tiles.data.map( + ({ theme, label, tooltip, description }) => ( + +
+
+
+ ) : null; + case "video": + return this.props.content.tiles.source ? ( +
+
+
+ ) : null; + case "image": + return this.props.content.tiles.source ? ( +
+ +
+ ) : null; + } + return null; + } + + renderStepsIndicator() { + let steps = []; + for (let i = 0; i < this.props.totalNumberOfScreens; i++) { + let className = i === this.props.order ? "current" : ""; + steps.push(
); + } + return steps; + } + + renderHelpText() { + return ( + +

+ + ); + } + + render() { + const { content, topSites } = this.props; + const hasSecondaryTopCTA = + content.secondary_button && content.secondary_button.position === "top"; + const showImportableSitesDisclaimer = + content.tiles && + content.tiles.type === "topsites" && + topSites && + topSites.showImportable; + + return ( +

+ {hasSecondaryTopCTA ? this.renderSecondaryCTA("top") : null} +
+
+ + +

+ +

+ {content.tiles ? this.renderTiles() : null} +
+ +
+ {content.secondary_button && content.secondary_button.position !== "top" + ? this.renderSecondaryCTA() + : null} + {content.help_text && content.help_text.position === "default" + ? this.renderHelpText() + : null} + + {(content.help_text && content.help_text.position === "footer") || + showImportableSitesDisclaimer + ? this.renderHelpText() + : null} +
+ ); + } +} diff --git a/browser/components/newtab/content-src/aboutwelcome/components/ReturnToAMO.jsx b/browser/components/newtab/content-src/aboutwelcome/components/ReturnToAMO.jsx new file mode 100644 index 0000000000..2d5501dc46 --- /dev/null +++ b/browser/components/newtab/content-src/aboutwelcome/components/ReturnToAMO.jsx @@ -0,0 +1,100 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React from "react"; +import { + AboutWelcomeUtils, + DEFAULT_RTAMO_CONTENT, +} from "../../lib/aboutwelcome-utils"; +import { Localized } from "./MSLocalized"; + +export class ReturnToAMO extends React.PureComponent { + constructor(props) { + super(props); + this.onClickAddExtension = this.onClickAddExtension.bind(this); + this.handleStartBtnClick = this.handleStartBtnClick.bind(this); + } + + onClickAddExtension() { + const { content, message_id, url } = this.props; + if (!content?.primary_button?.action?.data) { + return; + } + + // Set add-on url in action.data.url property from JSON + content.primary_button.action.data.url = url; + AboutWelcomeUtils.handleUserAction(content.primary_button.action); + const ping = { + event: "INSTALL", + event_context: { + source: "ADD_EXTENSION_BUTTON", + page: "about:welcome", + }, + message_id, + }; + window.AWSendEventTelemetry(ping); + } + + handleStartBtnClick() { + const { content, message_id } = this.props; + AboutWelcomeUtils.handleUserAction(content.startButton.action); + const ping = { + event: "CLICK_BUTTON", + event_context: { + source: content.startButton.message_id, + page: "about:welcome", + }, + message_id, + }; + window.AWSendEventTelemetry(ping); + } + + render() { + const { content } = this.props; + if (!content) { + return null; + } + // For experiments, when needed below rendered UI allows settings hard coded strings + // directly inside JSON except for ReturnToAMOText which picks add-on name and icon from fluent string + return ( +
+
+
+
+ +

+ + +

+ +

+
+ +

+
+
+ ); + } +} + +ReturnToAMO.defaultProps = DEFAULT_RTAMO_CONTENT; diff --git a/browser/components/newtab/content-src/aboutwelcome/components/SimpleAboutWelcome.jsx b/browser/components/newtab/content-src/aboutwelcome/components/SimpleAboutWelcome.jsx new file mode 100644 index 0000000000..4186cb2d69 --- /dev/null +++ b/browser/components/newtab/content-src/aboutwelcome/components/SimpleAboutWelcome.jsx @@ -0,0 +1,35 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React from "react"; +import { HeroText } from "./HeroText"; +import { FxCards } from "./FxCards"; +import { Localized } from "./MSLocalized"; + +export class SimpleAboutWelcome extends React.PureComponent { + render() { + const { props } = this; + return ( +
+
+
+ + + +
+
+
+ ); + } +} diff --git a/browser/components/newtab/content-src/aboutwelcome/components/Zap.jsx b/browser/components/newtab/content-src/aboutwelcome/components/Zap.jsx new file mode 100644 index 0000000000..a067c4d7fe --- /dev/null +++ b/browser/components/newtab/content-src/aboutwelcome/components/Zap.jsx @@ -0,0 +1,60 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React, { useEffect } from "react"; +import { Localized } from "./MSLocalized"; +const MS_STRING_PROP = "string_id"; +const ZAP_SIZE_THRESHOLD = 160; + +function calculateZapLength() { + let span = document.querySelector(".zap"); + if (!span) { + return; + } + let rect = span.getBoundingClientRect(); + if (rect && rect.width > ZAP_SIZE_THRESHOLD) { + span.classList.add("long"); + } else { + span.classList.add("short"); + } +} + +export const Zap = props => { + useEffect(() => { + requestAnimationFrame(() => calculateZapLength()); + }); + + if (!props.text) { + return null; + } + + if (props.hasZap) { + if (typeof props.text === "object" && props.text[MS_STRING_PROP]) { + return ( + +

+ +

+
+ ); + } else if (typeof props.text === "string") { + // Parse string to zap style last word of the props.text + let titleArray = props.text.split(" "); + let lastWord = `${titleArray.pop()}`; + return ( +

+ {titleArray.join(" ").concat(" ")} + {lastWord} +

+ ); + } + } else { + return ( + +

+ + ); + } + return null; +}; diff --git a/browser/components/newtab/content-src/activity-stream.jsx b/browser/components/newtab/content-src/activity-stream.jsx new file mode 100644 index 0000000000..a49bfa7dce --- /dev/null +++ b/browser/components/newtab/content-src/activity-stream.jsx @@ -0,0 +1,54 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { actionCreators as ac, actionTypes as at } from "common/Actions.jsm"; +import { Base } from "content-src/components/Base/Base"; +import { DetectUserSessionStart } from "content-src/lib/detect-user-session-start"; +import { initStore } from "content-src/lib/init-store"; +import { Provider } from "react-redux"; +import React from "react"; +import ReactDOM from "react-dom"; +import { reducers } from "common/Reducers.jsm"; + +export const NewTab = ({ store }) => ( + + + +); + +export function renderWithoutState() { + const store = initStore(reducers); + new DetectUserSessionStart(store).sendEventOrAddListener(); + + // If this document has already gone into the background by the time we've reached + // here, we can deprioritize requesting the initial state until the event loop + // frees up. If, however, the visibility changes, we then send the request. + let didRequest = false; + let requestIdleCallbackId = 0; + function doRequest() { + if (!didRequest) { + if (requestIdleCallbackId) { + cancelIdleCallback(requestIdleCallbackId); + } + didRequest = true; + store.dispatch(ac.AlsoToMain({ type: at.NEW_TAB_STATE_REQUEST })); + } + } + + if (document.hidden) { + requestIdleCallbackId = requestIdleCallback(doRequest); + addEventListener("visibilitychange", doRequest, { once: true }); + } else { + doRequest(); + } + + ReactDOM.hydrate(, document.getElementById("root")); +} + +export function renderCache(initialState) { + const store = initStore(reducers, initialState); + new DetectUserSessionStart(store).sendEventOrAddListener(); + + ReactDOM.hydrate(, document.getElementById("root")); +} diff --git a/browser/components/newtab/content-src/asrouter/README.md b/browser/components/newtab/content-src/asrouter/README.md new file mode 100644 index 0000000000..0ee3345630 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/README.md @@ -0,0 +1,34 @@ +# Activity Stream Router + +## Preferences `browser.newtab.activity-stream.asrouter.*` + +Name | Used for | Type | Example value +--- | --- | --- | --- +`allowHosts` | Allow a host in order to fetch messages from its endpoint | `[String]` | `["gist.github.com", "gist.githubusercontent.com", "localhost:8000"]` +`providers.snippets` | Message provider options for snippets | `Object` | [see below](#message-providers) +`providers.cfr` | Message provider options for cfr | `Object` | [see below](#message-providers) +`providers.onboarding` | Message provider options for onboarding | `Object` | [see below](#message-providers) +`useRemoteL10n` | Controls whether to use the remote Fluent files for l10n, default as `true` | `Boolean` | `[true|false]` + +### Message providers examples + +```json +{ + "id" : "snippets", + "type" : "remote", + "enabled": true, + "url" : "https://snippets.cdn.mozilla.net/us-west/bundles/bundle_d6d90fb9098ce8b45e60acf601bcb91b68322309.json", + "updateCycleInMs" : 14400000 +} +``` + +```json +{ + "id" : "onboarding", + "enabled": true, + "type" : "local", + "localProvider" : "OnboardingMessageProvider" +} +``` + +### [Snippet message format documentation](https://github.com/mozilla/activity-stream/blob/master/content-src/asrouter/schemas/message-format.md) diff --git a/browser/components/newtab/content-src/asrouter/asrouter-content.jsx b/browser/components/newtab/content-src/asrouter/asrouter-content.jsx new file mode 100644 index 0000000000..0ad8999ebc --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/asrouter-content.jsx @@ -0,0 +1,326 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { MESSAGE_TYPE_HASH as msg } from "common/ActorConstants.jsm"; +import { actionTypes as at } from "common/Actions.jsm"; +import { ASRouterUtils } from "./asrouter-utils"; +import { generateBundles } from "./rich-text-strings"; +import { ImpressionsWrapper } from "./components/ImpressionsWrapper/ImpressionsWrapper"; +import { LocalizationProvider } from "fluent-react"; +import { NEWTAB_DARK_THEME } from "content-src/lib/constants"; +import React from "react"; +import ReactDOM from "react-dom"; +import { SnippetsTemplates } from "./templates/template-manifest"; + +const TEMPLATES_BELOW_SEARCH = ["simple_below_search_snippet"]; + +// Note: nextProps/prevProps refer to props passed to , not +function shouldSendImpressionOnUpdate(nextProps, prevProps) { + return ( + nextProps.message.id && + (!prevProps.message || prevProps.message.id !== nextProps.message.id) + ); +} + +export class ASRouterUISurface extends React.PureComponent { + constructor(props) { + super(props); + this.sendClick = this.sendClick.bind(this); + this.sendImpression = this.sendImpression.bind(this); + this.sendUserActionTelemetry = this.sendUserActionTelemetry.bind(this); + this.onUserAction = this.onUserAction.bind(this); + this.fetchFlowParams = this.fetchFlowParams.bind(this); + this.onBlockSelected = this.onBlockSelected.bind(this); + this.onBlockById = this.onBlockById.bind(this); + this.onDismiss = this.onDismiss.bind(this); + this.onMessageFromParent = this.onMessageFromParent.bind(this); + + this.state = { message: {} }; + if (props.document) { + this.footerPortal = props.document.getElementById( + "footer-asrouter-container" + ); + } + } + + async fetchFlowParams(params = {}) { + let result = {}; + const { fxaEndpoint } = this.props; + if (!fxaEndpoint) { + const err = + "Tried to fetch flow params before fxaEndpoint pref was ready"; + console.error(err); // eslint-disable-line no-console + } + + try { + const urlObj = new URL(fxaEndpoint); + urlObj.pathname = "metrics-flow"; + Object.keys(params).forEach(key => { + urlObj.searchParams.append(key, params[key]); + }); + const response = await fetch(urlObj.toString(), { credentials: "omit" }); + if (response.status === 200) { + const { deviceId, flowId, flowBeginTime } = await response.json(); + result = { deviceId, flowId, flowBeginTime }; + } else { + console.error("Non-200 response", response); // eslint-disable-line no-console + } + } catch (error) { + console.error(error); // eslint-disable-line no-console + } + return result; + } + + sendUserActionTelemetry(extraProps = {}) { + const { message } = this.state; + const eventType = `${message.provider}_user_event`; + const source = extraProps.id; + delete extraProps.id; + ASRouterUtils.sendTelemetry({ + source, + message_id: message.id, + action: eventType, + ...extraProps, + }); + } + + sendImpression(extraProps) { + if (this.state.message.provider === "preview") { + return Promise.resolve(); + } + + this.sendUserActionTelemetry({ event: "IMPRESSION", ...extraProps }); + return ASRouterUtils.sendMessage({ + type: msg.IMPRESSION, + data: this.state.message, + }); + } + + // If link has a `metric` data attribute send it as part of the `event_context` + // telemetry field which can have arbitrary values. + // Used for router messages with links as part of the content. + sendClick(event) { + const { dataset } = event.target; + const metric = { + event_context: dataset.metric, + // Used for the `source` of the event. Needed to differentiate + // from other snippet or onboarding events that may occur. + id: "NEWTAB_FOOTER_BAR_CONTENT", + }; + const { entrypoint_name, entrypoint_value } = dataset; + // Assign the snippet referral for the action + const entrypoint = entrypoint_name + ? new URLSearchParams([[entrypoint_name, entrypoint_value]]).toString() + : entrypoint_value; + const action = { + type: dataset.action, + data: { + args: dataset.args, + ...(entrypoint && { entrypoint }), + }, + }; + if (action.type) { + ASRouterUtils.executeAction(action); + } + if ( + !this.state.message.content.do_not_autoblock && + !dataset.do_not_autoblock + ) { + this.onBlockById(this.state.message.id); + } + if (this.state.message.provider !== "preview") { + this.sendUserActionTelemetry({ event: "CLICK_BUTTON", ...metric }); + } + } + + onBlockSelected(options) { + return this.onBlockById(this.state.message.id, options); + } + + onBlockById(id, options) { + return ASRouterUtils.blockById(id, options).then(clearAll => { + if (clearAll) { + this.setState({ message: {} }); + } + }); + } + + onDismiss() { + this.clearMessage(this.state.message.id); + } + + clearMessage(id) { + if (id === this.state.message.id) { + this.setState({ message: {} }); + } + } + + clearProvider(id) { + if (this.state.message.provider === id) { + this.setState({ message: {} }); + } + } + + onMessageFromParent({ type, data }) { + // These only exists due to onPrefChange events in ASRouter + switch (type) { + case "ClearMessages": { + data.forEach(id => this.clearMessage(id)); + break; + } + case "ClearProviders": { + data.forEach(id => this.clearProvider(id)); + break; + } + case "EnterSnippetsPreviewMode": { + this.props.dispatch({ type: at.SNIPPETS_PREVIEW_MODE }); + break; + } + } + } + + requestMessage(endpoint) { + ASRouterUtils.sendMessage({ + type: "NEWTAB_MESSAGE_REQUEST", + data: { endpoint }, + }).then(state => this.setState(state)); + } + + componentWillMount() { + const endpoint = ASRouterUtils.getPreviewEndpoint(); + if (endpoint && endpoint.theme === "dark") { + global.window.dispatchEvent( + new CustomEvent("LightweightTheme:Set", { + detail: { data: NEWTAB_DARK_THEME }, + }) + ); + } + if (endpoint && endpoint.dir === "rtl") { + //Set `dir = rtl` on the HTML + this.props.document.dir = "rtl"; + } + ASRouterUtils.addListener(this.onMessageFromParent); + this.requestMessage(endpoint); + } + + componentWillUnmount() { + ASRouterUtils.removeListener(this.onMessageFromParent); + } + + componentDidUpdate(prevProps) { + if ( + prevProps.adminContent && + JSON.stringify(prevProps.adminContent) !== + JSON.stringify(this.props.adminContent) + ) { + this.updateContent(); + } + } + + updateContent() { + this.setState({ + ...this.props.adminContent, + }); + } + + async getMonitorUrl({ url, flowRequestParams = {} }) { + const flowValues = await this.fetchFlowParams(flowRequestParams); + + // Note that flowParams are actually added dynamically on the page + const urlObj = new URL(url); + ["deviceId", "flowId", "flowBeginTime"].forEach(key => { + if (key in flowValues) { + urlObj.searchParams.append(key, flowValues[key]); + } + }); + + return urlObj.toString(); + } + + async onUserAction(action) { + switch (action.type) { + // This needs to be handled locally because its + case "ENABLE_FIREFOX_MONITOR": + const url = await this.getMonitorUrl(action.data.args); + ASRouterUtils.executeAction({ type: "OPEN_URL", data: { args: url } }); + break; + default: + ASRouterUtils.executeAction(action); + } + } + + renderSnippets() { + const { message } = this.state; + if (!SnippetsTemplates[message.template]) { + return null; + } + const SnippetComponent = SnippetsTemplates[message.template]; + const { content } = this.state.message; + + return ( + + + + + + ); + } + + renderPreviewBanner() { + if (this.state.message.provider !== "preview") { + return null; + } + + return ( +
+ + Preview Purposes Only +
+ ); + } + + render() { + const { message } = this.state; + if (!message.id) { + return null; + } + const shouldRenderBelowSearch = TEMPLATES_BELOW_SEARCH.includes( + message.template + ); + + return shouldRenderBelowSearch ? ( + // Render special below search snippets in place; +
+ {this.renderSnippets()} +
+ ) : ( + // For regular snippets etc. we should render everything in our footer + // container. + ReactDOM.createPortal( + <> + {this.renderPreviewBanner()} + {this.renderSnippets()} + , + this.footerPortal + ) + ); + } +} + +ASRouterUISurface.defaultProps = { document: global.document }; diff --git a/browser/components/newtab/content-src/asrouter/asrouter-utils.js b/browser/components/newtab/content-src/asrouter/asrouter-utils.js new file mode 100644 index 0000000000..fe7f0110f2 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/asrouter-utils.js @@ -0,0 +1,108 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { MESSAGE_TYPE_HASH as msg } from "common/ActorConstants.jsm"; +import { actionCreators as ac } from "common/Actions.jsm"; + +export const ASRouterUtils = { + addListener(listener) { + if (global.ASRouterAddParentListener) { + global.ASRouterAddParentListener(listener); + } + }, + removeListener(listener) { + if (global.ASRouterRemoveParentListener) { + global.ASRouterRemoveParentListener(listener); + } + }, + sendMessage(action) { + if (global.ASRouterMessage) { + return global.ASRouterMessage(action); + } + throw new Error(`Unexpected call:\n${JSON.stringify(action, null, 3)}`); + }, + blockById(id, options) { + return ASRouterUtils.sendMessage({ + type: msg.BLOCK_MESSAGE_BY_ID, + data: { id, ...options }, + }); + }, + modifyMessageJson(content) { + return ASRouterUtils.sendMessage({ + type: msg.MODIFY_MESSAGE_JSON, + data: { content }, + }); + }, + executeAction(button_action) { + return ASRouterUtils.sendMessage({ + type: msg.USER_ACTION, + data: button_action, + }); + }, + unblockById(id) { + return ASRouterUtils.sendMessage({ + type: msg.UNBLOCK_MESSAGE_BY_ID, + data: { id }, + }); + }, + blockBundle(bundle) { + return ASRouterUtils.sendMessage({ + type: msg.BLOCK_BUNDLE, + data: { bundle }, + }); + }, + unblockBundle(bundle) { + return ASRouterUtils.sendMessage({ + type: msg.UNBLOCK_BUNDLE, + data: { bundle }, + }); + }, + overrideMessage(id) { + return ASRouterUtils.sendMessage({ + type: msg.OVERRIDE_MESSAGE, + data: { id }, + }); + }, + sendTelemetry(ping) { + return ASRouterUtils.sendMessage(ac.ASRouterUserEvent(ping)); + }, + getPreviewEndpoint() { + if ( + global.document && + global.document.location && + global.document.location.href.includes("endpoint") + ) { + const params = new URLSearchParams( + global.document.location.href.slice( + global.document.location.href.indexOf("endpoint") + ) + ); + try { + const endpoint = new URL(params.get("endpoint")); + return { + url: endpoint.href, + snippetId: params.get("snippetId"), + theme: this.getPreviewTheme(), + dir: this.getPreviewDir(), + }; + } catch (e) {} + } + + return null; + }, + getPreviewTheme() { + return new URLSearchParams( + global.document.location.href.slice( + global.document.location.href.indexOf("theme") + ) + ).get("theme"); + }, + getPreviewDir() { + return new URLSearchParams( + global.document.location.href.slice( + global.document.location.href.indexOf("dir") + ) + ).get("dir"); + }, +}; diff --git a/browser/components/newtab/content-src/asrouter/components/Button/Button.jsx b/browser/components/newtab/content-src/asrouter/components/Button/Button.jsx new file mode 100644 index 0000000000..b3ece86f16 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/components/Button/Button.jsx @@ -0,0 +1,32 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React from "react"; + +const ALLOWED_STYLE_TAGS = ["color", "backgroundColor"]; + +export const Button = props => { + const style = {}; + + // Add allowed style tags from props, e.g. props.color becomes style={color: props.color} + for (const tag of ALLOWED_STYLE_TAGS) { + if (typeof props[tag] !== "undefined") { + style[tag] = props[tag]; + } + } + // remove border if bg is set to something custom + if (style.backgroundColor) { + style.border = "0"; + } + + return ( + + ); +}; diff --git a/browser/components/newtab/content-src/asrouter/components/Button/_Button.scss b/browser/components/newtab/content-src/asrouter/components/Button/_Button.scss new file mode 100644 index 0000000000..330bfbb4fb --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/components/Button/_Button.scss @@ -0,0 +1,94 @@ +.ASRouterButton { + font-weight: 600; + font-size: 14px; + white-space: nowrap; + border-radius: 2px; + border: 0; + font-family: inherit; + padding: 8px 15px; + margin-inline-start: 12px; + color: inherit; + cursor: pointer; + + .tall & { + margin-inline-start: 20px; + } + + &.test-only { + width: 0; + height: 0; + overflow: hidden; + display: block; + visibility: hidden; + } + + &.primary { + border: 1px solid var(--newtab-button-primary-color); + background-color: var(--newtab-button-primary-color); + color: $grey-10; + + &:hover { + background-color: $blue-70; + } + + &:active { + background-color: $blue-80; + } + } + + &.secondary { + background-color: $grey-90-10; + + &:hover { + background-color: $grey-90-20; + } + + &:active { + background-color: $grey-90-30; + } + + &:focus { + box-shadow: 0 0 0 1px $blue-50 inset, 0 0 0 1px $blue-50, 0 0 0 4px $blue-50-30; + } + } + + &.slim { + background-color: $grey-90-10; + margin-inline-start: 0; + font-size: 12px; + padding: 6px 12px; + + &:hover { + background-color: $grey-90-20; + } + } +} + +[lwt-newtab-brighttext] { + .secondary { + background-color: $grey-10-10; + + &:hover { + background-color: $grey-10-20; + } + + &:active { + background-color: $grey-10-30; + } + } + + // Snippets scene 2 footer + .footer { + .secondary { + background-color: $grey-10-30; + + &:hover { + background-color: $grey-10-40; + } + + &:active { + background-color: $grey-10-50; + } + } + } +} diff --git a/browser/components/newtab/content-src/asrouter/components/ConditionalWrapper/ConditionalWrapper.jsx b/browser/components/newtab/content-src/asrouter/components/ConditionalWrapper/ConditionalWrapper.jsx new file mode 100644 index 0000000000..e4b0812f26 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/components/ConditionalWrapper/ConditionalWrapper.jsx @@ -0,0 +1,9 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// lifted from https://gist.github.com/kitze/23d82bb9eb0baabfd03a6a720b1d637f +const ConditionalWrapper = ({ condition, wrap, children }) => + condition && wrap ? wrap(children) : children; + +export default ConditionalWrapper; diff --git a/browser/components/newtab/content-src/asrouter/components/ImpressionsWrapper/ImpressionsWrapper.jsx b/browser/components/newtab/content-src/asrouter/components/ImpressionsWrapper/ImpressionsWrapper.jsx new file mode 100644 index 0000000000..8498bde03b --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/components/ImpressionsWrapper/ImpressionsWrapper.jsx @@ -0,0 +1,76 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React from "react"; + +export const VISIBLE = "visible"; +export const VISIBILITY_CHANGE_EVENT = "visibilitychange"; + +/** + * Component wrapper used to send telemetry pings on every impression. + */ +export class ImpressionsWrapper extends React.PureComponent { + // This sends an event when a user sees a set of new content. If content + // changes while the page is hidden (i.e. preloaded or on a hidden tab), + // only send the event if the page becomes visible again. + sendImpressionOrAddListener() { + if (this.props.document.visibilityState === VISIBLE) { + this.props.sendImpression({ id: this.props.id }); + } else { + // We should only ever send the latest impression stats ping, so remove any + // older listeners. + if (this._onVisibilityChange) { + this.props.document.removeEventListener( + VISIBILITY_CHANGE_EVENT, + this._onVisibilityChange + ); + } + + // When the page becomes visible, send the impression stats ping if the section isn't collapsed. + this._onVisibilityChange = () => { + if (this.props.document.visibilityState === VISIBLE) { + this.props.sendImpression({ id: this.props.id }); + this.props.document.removeEventListener( + VISIBILITY_CHANGE_EVENT, + this._onVisibilityChange + ); + } + }; + this.props.document.addEventListener( + VISIBILITY_CHANGE_EVENT, + this._onVisibilityChange + ); + } + } + + componentWillUnmount() { + if (this._onVisibilityChange) { + this.props.document.removeEventListener( + VISIBILITY_CHANGE_EVENT, + this._onVisibilityChange + ); + } + } + + componentDidMount() { + if (this.props.sendOnMount) { + this.sendImpressionOrAddListener(); + } + } + + componentDidUpdate(prevProps) { + if (this.props.shouldSendImpressionOnUpdate(this.props, prevProps)) { + this.sendImpressionOrAddListener(); + } + } + + render() { + return this.props.children; + } +} + +ImpressionsWrapper.defaultProps = { + document: global.document, + sendOnMount: true, +}; diff --git a/browser/components/newtab/content-src/asrouter/components/ModalOverlay/ModalOverlay.jsx b/browser/components/newtab/content-src/asrouter/components/ModalOverlay/ModalOverlay.jsx new file mode 100644 index 0000000000..fdfdf22db2 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/components/ModalOverlay/ModalOverlay.jsx @@ -0,0 +1,56 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React from "react"; + +export class ModalOverlayWrapper extends React.PureComponent { + constructor(props) { + super(props); + this.onKeyDown = this.onKeyDown.bind(this); + } + + // The intended behaviour is to listen for an escape key + // but not for a click; see Bug 1582242 + onKeyDown(event) { + if (event.key === "Escape") { + this.props.onClose(event); + } + } + + componentWillMount() { + this.props.document.addEventListener("keydown", this.onKeyDown); + this.props.document.body.classList.add("modal-open"); + } + + componentWillUnmount() { + this.props.document.removeEventListener("keydown", this.onKeyDown); + this.props.document.body.classList.remove("modal-open"); + } + + render() { + const { props } = this; + let className = props.unstyled ? "" : "modalOverlayInner active"; + if (props.innerClassName) { + className += ` ${props.innerClassName}`; + } + return ( +
+ +
+ ); + } +} + +ModalOverlayWrapper.defaultProps = { document: global.document }; diff --git a/browser/components/newtab/content-src/asrouter/components/ModalOverlay/_ModalOverlay.scss b/browser/components/newtab/content-src/asrouter/components/ModalOverlay/_ModalOverlay.scss new file mode 100644 index 0000000000..2cdbfcb1db --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/components/ModalOverlay/_ModalOverlay.scss @@ -0,0 +1,104 @@ +// Variable for the about:welcome modal scrollbars +$modal-scrollbar-z-index: 1100; + +.activity-stream { + &.modal-open { + overflow: hidden; + } +} + +.modalOverlayOuter { + background: var(--newtab-overlay-color); + height: 100%; + position: fixed; + top: 0; + left: 0; + width: 100%; + display: none; + z-index: $modal-scrollbar-z-index; + overflow: auto; + + &.active { + display: flex; + } +} + +.modalOverlayInner { + min-width: min-content; + width: 100%; + max-width: 960px; + position: relative; + margin: auto; + background: var(--newtab-modal-color); + box-shadow: 0 1px 15px 0 $black-30; + border-radius: 4px; + display: none; + z-index: $modal-scrollbar-z-index; + + // modal takes over entire screen + @media(max-width: 960px) { + height: 100%; + top: 0; + left: 0; + box-shadow: none; + border-radius: 0; + } + + &.active { + display: block; + } + + h2 { + color: $grey-60; + text-align: center; + font-weight: 200; + margin-top: 30px; + font-size: 28px; + line-height: 37px; + letter-spacing: -0.13px; + + @media(max-width: 960px) { + margin-top: 100px; + } + + @media(max-width: 850px) { + margin-top: 30px; + } + } + + .footer { + border-top: 1px solid $grey-30; + border-radius: 4px; + height: 70px; + width: 100%; + position: absolute; + bottom: 0; + text-align: center; + background-color: $white; + + // if modal is short enough, footer becomes sticky + @media(max-width: 850px) and (max-height: 730px) { + position: sticky; + } + + // if modal is narrow enough, footer becomes sticky + @media(max-width: 650px) and (max-height: 600px) { + position: sticky; + } + + .modalButton { + margin-top: 20px; + min-width: 150px; + height: 30px; + padding: 4px 30px 6px; + font-size: 15px; + + &:focus, + &.active, + &:hover { + box-shadow: 0 0 0 5px $grey-30; + transition: box-shadow 150ms; + } + } + } +} diff --git a/browser/components/newtab/content-src/asrouter/components/RichText/RichText.jsx b/browser/components/newtab/content-src/asrouter/components/RichText/RichText.jsx new file mode 100644 index 0000000000..45e35b83cc --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/components/RichText/RichText.jsx @@ -0,0 +1,83 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { Localized } from "fluent-react"; +import React from "react"; +import { RICH_TEXT_KEYS } from "../../rich-text-strings"; +import { safeURI } from "../../template-utils"; + +// Elements allowed in snippet content +const ALLOWED_TAGS = { + b: , + i: , + u: , + strong: , + em: , + br:
, +}; + +/** + * Transform an object (tag name: {url}) into (tag name: anchor) where the url + * is used as href, in order to render links inside a Fluent.Localized component. + */ +export function convertLinks( + links, + sendClick, + doNotAutoBlock, + openNewWindow = false +) { + if (links) { + return Object.keys(links).reduce((acc, linkTag) => { + const { action } = links[linkTag]; + // Setting the value to false will not include the attribute in the anchor + const url = action ? false : safeURI(links[linkTag].url); + + acc[linkTag] = ( + // eslint was getting a false positive caused by the dynamic injection + // of content. + // eslint-disable-next-line jsx-a11y/anchor-has-content + + ); + return acc; + }, {}); + } + + return null; +} + +/** + * Message wrapper used to sanitize markup and render HTML. + */ +export function RichText(props) { + if (!RICH_TEXT_KEYS.includes(props.localization_id)) { + throw new Error( + `ASRouter: ${props.localization_id} is not a valid rich text property. If you want it to be processed, you need to add it to asrouter/rich-text-strings.js` + ); + } + return ( + + {props.text} + + ); +} diff --git a/browser/components/newtab/content-src/asrouter/components/SnippetBase/SnippetBase.jsx b/browser/components/newtab/content-src/asrouter/components/SnippetBase/SnippetBase.jsx new file mode 100644 index 0000000000..fd25337fbf --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/components/SnippetBase/SnippetBase.jsx @@ -0,0 +1,121 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React from "react"; + +export class SnippetBase extends React.PureComponent { + constructor(props) { + super(props); + this.onBlockClicked = this.onBlockClicked.bind(this); + this.onDismissClicked = this.onDismissClicked.bind(this); + this.setBlockButtonRef = this.setBlockButtonRef.bind(this); + this.onBlockButtonMouseEnter = this.onBlockButtonMouseEnter.bind(this); + this.onBlockButtonMouseLeave = this.onBlockButtonMouseLeave.bind(this); + this.state = { blockButtonHover: false }; + } + + componentDidMount() { + if (this.blockButtonRef) { + this.blockButtonRef.addEventListener( + "mouseenter", + this.onBlockButtonMouseEnter + ); + this.blockButtonRef.addEventListener( + "mouseleave", + this.onBlockButtonMouseLeave + ); + } + } + + componentWillUnmount() { + if (this.blockButtonRef) { + this.blockButtonRef.removeEventListener( + "mouseenter", + this.onBlockButtonMouseEnter + ); + this.blockButtonRef.removeEventListener( + "mouseleave", + this.onBlockButtonMouseLeave + ); + } + } + + setBlockButtonRef(element) { + this.blockButtonRef = element; + } + + onBlockButtonMouseEnter() { + this.setState({ blockButtonHover: true }); + } + + onBlockButtonMouseLeave() { + this.setState({ blockButtonHover: false }); + } + + onBlockClicked() { + if (this.props.provider !== "preview") { + this.props.sendUserActionTelemetry({ + event: "BLOCK", + id: this.props.UISurface, + }); + } + + this.props.onBlock(); + } + + onDismissClicked() { + if (this.props.provider !== "preview") { + this.props.sendUserActionTelemetry({ + event: "DISMISS", + id: this.props.UISurface, + }); + } + + this.props.onDismiss(); + } + + renderDismissButton() { + if (this.props.footerDismiss) { + return ( +
+
+ +
+
+ ); + } + + const label = this.props.content.block_button_text || "Remove this"; + return ( + + + ); + } + + render() { + const textStyle = { + color: this.props.content.text_color, + backgroundColor: this.props.content.background_color, + }; + const customElement = ( + + ); + return ( + + ); + } +} + +export const EOYSnippet = props => { + const extendedContent = { + monthly_checkbox_label_text: "Make my donation monthly", + locale: "en-US", + currency_code: "usd", + selected_button: "donation_amount_second", + ...props.content, + }; + + return ( + + ); +}; diff --git a/browser/components/newtab/content-src/asrouter/templates/EOYSnippet/EOYSnippet.schema.json b/browser/components/newtab/content-src/asrouter/templates/EOYSnippet/EOYSnippet.schema.json new file mode 100644 index 0000000000..a82de98e09 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/EOYSnippet/EOYSnippet.schema.json @@ -0,0 +1,159 @@ +{ + "title": "EOYSnippet", + "description": "Fundraising Snippet", + "version": "1.1.0", + "type": "object", + "definitions": { + "plainText": { + "description": "Plain text (no HTML allowed)", + "type": "string" + }, + "richText": { + "description": "Text with HTML subset allowed: i, b, u, strong, em, br", + "type": "string" + }, + "link_url": { + "description": "Target for links or buttons", + "type": "string", + "format": "uri" + } + }, + "properties": { + "donation_form_url": { + "type": "string", + "description": "Url to the donation form." + }, + "currency_code": { + "type": "string", + "description": "The code for the currency. Examle gbp, cad, usd.", + "default": "usd" + }, + "locale": { + "type": "string", + "description": "String for the locale code.", + "default": "en-US" + }, + "text": { + "allOf": [ + {"$ref": "#/definitions/richText"}, + {"description": "Main body text of snippet. HTML subset allowed: i, b, u, strong, em, br"} + ] + }, + "text_color": { + "type": "string", + "description": "Modify the text message color" + }, + "background_color": { + "type": "string", + "description": "Snippet background color." + }, + "highlight_color": { + "type": "string", + "description": "Paragraph em highlight color." + }, + "donation_amount_first": { + "type": "number", + "description": "First button amount." + }, + "donation_amount_second": { + "type": "number", + "description": "Second button amount." + }, + "donation_amount_third": { + "type": "number", + "description": "Third button amount." + }, + "donation_amount_fourth": { + "type": "number", + "description": "Fourth button amount." + }, + "selected_button": { + "type": "string", + "description": "Default donation_amount_second. Donation amount button that's selected by default.", + "default": "donation_amount_second" + }, + "icon": { + "type": "string", + "description": "Snippet icon. 64x64px. SVG or PNG preferred." + }, + "icon_dark_theme": { + "type": "string", + "description": "Snippet icon. Dark theme variant. 64x64px. SVG or PNG preferred." + }, + "icon_alt_text": { + "type": "string", + "description": "Alt text for accessibility", + "default": "" + }, + "title": { + "allOf": [ + {"$ref": "#/definitions/plainText"}, + {"description": "Snippet title displayed before snippet text"} + ] + }, + "title_icon": { + "type": "string", + "description": "Small icon that shows up before the title / text. 16x16px. SVG or PNG preferred. Grayscale." + }, + "title_icon_dark_theme": { + "type": "string", + "description": "Small icon that shows up before the title / text. Dark theme variant. 16x16px. SVG or PNG preferred. Grayscale." + }, + "button_label": { + "allOf": [ + {"$ref": "#/definitions/plainText"}, + {"description": "Text for a button next to main snippet text that links to button_url. Requires button_url."} + ] + }, + "button_color": { + "type": "string", + "description": "The text color of the button. Valid CSS color." + }, + "button_background_color": { + "type": "string", + "description": "The background color of the button. Valid CSS color." + }, + "block_button_text": { + "type": "string", + "description": "Tooltip text used for dismiss button." + }, + "monthly_checkbox_label_text": { + "type": "string", + "description": "Label text for monthly checkbox.", + "default": "Make my donation monthly" + }, + "test": { + "type": "string", + "description": "Different styles for the snippet. Options are bold and takeover." + }, + "do_not_autoblock": { + "type": "boolean", + "description": "Used to prevent blocking the snippet after the CTA (link or button) has been clicked" + }, + "links": { + "additionalProperties": { + "url": { + "allOf": [ + {"$ref": "#/definitions/link_url"}, + {"description": "The url where the link points to."} + ] + }, + "metric": { + "type": "string", + "description": "Custom event name sent with telemetry event." + }, + "args": { + "type": "string", + "description": "Additional parameters for link action, example which specific menu the button should open" + } + } + } + }, + "additionalProperties": false, + "required": ["text", "donation_form_url", "donation_amount_first", "donation_amount_second", "donation_amount_third", "donation_amount_fourth", "button_label", "currency_code"], + "dependencies": { + "button_color": ["button_label"], + "button_background_color": ["button_label"] + } +} + diff --git a/browser/components/newtab/content-src/asrouter/templates/EOYSnippet/_EOYSnippet.scss b/browser/components/newtab/content-src/asrouter/templates/EOYSnippet/_EOYSnippet.scss new file mode 100644 index 0000000000..ef17606e80 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/EOYSnippet/_EOYSnippet.scss @@ -0,0 +1,54 @@ +.EOYSnippetForm { + margin: 10px 0 8px; + align-self: start; + font-size: 14px; + display: flex; + align-items: center; + + .donation-amount, + .donation-form-url { + white-space: nowrap; + font-size: 14px; + padding: 8px 20px; + border-radius: 2px; + } + + .donation-amount { + color: $grey-90; + margin-inline-end: 18px; + border: 1px solid $grey-40; + padding: 5px 14px; + background: $grey-10; + cursor: pointer; + } + + input { + &[type='radio'] { + opacity: 0; + margin-inline-end: -18px; + + &:checked + .donation-amount { + background: $grey-50; + color: $white; + border: 1px solid $grey-60; + } + + // accessibility + &:checked:focus + .donation-amount, + &:not(:checked):focus + .donation-amount { + border: 1px dotted var(--newtab-link-primary-color); + } + } + } + + .monthly-checkbox-container { + display: flex; + width: 100%; + } + + .donation-form-url { + margin-inline-start: 18px; + align-self: flex-end; + display: flex; + } +} diff --git a/browser/components/newtab/content-src/asrouter/templates/FXASignupSnippet/FXASignupSnippet.jsx b/browser/components/newtab/content-src/asrouter/templates/FXASignupSnippet/FXASignupSnippet.jsx new file mode 100644 index 0000000000..1d8197d675 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/FXASignupSnippet/FXASignupSnippet.jsx @@ -0,0 +1,38 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React from "react"; +import { SubmitFormSnippet } from "../SubmitFormSnippet/SubmitFormSnippet.jsx"; + +export const FXASignupSnippet = props => { + const userAgent = window.navigator.userAgent.match(/Firefox\/([0-9]+)\./); + const firefox_version = userAgent ? parseInt(userAgent[1], 10) : 0; + const extendedContent = { + scene1_button_label: "Learn more", + retry_button_label: "Try again", + scene2_email_placeholder_text: "Your email here", + scene2_button_label: "Sign me up", + scene2_dismiss_button_text: "Dismiss", + ...props.content, + hidden_inputs: { + action: "email", + context: "fx_desktop_v3", + entrypoint: "snippets", + utm_source: "snippet", + utm_content: firefox_version, + utm_campaign: props.content.utm_campaign, + utm_term: props.content.utm_term, + ...props.content.hidden_inputs, + }, + }; + + return ( + + ); +}; diff --git a/browser/components/newtab/content-src/asrouter/templates/FXASignupSnippet/FXASignupSnippet.schema.json b/browser/components/newtab/content-src/asrouter/templates/FXASignupSnippet/FXASignupSnippet.schema.json new file mode 100644 index 0000000000..d7d3e37bbc --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/FXASignupSnippet/FXASignupSnippet.schema.json @@ -0,0 +1,187 @@ +{ + "title": "FXASignupSnippet", + "description": "A snippet template for FxA sign up/sign in", + "version": "1.2.0", + "type": "object", + "definitions": { + "plainText": { + "description": "Plain text (no HTML allowed)", + "type": "string" + }, + "richText": { + "description": "Text with HTML subset allowed: i, b, u, strong, em, br", + "type": "string" + }, + "link_url": { + "description": "Target for links or buttons", + "type": "string", + "format": "uri" + } + }, + "properties": { + "scene1_title": { + "allof": [ + {"$ref": "#/definitions/plainText"}, + {"description": "snippet title displayed before snippet text"} + ] + }, + "scene1_text": { + "allOf": [ + {"$ref": "#/definitions/richText"}, + {"description": "Main body text of snippet. HTML subset allowed: i, b, u, strong, em, br"} + ] + }, + "scene1_section_title_icon": { + "type": "string", + "description": "Section title icon for scene 1. 16x16px. SVG or PNG preferred. scene1_section_title_text must also be specified to display." + }, + "scene1_section_title_icon_dark_theme": { + "type": "string", + "description": "Section title icon for scene 1, dark theme variant. 16x16px. SVG or PNG preferred. scene1_section_title_text must also be specified to display." + }, + "scene1_section_title_text": { + "type": "string", + "description": "Section title text for scene 1. scene1_section_title_icon must also be specified to display." + }, + "scene1_section_title_url": { + "allOf": [ + {"$ref": "#/definitions/link_url"}, + {"description": "A url, scene1_section_title_text links to this"} + ] + }, + "scene2_title": { + "allOf": [ + {"$ref": "#/definitions/plainText"}, + {"description": "Title displayed before text in scene 2. Should be plain text."} + ] + }, + "scene2_text": { + "allOf": [ + {"$ref": "#/definitions/richText"}, + {"description": "Main body text of snippet. HTML subset allowed: i, b, u, strong, em, br"} + ] + }, + "scene1_icon": { + "type": "string", + "description": "Snippet icon. 64x64px. SVG or PNG preferred." + }, + "scene1_icon_dark_theme": { + "type": "string", + "description": "Snippet icon. Dark theme variant. 64x64px. SVG or PNG preferred." + }, + "scene1_title_icon": { + "type": "string", + "description": "Small icon that shows up before the title / text. 16x16px. SVG or PNG preferred. Grayscale." + }, + "scene1_title_icon_dark_theme": { + "type": "string", + "description": "Small icon that shows up before the title / text. Dark theme variant. 16x16px. SVG or PNG preferred. Grayscale." + }, + "scene2_email_placeholder_text": { + "type": "string", + "description": "Value to show while input is empty.", + "default": "Your email here" + }, + "scene2_button_label": { + "type": "string", + "description": "Label for form submit button", + "default": "Sign me up" + }, + "scene2_dismiss_button_text": { + "type": "string", + "description": "Label for the dismiss button when the sign-up form is expanded.", + "default": "Dismiss" + }, + "hidden_inputs": { + "type": "object", + "description": "Each entry represents a hidden input, key is used as value for the name property.", + "properties": { + "action": { + "type": "string", + "enum": ["email"] + }, + "context": { + "type": "string", + "enum": ["fx_desktop_v3"] + }, + "entrypoint": { + "type": "string", + "enum": ["snippets"] + }, + "utm_content": { + "type": "number", + "description": "Firefox version number" + }, + "utm_source": { + "type": "string", + "enum": ["snippet"] + }, + "utm_campaign": { + "type": "string", + "description": "(fxa) Value to pass through to GA as utm_campaign." + }, + "utm_term": { + "type": "string", + "description": "(fxa) Value to pass through to GA as utm_term." + }, + "additionalProperties": false + } + }, + "scene1_button_label": { + "allOf": [ + {"$ref": "#/definitions/plainText"}, + {"description": "Text for a button next to main snippet text that links to button_url. Requires button_url."} + ], + "default": "Learn more" + }, + "scene1_button_color": { + "type": "string", + "description": "The text color of the button. Valid CSS color." + }, + "scene1_button_background_color": { + "type": "string", + "description": "The background color of the button. Valid CSS color." + }, + "retry_button_label": { + "allOf": [ + {"$ref": "#/definitions/plainText"}, + {"description": "Text for the button in the event of a submission error/failure."} + ], + "default": "Try again" + }, + "do_not_autoblock": { + "type": "boolean", + "description": "Used to prevent blocking the snippet after the CTA (link or button) has been clicked", + "default": false + }, + "utm_campaign": { + "type": "string", + "description": "(fxa) Value to pass through to GA as utm_campaign." + }, + "utm_term": { + "type": "string", + "description": "(fxa) Value to pass through to GA as utm_term." + }, + "links": { + "additionalProperties": { + "url": { + "allOf": [ + {"$ref": "#/definitions/link_url"}, + {"description": "The url where the link points to."} + ] + }, + "metric": { + "type": "string", + "description": "Custom event name sent with telemetry event." + } + } + } + }, + "additionalProperties": false, + "required": ["scene1_text", "scene2_text", "scene1_button_label"], + "dependencies": { + "scene1_button_color": ["scene1_button_label"], + "scene1_button_background_color": ["scene1_button_label"] + } +} + diff --git a/browser/components/newtab/content-src/asrouter/templates/FirstRun/addUtmParams.js b/browser/components/newtab/content-src/asrouter/templates/FirstRun/addUtmParams.js new file mode 100644 index 0000000000..cb29f66d6e --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/FirstRun/addUtmParams.js @@ -0,0 +1,30 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * BASE_PARAMS keys/values can be modified from outside this file + */ +export const BASE_PARAMS = { + utm_source: "activity-stream", + utm_campaign: "firstrun", + utm_medium: "referral", +}; + +/** + * Takes in a url as a string or URL object and returns a URL object with the + * utm_* parameters added to it. If a URL object is passed in, the paraemeters + * are added to it (the return value can be ignored in that case as it's the + * same object). + */ +export function addUtmParams(url, utmTerm) { + let returnUrl = url; + if (typeof returnUrl === "string") { + returnUrl = new URL(url); + } + Object.keys(BASE_PARAMS).forEach(key => { + returnUrl.searchParams.append(key, BASE_PARAMS[key]); + }); + returnUrl.searchParams.append("utm_term", utmTerm); + return returnUrl; +} diff --git a/browser/components/newtab/content-src/asrouter/templates/NewsletterSnippet/NewsletterSnippet.jsx b/browser/components/newtab/content-src/asrouter/templates/NewsletterSnippet/NewsletterSnippet.jsx new file mode 100644 index 0000000000..27c1684762 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/NewsletterSnippet/NewsletterSnippet.jsx @@ -0,0 +1,34 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React from "react"; +import { SubmitFormSnippet } from "../SubmitFormSnippet/SubmitFormSnippet.jsx"; + +export const NewsletterSnippet = props => { + const extendedContent = { + scene1_button_label: "Learn more", + retry_button_label: "Try again", + scene2_email_placeholder_text: "Your email here", + scene2_button_label: "Sign me up", + scene2_dismiss_button_text: "Dismiss", + scene2_newsletter: "mozilla-foundation", + ...props.content, + hidden_inputs: { + newsletters: props.content.scene2_newsletter || "mozilla-foundation", + fmt: "H", + lang: props.content.locale || "en-US", + source_url: `https://snippets.mozilla.com/show/${props.id}`, + ...props.content.hidden_inputs, + }, + }; + + return ( + + ); +}; diff --git a/browser/components/newtab/content-src/asrouter/templates/NewsletterSnippet/NewsletterSnippet.schema.json b/browser/components/newtab/content-src/asrouter/templates/NewsletterSnippet/NewsletterSnippet.schema.json new file mode 100644 index 0000000000..eeb63554ed --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/NewsletterSnippet/NewsletterSnippet.schema.json @@ -0,0 +1,177 @@ +{ + "title": "NewsletterSnippet", + "description": "A snippet template for send to device mobile download", + "version": "1.2.0", + "type": "object", + "definitions": { + "plainText": { + "description": "Plain text (no HTML allowed)", + "type": "string" + }, + "richText": { + "description": "Text with HTML subset allowed: i, b, u, strong, em, br", + "type": "string" + }, + "link_url": { + "description": "Target for links or buttons", + "type": "string", + "format": "uri" + } + }, + "properties": { + "locale": { + "type": "string", + "description": "Two to five character string for the locale code", + "default": "en-US" + }, + "scene1_title": { + "allof": [ + {"$ref": "#/definitions/plainText"}, + {"description": "snippet title displayed before snippet text"} + ] + }, + "scene1_text": { + "allOf": [ + {"$ref": "#/definitions/richText"}, + {"description": "Main body text of snippet. HTML subset allowed: i, b, u, strong, em, br"} + ] + }, + "scene1_section_title_icon": { + "type": "string", + "description": "Section title icon for scene 1. 16x16px. SVG or PNG preferred. scene1_section_title_text must also be specified to display." + }, + "scene1_section_title_icon_dark_theme": { + "type": "string", + "description": "Section title icon for scene 1, dark theme variant. 16x16px. SVG or PNG preferred. scene1_section_title_text must also be specified to display." + }, + "scene1_section_title_text": { + "type": "string", + "description": "Section title text for scene 1. scene1_section_title_icon must also be specified to display." + }, + "scene1_section_title_url": { + "allOf": [ + {"$ref": "#/definitions/link_url"}, + {"description": "A url, scene1_section_title_text links to this"} + ] + }, + "scene2_title": { + "allOf": [ + {"$ref": "#/definitions/plainText"}, + {"description": "Title displayed before text in scene 2. Should be plain text."} + ] + }, + "scene2_text": { + "allOf": [ + {"$ref": "#/definitions/richText"}, + {"description": "Main body text of snippet. HTML subset allowed: i, b, u, strong, em, br"} + ] + }, + "scene1_icon": { + "type": "string", + "description": "Snippet icon. 64x64px. SVG or PNG preferred." + }, + "scene1_icon_dark_theme": { + "type": "string", + "description": "Snippet icon. Dark theme variant. 64x64px. SVG or PNG preferred." + }, + "scene1_title_icon": { + "type": "string", + "description": "Small icon that shows up before the title / text. 16x16px. SVG or PNG preferred. Grayscale." + }, + "scene1_title_icon_dark_theme": { + "type": "string", + "description": "Small icon that shows up before the title / text. Dark theme variant. 16x16px. SVG or PNG preferred. Grayscale." + }, + "scene2_email_placeholder_text": { + "type": "string", + "description": "Value to show while input is empty.", + "default": "Your email here" + }, + "scene2_button_label": { + "type": "string", + "description": "Label for form submit button", + "default": "Sign me up" + }, + "scene2_privacy_html": { + "type": "string", + "description": "(send to device) Html for disclaimer and link underneath input box." + }, + "scene2_dismiss_button_text": { + "type": "string", + "description": "Label for the dismiss button when the sign-up form is expanded.", + "default": "Dismiss" + }, + "hidden_inputs": { + "type": "object", + "description": "Each entry represents a hidden input, key is used as value for the name property.", + "properties": { + "fmt": { + "type": "string", + "description": "", + "default": "H" + } + } + }, + "scene1_button_label": { + "allOf": [ + {"$ref": "#/definitions/plainText"}, + {"description": "Text for a button next to main snippet text that links to button_url. Requires button_url."} + ], + "default": "Learn more" + }, + "scene1_button_color": { + "type": "string", + "description": "The text color of the button. Valid CSS color." + }, + "scene1_button_background_color": { + "type": "string", + "description": "The background color of the button. Valid CSS color." + }, + "retry_button_label": { + "allOf": [ + {"$ref": "#/definitions/plainText"}, + {"description": "Text for the button in the event of a submission error/failure."} + ], + "default": "Try again" + }, + "do_not_autoblock": { + "type": "boolean", + "description": "Used to prevent blocking the snippet after the CTA (link or button) has been clicked", + "default": false + }, + "success_text": { + "type": "string", + "description": "Message shown on successful registration." + }, + "error_text": { + "type": "string", + "description": "Message shown if registration failed." + }, + "scene2_newsletter": { + "type": "string", + "description": "Newsletter/basket id user is subscribing to.", + "default": "mozilla-foundation" + }, + "links": { + "additionalProperties": { + "url": { + "allOf": [ + {"$ref": "#/definitions/link_url"}, + {"description": "The url where the link points to."} + ] + }, + "metric": { + "type": "string", + "description": "Custom event name sent with telemetry event." + } + } + } + }, + "additionalProperties": false, + "required": ["scene1_text", "scene2_text", "scene1_button_label"], + "dependencies": { + "scene1_button_color": ["scene1_button_label"], + "scene1_button_background_color": ["scene1_button_label"] + } +} + diff --git a/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/OnboardingMessage.jsx b/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/OnboardingMessage.jsx new file mode 100644 index 0000000000..ce1a840247 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/OnboardingMessage.jsx @@ -0,0 +1,52 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React from "react"; +import { Localized } from "../../../aboutwelcome/components/MSLocalized"; + +export class OnboardingCard extends React.PureComponent { + constructor(props) { + super(props); + this.onClick = this.onClick.bind(this); + } + + onClick() { + const { props } = this; + const ping = { + event: "CLICK_BUTTON", + message_id: props.id, + id: props.UISurface, + }; + props.sendUserActionTelemetry(ping); + props.onAction(props.content.primary_button.action, props.message); + } + + render() { + const { content } = this.props; + const className = this.props.className || "onboardingMessage"; + return ( +
+
+
+ + +

+ + +

+ + + + +

+
+ ); + } +} diff --git a/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/OnboardingMessage.schema.json b/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/OnboardingMessage.schema.json new file mode 100644 index 0000000000..f355d89da7 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/OnboardingMessage.schema.json @@ -0,0 +1,142 @@ +{ + "title": "OnboardingMessage", + "description": "A template with a title, icon, button and description. No markup allowed.", + "version": "1.0.0", + "type": "object", + "properties": { + "title": { + "oneOf": [ + { + "type": "string", + "description": "The message displayed in the title of the onboarding card" + }, + { + "type": "object", + "properties": { + "string_id": { + "type": "string" + } + }, + "required": ["string_id"], + "description": "Id of localized string for onboarding card title" + } + ], + "description": "Id of localized string or message override." + }, + "text": { + "oneOf": [ + { + "type": "string", + "description": "The message displayed in the description of the onboarding card" + }, + { + "type": "object", + "properties": { + "string_id": { + "type": "string" + }, + "args": { + "type": "object", + "description": "An optional argument to pass to the localization module" + } + }, + "required": ["string_id"], + "description": "Id of localized string for onboarding card description" + } + ], + "description": "Id of localized string or message override." + }, + "icon": { + "allOf": [ + { + "type": "string", + "description": "Image associated with the onboarding card" + } + ] + }, + "primary_button": { + "type": "object", + "properties": { + "label": { + "oneOf": [ + { + "type": "string", + "description": "The label of the onboarding messages' action button" + }, + { + "type": "object", + "properties": { + "string_id": { + "type": "string" + } + }, + "required": ["string_id"], + "description": "Id of localized string for onboarding messages' button" + } + ], + "description": "Id of localized string or message override." + }, + "action": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Action dispatched by the button." + }, + "data": { + "properties": { + "args": { + "type": "string", + "description": "Additional parameters for button action, for example which link the button should open." + } + } + } + } + } + } + }, + "secondary_buttons": { + "type": "object", + "properties": { + "label": { + "oneOf": [ + { + "type": "string", + "description": "The label of the onboarding messages' (optional) secondary action button" + }, + { + "type": "object", + "properties": { + "string_id": { + "type": "string" + } + }, + "required": ["string_id"], + "description": "Id of localized string for onboarding messages' button" + } + ], + "description": "Id of localized string or message override." + }, + "action": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Action dispatched by the button." + }, + "data": { + "properties": { + "args": { + "type": "string", + "description": "Additional parameters for button action, for example which link the button should open." + } + } + } + } + } + } + } + }, + "additionalProperties": true, + "required": ["title", "text", "icon", "primary_button"] +} diff --git a/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/ToolbarBadgeMessage.schema.json b/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/ToolbarBadgeMessage.schema.json new file mode 100644 index 0000000000..b873e62b83 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/ToolbarBadgeMessage.schema.json @@ -0,0 +1,39 @@ +{ + "title": "ToolbarBadgeMessage", + "description": "A template that specifies to which element in the browser toolbar to add a notification.", + "version": "1.1.0", + "type": "object", + "properties": { + "target": { + "type": "string" + }, + "action": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "additionalProperties": false, + "required": ["id"], + "description": "Optional action to take in addition to showing the notification" + }, + "delay": { + "type": "number", + "description": "Optional delay in ms after which to show the notification" + }, + "badgeDescription": { + "type": "object", + "description": "This is used in combination with the badged button to offer a text based alternative to the visual badging. Example 'New Feature: What's New'", + "properties": { + "string_id": { + "type": "string", + "description": "Fluent string id" + } + }, + "required": ["string_id"] + } + }, + "additionalProperties": false, + "required": ["target"] +} diff --git a/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/UpdateAction.schema.json b/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/UpdateAction.schema.json new file mode 100644 index 0000000000..7624c67d4c --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/UpdateAction.schema.json @@ -0,0 +1,36 @@ +{ + "title": "UpdateActionMessage", + "description": "A template for messages that execute predetermined actions.", + "version": "1.0.0", + "type": "object", + "properties": { + "action": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "URL data to be used as argument to the action" + }, + "expireDelta": { + "type": "number", + "description": "Expiration timestamp to be used as argument to the action" + } + } + }, + "description": "Additional data provided as argument when executing the action" + }, + "additionalProperties": false, + "description": "Optional action to take in addition to showing the notification" + }, + "additionalProperties": false, + "required": ["id", "action"] + }, + "additionalProperties": false, + "required": ["action"] +} diff --git a/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/WhatsNewMessage.schema.json b/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/WhatsNewMessage.schema.json new file mode 100644 index 0000000000..998f2cfc8d --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/WhatsNewMessage.schema.json @@ -0,0 +1,97 @@ +{ + "title": "WhatsNewMessage", + "description": "A template for the messages that appear in the What's New panel.", + "version": "1.2.0", + "type": "object", + "definitions": { + "localizableText": { + "oneOf": [ + { + "type": "string", + "description": "The string to be rendered." + }, + { + "type": "object", + "properties": { + "string_id": { + "type": "string" + } + }, + "required": ["string_id"], + "description": "Id of localized string to be rendered." + } + ] + } + }, + "properties": { + "layout": { + "description": "Different message layouts", + "enum": ["tracking-protections"] + }, + "layout_title_content_variable": { + "description": "Select what profile specific value to show for the current layout.", + "type": "string" + }, + "bucket_id": { + "type": "string", + "description": "A bucket identifier for the addon. This is used in order to anonymize telemetry for history-sensitive targeting." + }, + "published_date": { + "type": "integer", + "description": "The date/time (number of milliseconds elapsed since January 1, 1970 00:00:00 UTC) the message was published." + }, + "title": { + "allOf": [ + {"$ref": "#/definitions/localizableText"}, + {"description": "Id of localized string or message override of What's New message title"} + ] + }, + "subtitle": { + "allOf": [ + {"$ref": "#/definitions/localizableText"}, + {"description": "Id of localized string or message override of What's New message subtitle"} + ] + }, + "body": { + "allOf": [ + {"$ref": "#/definitions/localizableText"}, + {"description": "Id of localized string or message override of What's New message body"} + ] + }, + "link_text": { + "allOf": [ + {"$ref": "#/definitions/localizableText"}, + {"description": "(optional) Id of localized string or message override of What's New message link text"} + ] + }, + "cta_url": { + "description": "Target URL for the What's New message.", + "type": "string", + "format": "uri" + }, + "cta_type": { + "description": "Type of url open action", + "enum": ["OPEN_URL", "OPEN_ABOUT_PAGE", "OPEN_PROTECTION_REPORT"] + }, + "cta_where": { + "description": "How to open the cta: new window, tab, focused, unfocused.", + "enum": ["current", "tabshifted", "tab", "save", "window"] + }, + "icon_url": { + "description": "(optional) URL for the What's New message icon.", + "type": "string", + "format": "uri" + }, + "icon_alt": { + "allOf": [ + {"$ref": "#/definitions/localizableText"}, + {"description": "Alt text for image."} + ] + } + }, + "additionalProperties": false, + "required": ["published_date", "title", "body", "cta_url", "bucket_id"], + "dependencies": { + "layout": ["layout_title_content_variable"] + } +} diff --git a/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/_OnboardingMessage.scss b/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/_OnboardingMessage.scss new file mode 100644 index 0000000000..d4109317a1 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/_OnboardingMessage.scss @@ -0,0 +1,131 @@ +@import '../../../styles/OnboardingImages'; + +.onboardingMessage { + height: 340px; + text-align: center; + padding: 13px; + font-weight: 200; + + // at 850px, img floats left, content floats right next to it + @media(max-width: 850px) { + height: 170px; + text-align: left; + padding: 10px; + border-bottom: 1px solid $grey-30; + display: flex; + margin-bottom: 11px; + + &:last-child { + border: 0; + } + + .onboardingContent { + padding-left: 10px; + height: 100%; + + > span > h3 { + margin-top: 0; + margin-bottom: 4px; + font-weight: 400; + } + + > span > p { + margin-top: 0; + line-height: 22px; + font-size: 15px; + } + } + } + + @media(max-width: 650px) { + height: 250px; + } + + .onboardingContent { + height: 175px; + + > span > h3 { + color: $grey-90; + margin-bottom: 8px; + font-weight: 400; + } + + > span > p { + color: $grey-60; + margin-top: 0; + height: 180px; + margin-bottom: 12px; + font-size: 15px; + line-height: 22px; + + @media(max-width: 650px) { + margin-bottom: 0; + height: 160px; + } + } + } + + .onboardingButton { + background-color: $grey-90-10; + border: 0; + width: 150px; + height: 30px; + margin-bottom: 23px; + padding: 4px 0 6px; + font-size: 15px; + + // at 850px, the button shimmies down and to the right + @media(max-width: 850px) { + float: right; + margin-top: -105px; + margin-inline-end: -10px; + } + + @media(max-width: 650px) { + float: none; + } + + &:focus, + &.active, + &:hover { + box-shadow: 0 0 0 5px $grey-30; + transition: box-shadow 150ms; + } + } + + + &::before { + content: ''; + height: 230px; + width: 1px; + position: absolute; + background-color: $grey-30; + margin-top: 40px; + margin-inline-start: 215px; + + // at 850px, the line goes from vertical to horizontal + @media(max-width: 850px) { + content: none; + } + } + + &:last-child::before { + content: none; + } +} + +.onboardingMessageImage { + height: 112px; + width: 180px; + background-size: auto 140px; + background-position: center center; + background-repeat: no-repeat; + display: inline-block; + + // Cards will wrap into the next line after this breakpoint + @media(max-width: 865px) { + height: 75px; + min-width: 80px; + background-size: 140px; + } +} diff --git a/browser/components/newtab/content-src/asrouter/templates/SendToDeviceSnippet/SendToDeviceSnippet.jsx b/browser/components/newtab/content-src/asrouter/templates/SendToDeviceSnippet/SendToDeviceSnippet.jsx new file mode 100644 index 0000000000..0929b8f711 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/SendToDeviceSnippet/SendToDeviceSnippet.jsx @@ -0,0 +1,76 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { isEmailOrPhoneNumber } from "./isEmailOrPhoneNumber"; +import React from "react"; +import { SubmitFormSnippet } from "../SubmitFormSnippet/SubmitFormSnippet.jsx"; + +function validateInput(value, content) { + const type = isEmailOrPhoneNumber(value, content); + return type ? "" : "Must be an email or a phone number."; +} + +function processFormData(input, message) { + const { content } = message; + const type = content.include_sms + ? isEmailOrPhoneNumber(input.value, content) + : "email"; + const formData = new FormData(); + let url; + if (type === "phone") { + url = "https://basket.mozilla.org/news/subscribe_sms/"; + formData.append("mobile_number", input.value); + formData.append("msg_name", content.message_id_sms); + formData.append("country", content.country); + } else if (type === "email") { + url = "https://basket.mozilla.org/news/subscribe/"; + formData.append("email", input.value); + formData.append("newsletters", content.message_id_email); + formData.append( + "source_url", + encodeURIComponent(`https://snippets.mozilla.com/show/${message.id}`) + ); + } + formData.append("lang", content.locale); + return { formData, url }; +} + +function addDefaultValues(props) { + return { + ...props, + content: { + scene1_button_label: "Learn more", + retry_button_label: "Try again", + scene2_dismiss_button_text: "Dismiss", + scene2_button_label: "Send", + scene2_input_placeholder: "Your email here", + locale: "en-US", + country: "us", + message_id_email: "", + include_sms: false, + ...props.content, + }, + }; +} + +export const SendToDeviceSnippet = props => { + const propsWithDefaults = addDefaultValues(props); + + return ( + + ); +}; + +export const SendToDeviceScene2Snippet = props => { + return ; +}; diff --git a/browser/components/newtab/content-src/asrouter/templates/SendToDeviceSnippet/SendToDeviceSnippet.schema.json b/browser/components/newtab/content-src/asrouter/templates/SendToDeviceSnippet/SendToDeviceSnippet.schema.json new file mode 100644 index 0000000000..238840234a --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/SendToDeviceSnippet/SendToDeviceSnippet.schema.json @@ -0,0 +1,234 @@ +{ + "title": "SendToDeviceSnippet", + "description": "A snippet template for send to device mobile download", + "version": "1.2.0", + "type": "object", + "definitions": { + "plainText": { + "description": "Plain text (no HTML allowed)", + "type": "string" + }, + "richText": { + "description": "Text with HTML subset allowed: i, b, u, strong, em, br", + "type": "string" + }, + "link_url": { + "description": "Target for links or buttons", + "type": "string", + "format": "uri" + } + }, + "properties": { + "locale": { + "type": "string", + "description": "Two to five character string for the locale code", + "default": "en-US" + }, + "country": { + "type": "string", + "description": "Two character string for the country code (used for SMS)", + "default": "us" + }, + "scene1_title": { + "allof": [ + {"$ref": "#/definitions/plainText"}, + {"description": "snippet title displayed before snippet text"} + ] + }, + "scene1_text": { + "allOf": [ + {"$ref": "#/definitions/richText"}, + {"description": "Main body text of snippet. HTML subset allowed: i, b, u, strong, em, br"} + ] + }, + "scene1_section_title_icon": { + "type": "string", + "description": "Section title icon for scene 1. 16x16px. SVG or PNG preferred. scene1_section_title_text must also be specified to display." + }, + "scene1_section_title_icon_dark_theme": { + "type": "string", + "description": "Section title icon for scene 1, dark theme variant. 16x16px. SVG or PNG preferred. scene1_section_title_text must also be specified to display." + }, + "scene1_section_title_text": { + "type": "string", + "description": "Section title text for scene 1. scene1_section_title_icon must also be specified to display." + }, + "scene1_section_title_url": { + "allOf": [ + {"$ref": "#/definitions/link_url"}, + {"description": "A url, scene1_section_title_text links to this"} + ] + }, + "scene2_title": { + "allOf": [ + {"$ref": "#/definitions/plainText"}, + {"description": "Title displayed before text in scene 2. Should be plain text."} + ] + }, + "scene2_text": { + "allOf": [ + {"$ref": "#/definitions/richText"}, + {"description": "Main body text of snippet. HTML subset allowed: i, b, u, strong, em, br"} + ] + }, + "scene1_icon": { + "type": "string", + "description": "Snippet icon. 64x64px. SVG or PNG preferred." + }, + "scene1_icon_dark_theme": { + "type": "string", + "description": "Snippet icon. Dark theme variant. 64x64px. SVG or PNG preferred." + }, + "scene2_icon": { + "type": "string", + "description": "(send to device) Image to display above the form. Dark theme variant. 98x98px. SVG or PNG preferred." + }, + "scene2_icon_dark_theme": { + "type": "string", + "description": "(send to device) Image to display above the form. 98x98px. SVG or PNG preferred." + }, + "scene1_title_icon": { + "type": "string", + "description": "Small icon that shows up before the title / text. 16x16px. SVG or PNG preferred. Grayscale." + }, + "scene1_title_icon_dark_theme": { + "type": "string", + "description": "Small icon that shows up before the title / text. Dark theme variant. 16x16px. SVG or PNG preferred. Grayscale." + }, + "scene2_button_label": { + "type": "string", + "description": "Label for form submit button", + "default": "Send" + }, + "scene2_input_placeholder": { + "type": "string", + "description": "(send to device) Value to show while input is empty.", + "default": "Your email here" + }, + "scene2_disclaimer_html": { + "type": "string", + "description": "(send to device) Html for disclaimer and link underneath input box." + }, + "scene2_dismiss_button_text": { + "type": "string", + "description": "Label for the dismiss button when the sign-up form is expanded.", + "default": "Dismiss" + }, + "hidden_inputs": { + "type": "object", + "description": "Each entry represents a hidden input, key is used as value for the name property.", + "properties": { + "action": { + "type": "string", + "enum": ["email"] + }, + "context": { + "type": "string", + "enum": ["fx_desktop_v3"] + }, + "entrypoint": { + "type": "string", + "enum": ["snippets"] + }, + "utm_content": { + "type": "string", + "description": "Firefox version number" + }, + "utm_source": { + "type": "string", + "enum": ["snippet"] + }, + "utm_campaign": { + "type": "string", + "description": "(fxa) Value to pass through to GA as utm_campaign." + }, + "utm_term": { + "type": "string", + "description": "(fxa) Value to pass through to GA as utm_term." + }, + "additionalProperties": false + } + }, + "scene1_button_label": { + "allOf": [ + {"$ref": "#/definitions/plainText"}, + {"description": "Text for a button next to main snippet text that links to button_url. Requires button_url."} + ], + "default": "Learn more" + }, + "scene1_button_color": { + "type": "string", + "description": "The text color of the button. Valid CSS color." + }, + "scene1_button_background_color": { + "type": "string", + "description": "The background color of the button. Valid CSS color." + }, + "retry_button_label": { + "allOf": [ + {"$ref": "#/definitions/plainText"}, + {"description": "Text for the button in the event of a submission error/failure."} + ], + "default": "Try again" + }, + "do_not_autoblock": { + "type": "boolean", + "description": "Used to prevent blocking the snippet after the CTA (link or button) has been clicked", + "default": false + }, + "success_title": { + "type": "string", + "description": "(send to device) Title shown before text on successful registration." + }, + "success_text": { + "type": "string", + "description": "Message shown on successful registration." + }, + "error_text": { + "type": "string", + "description": "Message shown if registration failed." + }, + "include_sms": { + "type": "boolean", + "description": "(send to device) Allow users to send an SMS message with the form?", + "default": false + }, + "message_id_sms": { + "type": "string", + "description": "(send to device) Newsletter/basket id representing the SMS message to be sent." + }, + "message_id_email": { + "type": "string", + "description": "(send to device) Newsletter/basket id representing the email message to be sent. Must be a value from the 'Slug' column here: https://basket.mozilla.org/news/." + }, + "utm_campaign": { + "type": "string", + "description": "(fxa) Value to pass through to GA as utm_campaign." + }, + "utm_term": { + "type": "string", + "description": "(fxa) Value to pass through to GA as utm_term." + }, + "links": { + "additionalProperties": { + "url": { + "allOf": [ + {"$ref": "#/definitions/link_url"}, + {"description": "The url where the link points to."} + ] + }, + "metric": { + "type": "string", + "description": "Custom event name sent with telemetry event." + } + } + } + }, + "additionalProperties": false, + "required": ["scene1_text", "scene2_text", "scene1_button_label"], + "dependencies": { + "scene1_button_color": ["scene1_button_label"], + "scene1_button_background_color": ["scene1_button_label"] + } +} + diff --git a/browser/components/newtab/content-src/asrouter/templates/SendToDeviceSnippet/isEmailOrPhoneNumber.js b/browser/components/newtab/content-src/asrouter/templates/SendToDeviceSnippet/isEmailOrPhoneNumber.js new file mode 100644 index 0000000000..29addb688d --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/SendToDeviceSnippet/isEmailOrPhoneNumber.js @@ -0,0 +1,38 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Checks if a given string is an email or phone number or neither + * @param {string} val The user input + * @param {ASRMessageContent} content .content property on ASR message + * @returns {"email"|"phone"|""} The type of the input + */ +export function isEmailOrPhoneNumber(val, content) { + const { locale } = content; + // http://emailregex.com/ + const email_re = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; + const check_email = email_re.test(val); + let check_phone; // depends on locale + switch (locale) { + case "en-US": + case "en-CA": + // allow 10-11 digits in case user wants to enter country code + check_phone = val.length >= 10 && val.length <= 11 && !isNaN(val); + break; + case "de": + // allow between 2 and 12 digits for german phone numbers + check_phone = val.length >= 2 && val.length <= 12 && !isNaN(val); + break; + // this case should never be hit, but good to have a fallback just in case + default: + check_phone = !isNaN(val); + break; + } + if (check_email) { + return "email"; + } else if (check_phone) { + return "phone"; + } + return ""; +} diff --git a/browser/components/newtab/content-src/asrouter/templates/SimpleBelowSearchSnippet/SimpleBelowSearchSnippet.jsx b/browser/components/newtab/content-src/asrouter/templates/SimpleBelowSearchSnippet/SimpleBelowSearchSnippet.jsx new file mode 100644 index 0000000000..2641d51e86 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/SimpleBelowSearchSnippet/SimpleBelowSearchSnippet.jsx @@ -0,0 +1,133 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React from "react"; +import { Button } from "../../components/Button/Button"; +import { RichText } from "../../components/RichText/RichText"; +import { safeURI } from "../../template-utils"; +import { SnippetBase } from "../../components/SnippetBase/SnippetBase"; + +const DEFAULT_ICON_PATH = "chrome://branding/content/icon64.png"; +// Alt text placeholder in case the prop from the server isn't available +const ICON_ALT_TEXT = ""; + +export class SimpleBelowSearchSnippet extends React.PureComponent { + constructor(props) { + super(props); + this.onButtonClick = this.onButtonClick.bind(this); + } + + renderText() { + const { props } = this; + return props.content.text ? ( + + ) : null; + } + + renderTitle() { + const { title } = this.props.content; + return title ? ( +

+ {title} +
+

+ ) : null; + } + + async onButtonClick() { + if (this.props.provider !== "preview") { + this.props.sendUserActionTelemetry({ + event: "CLICK_BUTTON", + id: this.props.UISurface, + }); + } + const { button_url } = this.props.content; + // If button_url is defined handle it as OPEN_URL action + const type = this.props.content.button_action || (button_url && "OPEN_URL"); + await this.props.onAction({ + type, + data: { args: this.props.content.button_action_args || button_url }, + }); + if (!this.props.content.do_not_autoblock) { + this.props.onBlock(); + } + } + + _shouldRenderButton() { + return ( + this.props.content.button_action || + this.props.onButtonClick || + this.props.content.button_url + ); + } + + renderButton() { + const { props } = this; + if (!this._shouldRenderButton()) { + return null; + } + + return ( + + ); + } + + render() { + const { props } = this; + let className = "SimpleBelowSearchSnippet"; + let containerName = "below-search-snippet"; + + if (props.className) { + className += ` ${props.className}`; + } + if (this._shouldRenderButton()) { + className += " withButton"; + containerName += " withButton"; + } + + return ( +
+
+ + {props.content.icon_alt_text + {props.content.icon_alt_text +
+ {this.renderTitle()} +

{this.renderText()}

+ {this.props.extraContent} +
+ {
{this.renderButton()}
} +
+
+
+ ); + } +} diff --git a/browser/components/newtab/content-src/asrouter/templates/SimpleBelowSearchSnippet/SimpleBelowSearchSnippet.schema.json b/browser/components/newtab/content-src/asrouter/templates/SimpleBelowSearchSnippet/SimpleBelowSearchSnippet.schema.json new file mode 100644 index 0000000000..049f66ef6b --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/SimpleBelowSearchSnippet/SimpleBelowSearchSnippet.schema.json @@ -0,0 +1,110 @@ +{ + "title": "SimpleBelowSearchSnippet", + "description": "A simple template with an icon, rich text and an optional button. It gets inserted below the Activity Stream search box.", + "version": "1.2.0", + "type": "object", + "definitions": { + "plainText": { + "description": "Plain text (no HTML allowed)", + "type": "string" + }, + "richText": { + "description": "Text with HTML subset allowed: i, b, u, strong, em, br", + "type": "string" + }, + "link_url": { + "description": "Target for links or buttons", + "type": "string", + "format": "uri" + } + }, + "properties": { + "title": { + "allOf": [ + {"$ref": "#/definitions/plainText"}, + {"description": "Snippet title displayed before snippet text"} + ] + }, + "text": { + "allOf": [ + {"$ref": "#/definitions/richText"}, + {"description": "Main body text of snippet. HTML subset allowed: i, b, u, strong, em, br"} + ] + }, + "icon": { + "type": "string", + "description": "Snippet icon. 64x64px. SVG or PNG preferred." + }, + "icon_dark_theme": { + "type": "string", + "description": "Snippet icon. Dark theme variant. 64x64px. SVG or PNG preferred." + }, + "icon_alt_text": { + "type": "string", + "description": "Alt text describing icon for screen readers", + "default": "" + }, + "block_button_text": { + "type": "string", + "description": "Tooltip text used for dismiss button.", + "default": "Remove this" + }, + "button_action": { + "type": "string", + "description": "The type of action the button should trigger." + }, + "button_url": { + "allOf": [ + {"$ref": "#/definitions/link_url"}, + {"description": "A url, button_label links to this"} + ] + }, + "button_action_args": { + "description": "Additional parameters for button action, example which specific menu the button should open" + }, + "button_label": { + "allOf": [ + {"$ref": "#/definitions/plainText"}, + {"description": "Text for a button next to main snippet text that links to button_url. Requires button_url."} + ] + }, + "button_color": { + "type": "string", + "description": "The text color of the button. Valid CSS color." + }, + "button_background_color": { + "type": "string", + "description": "The background color of the button. Valid CSS color." + }, + "do_not_autoblock": { + "type": "boolean", + "description": "Used to prevent blocking the snippet after the CTA link has been clicked" + }, + "links": { + "additionalProperties": { + "url": { + "allOf": [ + {"$ref": "#/definitions/link_url"}, + {"description": "The url where the link points to."} + ] + }, + "metric": { + "type": "string", + "description": "Custom event name sent with telemetry event." + }, + "args": { + "type": "string", + "description": "Additional parameters for link action, example which specific menu the button should open" + } + } + } + }, + "additionalProperties": false, + "required": ["text"], + "dependencies": { + "button_action": ["button_label"], + "button_url": ["button_label"], + "button_color": ["button_label"], + "button_background_color": ["button_label"] + } +} diff --git a/browser/components/newtab/content-src/asrouter/templates/SimpleBelowSearchSnippet/_SimpleBelowSearchSnippet.scss b/browser/components/newtab/content-src/asrouter/templates/SimpleBelowSearchSnippet/_SimpleBelowSearchSnippet.scss new file mode 100644 index 0000000000..dd9e637529 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/SimpleBelowSearchSnippet/_SimpleBelowSearchSnippet.scss @@ -0,0 +1,198 @@ + +.below-search-snippet { + margin: 0 auto 16px; + + &.withButton { + margin: auto; + min-height: 60px; + background-color: transparent; + + .snippet-hover-wrapper { + min-height: 60px; + border-radius: 4px; + + &:hover { + background-color: var(--newtab-element-hover-color); + + .blockButton { + display: block; + opacity: 1; + } + } + } + } +} + +.SimpleBelowSearchSnippet { + background-color: transparent; + border: 0; + box-shadow: none; + position: relative; + margin: auto; + z-index: auto; + + @media (min-width: $break-point-large) { + width: 736px; + } + + &.active { + background-color: var(--newtab-element-hover-color); + border-radius: 4px; + } + + .innerWrapper { + align-items: center; + background-color: transparent; + border-radius: 4px; + box-shadow: var(--newtab-card-shadow); + flex-direction: column; + padding: 16px; + text-align: center; + width: 100%; + + @mixin full-width-styles { + align-items: flex-start; + background-color: transparent; + border-radius: 4px; + box-shadow: none; + flex-direction: row; + padding: 0; + text-align: inherit; + width: 696px; + } + + @media (min-width: $break-point-medium) { + @include full-width-styles; + } + + @media (max-width: 865px) { + margin-inline-start: 0; + } + + // There is an off-by-one gap between breakpoints; this is to prevent weirdness at exactly 610px. + @media (max-width: $break-point-medium - 1px) { + margin: auto; + } + } + + .blockButton { + display: block; + inset-inline-end: 10px; + opacity: 1; + top: 50%; + + &:focus { + box-shadow: 0 0 0 1px $blue-50 inset, 0 0 0 1px $blue-50, 0 0 0 4px $blue-50-30; + border-radius: 2px; + } + } + + .title { + font-size: inherit; + margin: 0; + } + + .title-inline { + display: inline; + } + + .textContainer { + margin: 10px; + margin-inline-start: 0; + padding-inline-end: 20px; + } + + .icon { + margin-top: 8px; + margin-inline-start: 12px; + height: 32px; + width: 32px; + + @mixin full-width-styles { + height: 24px; + width: 24px; + } + + @media (min-width: $break-point-medium) { + @include full-width-styles; + } + + @media (max-width: $break-point-medium) { + margin: auto; + } + } + + &.withButton { + line-height: 20px; + margin-bottom: 10px; + min-height: 60px; + background-color: transparent; + + .innerWrapper { + // There is an off-by-one gap between breakpoints; this is to prevent weirdness at exactly 1121px. + @media (max-width: $break-point-widest + 1px) { + margin: 0 40px; + } + } + + .blockButton { + display: block; + inset-inline-end: -10%; + opacity: 0; + margin: auto; + top: unset; + + &:focus { + opacity: 1; + box-shadow: none; + } + + // There is an off-by-one gap between breakpoints; this is to prevent weirdness at exactly 1121px. + @media (max-width: $break-point-widest + 1px) { + inset-inline-end: 2%; + } + } + + .icon { + width: 42px; + height: 42px; + flex-shrink: 0; + margin: auto 0; + margin-inline-end: 10px; + + @media (max-width: $break-point-medium) { + margin: auto; + } + } + + .buttonContainer { + margin: auto; + margin-inline-end: 0; + + @media (max-width: $break-point-medium) { + margin: auto; + } + } + } + + button { + @media (max-width: $break-point-medium) { + margin: auto; + } + } + + .body { + display: inline; + position: sticky; + transform: translateY(-50%); + margin: 8px 0 0; + + @media (min-width: $break-point-medium) { + margin: 12px 0; + } + + a { + font-weight: 600; + } + } +} diff --git a/browser/components/newtab/content-src/asrouter/templates/SimpleSnippet/SimpleSnippet.jsx b/browser/components/newtab/content-src/asrouter/templates/SimpleSnippet/SimpleSnippet.jsx new file mode 100644 index 0000000000..8d7b8c1f7b --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/SimpleSnippet/SimpleSnippet.jsx @@ -0,0 +1,225 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { Button } from "../../components/Button/Button"; +import ConditionalWrapper from "../../components/ConditionalWrapper/ConditionalWrapper"; +import React from "react"; +import { RichText } from "../../components/RichText/RichText"; +import { safeURI } from "../../template-utils"; +import { SnippetBase } from "../../components/SnippetBase/SnippetBase"; + +const DEFAULT_ICON_PATH = "chrome://branding/content/icon64.png"; +// Alt text placeholder in case the prop from the server isn't available +const ICON_ALT_TEXT = ""; + +export class SimpleSnippet extends React.PureComponent { + constructor(props) { + super(props); + this.onButtonClick = this.onButtonClick.bind(this); + } + + onButtonClick() { + if (this.props.provider !== "preview") { + this.props.sendUserActionTelemetry({ + event: "CLICK_BUTTON", + id: this.props.UISurface, + }); + } + const { + button_url, + button_entrypoint_value, + button_entrypoint_name, + } = this.props.content; + // If button_url is defined handle it as OPEN_URL action + const type = this.props.content.button_action || (button_url && "OPEN_URL"); + // Assign the snippet referral for the action + const entrypoint = button_entrypoint_name + ? new URLSearchParams([ + [button_entrypoint_name, button_entrypoint_value], + ]).toString() + : button_entrypoint_value; + this.props.onAction({ + type, + data: { + args: this.props.content.button_action_args || button_url, + ...(entrypoint && { entrypoint }), + }, + }); + if (!this.props.content.do_not_autoblock) { + this.props.onBlock(); + } + } + + _shouldRenderButton() { + return ( + this.props.content.button_action || + this.props.onButtonClick || + this.props.content.button_url + ); + } + + renderTitle() { + const { title } = this.props.content; + return title ? ( +

+ {this.renderTitleIcon()} {title} +

+ ) : null; + } + + renderTitleIcon() { + const titleIconLight = safeURI(this.props.content.title_icon); + const titleIconDark = safeURI( + this.props.content.title_icon_dark_theme || this.props.content.title_icon + ); + if (!titleIconLight) { + return null; + } + + return ( + + + + + ); + } + + renderButton() { + const { props } = this; + if (!this._shouldRenderButton()) { + return null; + } + + return ( + + ); + } + + renderText() { + const { props } = this; + return ( + + ); + } + + wrapSectionHeader(url) { + return function(children) { + return
{children}; + }; + } + + wrapSnippetContent(children) { + return
{children}
; + } + + renderSectionHeader() { + const { props } = this; + + // an icon and text must be specified to render the section header + if (props.content.section_title_icon && props.content.section_title_text) { + const sectionTitleIconLight = safeURI(props.content.section_title_icon); + const sectionTitleIconDark = safeURI( + props.content.section_title_icon_dark_theme || + props.content.section_title_icon + ); + const sectionTitleURL = props.content.section_title_url; + + return ( +
+

+ + + + + {props.content.section_title_text} + + +

+
+ ); + } + + return null; + } + + render() { + const { props } = this; + const sectionHeader = this.renderSectionHeader(); + let className = "SimpleSnippet"; + + if (props.className) { + className += ` ${props.className}`; + } + if (props.content.tall) { + className += " tall"; + } + if (sectionHeader) { + className += " has-section-header"; + } + + return ( +
+ + {sectionHeader} + + {props.content.icon_alt_text + {props.content.icon_alt_text +
+ {this.renderTitle()}

{this.renderText()}

+ {this.props.extraContent} +
+ {
{this.renderButton()}
} +
+
+
+ ); + } +} diff --git a/browser/components/newtab/content-src/asrouter/templates/SimpleSnippet/SimpleSnippet.schema.json b/browser/components/newtab/content-src/asrouter/templates/SimpleSnippet/SimpleSnippet.schema.json new file mode 100644 index 0000000000..1229700d67 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/SimpleSnippet/SimpleSnippet.schema.json @@ -0,0 +1,155 @@ +{ + "title": "SimpleSnippet", + "description": "A simple template with an icon, text, and optional button.", + "version": "1.1.2", + "type": "object", + "definitions": { + "plainText": { + "description": "Plain text (no HTML allowed)", + "type": "string" + }, + "richText": { + "description": "Text with HTML subset allowed: i, b, u, strong, em, br", + "type": "string" + }, + "link_url": { + "description": "Target for links or buttons", + "type": "string", + "format": "uri" + } + }, + "properties": { + "title": { + "allOf": [ + {"$ref": "#/definitions/plainText"}, + {"description": "Snippet title displayed before snippet text"} + ] + }, + "text": { + "allOf": [ + {"$ref": "#/definitions/richText"}, + {"description": "Main body text of snippet. HTML subset allowed: i, b, u, strong, em, br"} + ] + }, + "icon": { + "type": "string", + "description": "Snippet icon. 64x64px. SVG or PNG preferred." + }, + "icon_dark_theme": { + "type": "string", + "description": "Snippet icon, dark theme variant. 64x64px. SVG or PNG preferred." + }, + "icon_alt_text": { + "type": "string", + "description": "Alt text describing icon for screen readers", + "default": "" + }, + "title_icon": { + "type": "string", + "description": "Small icon that shows up before the title / text. 16x16px. SVG or PNG preferred. Grayscale." + }, + "title_icon_dark_theme": { + "type": "string", + "description": "Small icon that shows up before the title / text. Dark theme variant. 16x16px. SVG or PNG preferred. Grayscale." + }, + "title_icon_alt_text": { + "type": "string", + "description": "Alt text describing title icon for screen readers", + "default": "" + }, + "button_action": { + "type": "string", + "description": "The type of action the button should trigger." + }, + "button_url": { + "allOf": [ + {"$ref": "#/definitions/link_url"}, + {"description": "A url, button_label links to this"} + ] + }, + "button_action_args": { + "description": "Additional parameters for button action, example which specific menu the button should open" + }, + "button_entrypoint_value": { + "description": "String used for telemetry attribution of clicks", + "type": "string" + }, + "button_entrypoint_name": { + "description": "String used for telemetry attribution of clicks", + "type": "string" + }, + "button_label": { + "allOf": [ + {"$ref": "#/definitions/plainText"}, + {"description": "Text for a button next to main snippet text that links to button_url. Requires button_url."} + ] + }, + "button_color": { + "type": "string", + "description": "The text color of the button. Valid CSS color." + }, + "button_background_color": { + "type": "string", + "description": "The background color of the button. Valid CSS color." + }, + "block_button_text": { + "type": "string", + "description": "Tooltip text used for dismiss button.", + "default": "Remove this" + }, + "tall": { + "type": "boolean", + "description": "To be used by fundraising only, increases height to roughly 120px. Defaults to false." + }, + "do_not_autoblock": { + "type": "boolean", + "description": "Used to prevent blocking the snippet after the CTA (link or button) has been clicked" + }, + "links": { + "additionalProperties": { + "url": { + "allOf": [ + {"$ref": "#/definitions/link_url"}, + {"description": "The url where the link points to."} + ] + }, + "metric": { + "type": "string", + "description": "Custom event name sent with telemetry event." + }, + "args": { + "type": "string", + "description": "Additional parameters for link action, example which specific menu the button should open" + } + } + }, + "section_title_icon": { + "type": "string", + "description": "Section title icon. 16x16px. SVG or PNG preferred. section_title_text must also be specified to display." + }, + "section_title_icon_dark_theme": { + "type": "string", + "description": "Section title icon, dark theme variant. 16x16px. SVG or PNG preferred. section_title_text must also be specified to display." + }, + "section_title_text": { + "type": "string", + "description": "Section title text. section_title_icon must also be specified to display." + }, + "section_title_url": { + "allOf": [ + {"$ref": "#/definitions/link_url"}, + {"description": "A url, section_title_text links to this"} + ] + } + }, + "additionalProperties": false, + "required": ["text"], + "dependencies": { + "button_action": ["button_label"], + "button_url": ["button_label"], + "button_color": ["button_label"], + "button_background_color": ["button_label"], + "section_title_url": ["section_title_text"], + "button_entrypoint_name": ["button_entrypoint_value"] + } +} diff --git a/browser/components/newtab/content-src/asrouter/templates/SimpleSnippet/_SimpleSnippet.scss b/browser/components/newtab/content-src/asrouter/templates/SimpleSnippet/_SimpleSnippet.scss new file mode 100644 index 0000000000..b16f78dc93 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/SimpleSnippet/_SimpleSnippet.scss @@ -0,0 +1,135 @@ +$section-header-height: 30px; +$icon-width: 54px; // width of primary icon + margin + +.SimpleSnippet { + &.tall { + padding: 27px 0; + } + + p em { + color: $grey-90; + font-style: normal; + background: $yellow-50; + } + + &.bold, + &.takeover { + .donation-form-url, + .donation-amount { + padding-top: 8px; + padding-bottom: 8px; + } + } + + &.bold { + height: 176px; + + .body { + font-size: 14px; + line-height: 20px; + margin-bottom: 20px; + } + + .icon { + width: 71px; + height: 71px; + } + } + + &.takeover { + height: 344px; + + .body { + font-size: 16px; + line-height: 24px; + margin-bottom: 35px; + } + + .icon { + width: 79px; + height: 79px; + } + } + + .title { + font-size: inherit; + margin: 0; + } + + .title-inline { + display: inline; + } + + .titleIcon { + background-repeat: no-repeat; + background-size: 14px; + background-position: center; + height: 16px; + width: 16px; + margin-top: 2px; + margin-inline-end: 2px; + display: inline-block; + vertical-align: top; + } + + .body { + display: inline; + margin: 0; + } + + &.tall .icon { + margin-inline-end: 20px; + } + + &.takeover, + &.bold { + .icon { + margin-inline-end: 20px; + } + } + + .icon { + align-self: flex-start; + } + + &.has-section-header .innerWrapper { + // account for section header being 100% width + flex-wrap: wrap; + padding-top: 7px; + } + + // wrapper div added if section-header is displayed that allows icon/text/button + // to squish instead of wrapping. this is effectively replicating layout behavior + // when section-header is *not* present. + .innerContentWrapper { + align-items: center; + display: flex; + } + + .section-header { + flex: 0 0 100%; + margin-bottom: 10px; + } + + .section-title { + // color should match that of 'Recommended by Pocket' and 'Highlights' in newtab page + color: var(--newtab-section-header-text-color); + display: inline-block; + font-size: 13px; + font-weight: bold; + margin: 0; + + a { + color: var(--newtab-section-header-text-color); + font-weight: inherit; + text-decoration: none; + } + + .icon { + height: 16px; + margin-inline-end: 6px; + margin-top: -2px; + width: 16px; + } + } +} diff --git a/browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/SubmitFormScene2Snippet.schema.json b/browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/SubmitFormScene2Snippet.schema.json new file mode 100644 index 0000000000..f3dcde11af --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/SubmitFormScene2Snippet.schema.json @@ -0,0 +1,163 @@ +{ + "title": "SubmitFormSnippet", + "description": "A template with two states: a SimpleSnippet and another that contains a form", + "version": "1.2.0", + "type": "object", + "definitions": { + "plainText": { + "description": "Plain text (no HTML allowed)", + "type": "string" + }, + "richText": { + "description": "Text with HTML subset allowed: i, b, u, strong, em, br", + "type": "string" + }, + "link_url": { + "description": "Target for links or buttons", + "type": "string", + "format": "uri" + } + }, + "properties": { + "locale": { + "type": "string", + "description": "Two to five character string for the locale code" + }, + "country": { + "type": "string", + "description": "Two character string for the country code (used for SMS)" + }, + "section_title_icon": { + "type": "string", + "description": "Section title icon. 16x16px. SVG or PNG preferred. section_title_text must also be specified to display." + }, + "section_title_icon_dark_theme": { + "type": "string", + "description": "Section title icon, dark theme variant. 16x16px. SVG or PNG preferred. section_title_text must also be specified to display." + }, + "section_title_text": { + "type": "string", + "description": "Section title text. section_title_icon must also be specified to display." + }, + "section_title_url": { + "allOf": [ + {"$ref": "#/definitions/link_url"}, + {"description": "A url, section_title_text links to this"} + ] + }, + "scene2_text": { + "allOf": [ + {"$ref": "#/definitions/richText"}, + {"description": "Main body text of snippet. HTML subset allowed: i, b, u, strong, em, br"} + ] + }, + "form_action": { + "type": "string", + "description": "Endpoint to submit form data." + }, + "success_title": { + "type": "string", + "description": "(send to device) Title shown before text on successful registration." + }, + "success_text": { + "type": "string", + "description": "Message shown on successful registration." + }, + "error_text": { + "type": "string", + "description": "Message shown if registration failed." + }, + "scene2_email_placeholder_text": { + "type": "string", + "description": "Value to show while input is empty." + }, + "scene2_input_placeholder": { + "type": "string", + "description": "(send to device) Value to show while input is empty." + }, + "scene2_button_label": { + "type": "string", + "description": "Label for form submit button" + }, + "scene2_privacy_html": { + "type": "string", + "description": "Information about how the form data is used." + }, + "scene2_disclaimer_html": { + "type": "string", + "description": "(send to device) Html for disclaimer and link underneath input box." + }, + "scene2_icon": { + "type": "string", + "description": "(send to device) Image to display above the form. 98x98px. SVG or PNG preferred." + }, + "scene2_icon_dark_theme": { + "type": "string", + "description": "(send to device) Image to display above the form. Dark theme variant. 98x98px. SVG or PNG preferred." + }, + "scene2_icon_alt_text": { + "type": "string", + "description": "Alt text describing scene2 icon for screen readers", + "default": "" + }, + "scene2_newsletter": { + "type": "string", + "description": "Newsletter/basket id user is subscribing to. Must be a value from the 'Slug' column here: https://basket.mozilla.org/news/. Default 'mozilla-foundation'." + }, + "hidden_inputs": { + "type": "object", + "description": "Each entry represents a hidden input, key is used as value for the name property." + }, + "retry_button_label": { + "allOf": [ + {"$ref": "#/definitions/plainText"}, + {"description": "Text for the button in the event of a submission error/failure."} + ], + "default": "Try again" + }, + "do_not_autoblock": { + "type": "boolean", + "description": "Used to prevent blocking the snippet after the CTA (link or button) has been clicked" + }, + "include_sms": { + "type": "boolean", + "description": "(send to device) Allow users to send an SMS message with the form?" + }, + "message_id_sms": { + "type": "string", + "description": "(send to device) Newsletter/basket id representing the SMS message to be sent." + }, + "message_id_email": { + "type": "string", + "description": "(send to device) Newsletter/basket id representing the email message to be sent. Must be a value from the 'Slug' column here: https://basket.mozilla.org/news/." + }, + "utm_campaign": { + "type": "string", + "description": "(fxa) Value to pass through to GA as utm_campaign." + }, + "utm_term": { + "type": "string", + "description": "(fxa) Value to pass through to GA as utm_term." + }, + "links": { + "additionalProperties": { + "url": { + "allOf": [ + {"$ref": "#/definitions/link_url"}, + {"description": "The url where the link points to."} + ] + }, + "metric": { + "type": "string", + "description": "Custom event name sent with telemetry event." + } + } + } + }, + "additionalProperties": false, + "required": ["scene2_text"], + "dependencies": { + "section_title_icon": ["section_title_text"], + "section_title_icon_dark_theme": ["section_title_text"] + } +} diff --git a/browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/SubmitFormSnippet.jsx b/browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/SubmitFormSnippet.jsx new file mode 100644 index 0000000000..d1f267f2fa --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/SubmitFormSnippet.jsx @@ -0,0 +1,409 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { Button } from "../../components/Button/Button"; +import React from "react"; +import { RichText } from "../../components/RichText/RichText"; +import { safeURI } from "../../template-utils"; +import { SimpleSnippet } from "../SimpleSnippet/SimpleSnippet"; +import { SnippetBase } from "../../components/SnippetBase/SnippetBase"; +import ConditionalWrapper from "../../components/ConditionalWrapper/ConditionalWrapper"; + +// Alt text placeholder in case the prop from the server isn't available +const ICON_ALT_TEXT = ""; + +export class SubmitFormSnippet extends React.PureComponent { + constructor(props) { + super(props); + this.expandSnippet = this.expandSnippet.bind(this); + this.handleSubmit = this.handleSubmit.bind(this); + this.handleSubmitAttempt = this.handleSubmitAttempt.bind(this); + this.onInputChange = this.onInputChange.bind(this); + this.state = { + expanded: false, + submitAttempted: false, + signupSubmitted: false, + signupSuccess: false, + disableForm: false, + }; + } + + handleSubmitAttempt() { + if (!this.state.submitAttempted) { + this.setState({ submitAttempted: true }); + } + } + + async handleSubmit(event) { + let json; + + if (this.state.disableForm) { + return; + } + + event.preventDefault(); + this.setState({ disableForm: true }); + this.props.sendUserActionTelemetry({ + event: "CLICK_BUTTON", + event_context: "conversion-subscribe-activation", + id: "NEWTAB_FOOTER_BAR_CONTENT", + }); + + if (this.props.form_method.toUpperCase() === "GET") { + this.props.onBlock({ preventDismiss: true }); + this.refs.form.submit(); + return; + } + + const { url, formData } = this.props.processFormData + ? this.props.processFormData(this.refs.mainInput, this.props) + : { url: this.refs.form.action, formData: new FormData(this.refs.form) }; + + try { + const fetchRequest = new Request(url, { + body: formData, + method: "POST", + credentials: "omit", + }); + const response = await fetch(fetchRequest); // eslint-disable-line fetch-options/no-fetch-credentials + json = await response.json(); + } catch (err) { + console.log(err); // eslint-disable-line no-console + } + + if (json && json.status === "ok") { + this.setState({ signupSuccess: true, signupSubmitted: true }); + if (!this.props.content.do_not_autoblock) { + this.props.onBlock({ preventDismiss: true }); + } + this.props.sendUserActionTelemetry({ + event: "CLICK_BUTTON", + event_context: "subscribe-success", + id: "NEWTAB_FOOTER_BAR_CONTENT", + }); + } else { + // eslint-disable-next-line no-console + console.error( + "There was a problem submitting the form", + json || "[No JSON response]" + ); + this.setState({ signupSuccess: false, signupSubmitted: true }); + this.props.sendUserActionTelemetry({ + event: "CLICK_BUTTON", + event_context: "subscribe-error", + id: "NEWTAB_FOOTER_BAR_CONTENT", + }); + } + + this.setState({ disableForm: false }); + } + + expandSnippet() { + this.props.sendUserActionTelemetry({ + event: "CLICK_BUTTON", + event_context: "scene1-button-learn-more", + id: this.props.UISurface, + }); + + this.setState({ + expanded: true, + signupSuccess: false, + signupSubmitted: false, + }); + } + + renderHiddenFormInputs() { + const { hidden_inputs } = this.props.content; + + if (!hidden_inputs) { + return null; + } + + return Object.keys(hidden_inputs).map((key, idx) => ( + + )); + } + + renderDisclaimer() { + const { content } = this.props; + if (!content.scene2_disclaimer_html) { + return null; + } + return ( +

+ +

+ ); + } + + renderFormPrivacyNotice() { + const { content } = this.props; + if (!content.scene2_privacy_html) { + return null; + } + return ( +

+ + +

+ ); + } + + renderSignupSubmitted() { + const { content } = this.props; + const isSuccess = this.state.signupSuccess; + const successTitle = isSuccess && content.success_title; + const bodyText = isSuccess + ? { success_text: content.success_text } + : { error_text: content.error_text }; + const retryButtonText = content.retry_button_label; + return ( + +
+ {successTitle ? ( +

{successTitle}

+ ) : null} +

+ + {isSuccess ? null : ( + + )} +

+
+
+ ); + } + + onInputChange(event) { + if (!this.props.validateInput) { + return; + } + const hasError = this.props.validateInput( + event.target.value, + this.props.content + ); + event.target.setCustomValidity(hasError); + } + + wrapSectionHeader(url) { + return function(children) { + return {children}; + }; + } + + renderInput() { + const placholder = + this.props.content.scene2_email_placeholder_text || + this.props.content.scene2_input_placeholder; + return ( + + ); + } + + renderForm() { + return ( +
+ {this.renderHiddenFormInputs()} +
+ {this.renderInput()} + +
+ {this.renderFormPrivacyNotice() || this.renderDisclaimer()} +
+ ); + } + + renderScene2Icon() { + const { content } = this.props; + if (!content.scene2_icon) { + return null; + } + + return ( +
+ {content.scene2_icon_alt_text + {content.scene2_icon_alt_text +
+ ); + } + + renderSignupView() { + const { content } = this.props; + const containerClass = `SubmitFormSnippet ${this.props.className}`; + return ( + + {this.renderScene2Icon()} +
+

+ {content.scene2_title && ( +

{content.scene2_title}

+ )}{" "} + {content.scene2_text && ( + + )} +

+
+ {this.renderForm()} +
+ ); + } + + renderSectionHeader() { + const { props } = this; + + // an icon and text must be specified to render the section header + if (props.content.section_title_icon && props.content.section_title_text) { + const sectionTitleIconLight = safeURI(props.content.section_title_icon); + const sectionTitleIconDark = safeURI( + props.content.section_title_icon_dark_theme || + props.content.section_title_icon + ); + const sectionTitleURL = props.content.section_title_url; + + return ( +
+

+ + + + + {props.content.section_title_text} + + +

+
+ ); + } + + return null; + } + + renderSignupViewAlt() { + const { content } = this.props; + const containerClass = `SubmitFormSnippet ${this.props.className} scene2Alt`; + return ( + + {this.renderSectionHeader()} + {this.renderScene2Icon()} +
+

+ {content.scene2_text && ( + + )} +

+ {this.renderForm()} +
+
+ ); + } + + getFirstSceneContent() { + return Object.keys(this.props.content) + .filter(key => key.includes("scene1")) + .reduce((acc, key) => { + acc[key.substr(7)] = this.props.content[key]; + return acc; + }, {}); + } + + render() { + const content = { ...this.props.content, ...this.getFirstSceneContent() }; + + if (this.state.signupSubmitted) { + return this.renderSignupSubmitted(); + } + // Render only scene 2 (signup view). Must check before `renderSignupView` + // to catch the Failure/Try again scenario where we want to return and render + // the scene again. + if (this.props.expandedAlt) { + return this.renderSignupViewAlt(); + } + if (this.state.expanded) { + return this.renderSignupView(); + } + return ( + + ); + } +} diff --git a/browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/SubmitFormSnippet.schema.json b/browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/SubmitFormSnippet.schema.json new file mode 100644 index 0000000000..0fc3128d1c --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/SubmitFormSnippet.schema.json @@ -0,0 +1,225 @@ +{ + "title": "SubmitFormSnippet", + "description": "A template with two states: a SimpleSnippet and another that contains a form", + "version": "1.2.0", + "type": "object", + "definitions": { + "plainText": { + "description": "Plain text (no HTML allowed)", + "type": "string" + }, + "richText": { + "description": "Text with HTML subset allowed: i, b, u, strong, em, br", + "type": "string" + }, + "link_url": { + "description": "Target for links or buttons", + "type": "string", + "format": "uri" + } + }, + "properties": { + "locale": { + "type": "string", + "description": "Two to five character string for the locale code" + }, + "country": { + "type": "string", + "description": "Two character string for the country code (used for SMS)" + }, + "scene1_title": { + "allof": [ + {"$ref": "#/definitions/plainText"}, + {"description": "snippet title displayed before snippet text"} + ] + }, + "scene1_text": { + "allOf": [ + {"$ref": "#/definitions/richText"}, + {"description": "Main body text of snippet. HTML subset allowed: i, b, u, strong, em, br"} + ] + }, + "scene1_section_title_icon": { + "type": "string", + "description": "Section title icon for scene 1. 16x16px. SVG or PNG preferred. scene1_section_title_text must also be specified to display." + }, + "scene1_section_title_icon_dark_theme": { + "type": "string", + "description": "Section title icon for scene 1, dark theme variant. 16x16px. SVG or PNG preferred. scene1_section_title_text must also be specified to display." + }, + "scene1_section_title_text": { + "type": "string", + "description": "Section title text for scene 1. scene1_section_title_icon must also be specified to display." + }, + "scene1_section_title_url": { + "allOf": [ + {"$ref": "#/definitions/link_url"}, + {"description": "A url, scene1_section_title_text links to this"} + ] + }, + "scene2_title": { + "allOf": [ + {"$ref": "#/definitions/plainText"}, + {"description": "Title displayed before text in scene 2. Should be plain text."} + ] + }, + "scene2_text": { + "allOf": [ + {"$ref": "#/definitions/richText"}, + {"description": "Main body text of snippet. HTML subset allowed: i, b, u, strong, em, br"} + ] + }, + "scene1_icon": { + "type": "string", + "description": "Snippet icon. 64x64px. SVG or PNG preferred." + }, + "scene1_icon_dark_theme": { + "type": "string", + "description": "Snippet icon. Dark theme variant. 64x64px. SVG or PNG preferred." + }, + "scene1_icon_alt_text": { + "type": "string", + "description": "Alt text describing scene1 icon for screen readers", + "default": "" + }, + "scene1_title_icon": { + "type": "string", + "description": "Small icon that shows up before the title / text. 16x16px. SVG or PNG preferred. Grayscale." + }, + "scene1_title_icon_dark_theme": { + "type": "string", + "description": "Small icon that shows up before the title / text. Dark theme variant. 16x16px. SVG or PNG preferred. Grayscale." + }, + "scene1_title_icon_alt_text": { + "type": "string", + "description": "Alt text describing scene1 title icon for screen readers", + "default": "" + }, + "form_action": { + "type": "string", + "description": "Endpoint to submit form data." + }, + "success_title": { + "type": "string", + "description": "(send to device) Title shown before text on successful registration." + }, + "success_text": { + "type": "string", + "description": "Message shown on successful registration." + }, + "error_text": { + "type": "string", + "description": "Message shown if registration failed." + }, + "scene2_email_placeholder_text": { + "type": "string", + "description": "Value to show while input is empty." + }, + "scene2_input_placeholder": { + "type": "string", + "description": "(send to device) Value to show while input is empty." + }, + "scene2_button_label": { + "type": "string", + "description": "Label for form submit button" + }, + "scene2_privacy_html": { + "type": "string", + "description": "Information about how the form data is used." + }, + "scene2_disclaimer_html": { + "type": "string", + "description": "(send to device) Html for disclaimer and link underneath input box." + }, + "scene2_dismiss_button_text": { + "type": "string", + "description": "Label for the dismiss button when the sign-up form is expanded." + }, + "scene2_icon": { + "type": "string", + "description": "(send to device) Image to display above the form. 98x98px. SVG or PNG preferred." + }, + "scene2_icon_dark_theme": { + "type": "string", + "description": "(send to device) Image to display above the form. Dark theme variant. 98x98px. SVG or PNG preferred." + }, + "scene2_icon_alt_text": { + "type": "string", + "description": "Alt text describing scene2 icon for screen readers", + "default": "" + }, + "scene2_newsletter": { + "type": "string", + "description": "Newsletter/basket id user is subscribing to. Must be a value from the 'Slug' column here: https://basket.mozilla.org/news/. Default 'mozilla-foundation'." + }, + "hidden_inputs": { + "type": "object", + "description": "Each entry represents a hidden input, key is used as value for the name property." + }, + "scene1_button_label": { + "allOf": [ + {"$ref": "#/definitions/plainText"}, + {"description": "Text for a button next to main snippet text that links to button_url. Requires button_url."} + ] + }, + "scene1_button_color": { + "type": "string", + "description": "The text color of the button. Valid CSS color." + }, + "scene1_button_background_color": { + "type": "string", + "description": "The background color of the button. Valid CSS color." + }, + "retry_button_label": { + "allOf": [ + {"$ref": "#/definitions/plainText"}, + {"description": "Text for the button in the event of a submission error/failure."} + ], + "default": "Try again" + }, + "do_not_autoblock": { + "type": "boolean", + "description": "Used to prevent blocking the snippet after the CTA (link or button) has been clicked" + }, + "include_sms": { + "type": "boolean", + "description": "(send to device) Allow users to send an SMS message with the form?" + }, + "message_id_sms": { + "type": "string", + "description": "(send to device) Newsletter/basket id representing the SMS message to be sent." + }, + "message_id_email": { + "type": "string", + "description": "(send to device) Newsletter/basket id representing the email message to be sent. Must be a value from the 'Slug' column here: https://basket.mozilla.org/news/." + }, + "utm_campaign": { + "type": "string", + "description": "(fxa) Value to pass through to GA as utm_campaign." + }, + "utm_term": { + "type": "string", + "description": "(fxa) Value to pass through to GA as utm_term." + }, + "links": { + "additionalProperties": { + "url": { + "allOf": [ + {"$ref": "#/definitions/link_url"}, + {"description": "The url where the link points to."} + ] + }, + "metric": { + "type": "string", + "description": "Custom event name sent with telemetry event." + } + } + } + }, + "additionalProperties": false, + "required": ["scene1_text", "scene2_text", "scene1_button_label"], + "dependencies": { + "scene1_button_color": ["scene1_button_label"], + "scene1_button_background_color": ["scene1_button_label"] + } +} diff --git a/browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/_SubmitFormSnippet.scss b/browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/_SubmitFormSnippet.scss new file mode 100644 index 0000000000..286366c12b --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/_SubmitFormSnippet.scss @@ -0,0 +1,176 @@ +.SubmitFormSnippet { + flex-direction: column; + flex: 1 1 100%; + width: 100%; + + .disclaimerText { + margin: 5px 0 0; + font-size: 12px; + color: var(--newtab-text-secondary-color); + } + + p { + margin: 0; + } + + &.send_to_device_snippet { + text-align: center; + + .message { + font-size: 16px; + margin-bottom: 20px; + } + + .scene2Title { + font-size: 24px; + display: block; + } + } + + .ASRouterButton { + &.primary { + flex: 1 1 0; + } + } + + .scene2Icon { + width: 100%; + margin-bottom: 20px; + + img { + width: 98px; + display: inline-block; + } + } + + .scene2Title { + font-size: inherit; + margin: 0 0 10px; + font-weight: bold; + display: inline; + } + + form { + display: flex; + flex-direction: column; + width: 100%; + } + + .message { + font-size: 14px; + align-self: stretch; + flex: 0 0 100%; + margin-bottom: 10px; + } + + .privacyNotice { + font-size: 12px; + color: var(--newtab-text-secondary-color); + margin-top: 10px; + display: flex; + flex: 0 0 100%; + } + + .innerWrapper { + // https://github.com/mozmeao/snippets/blob/2054899350590adcb3c0b0a341c782b0e2f81d0b/activity-stream/newsletter-subscribe.html#L46 + max-width: 736px; + flex-wrap: wrap; + justify-items: center; + padding-top: 40px; + padding-bottom: 40px; + } + + .footer { + width: 100%; + margin: 0 auto; + text-align: right; + background-color: var(--newtab-background-color); + padding: 10px 0; + + .footer-content { + margin: 0 auto; + max-width: 768px; + width: 100%; + text-align: right; + + [dir='rtl'] & { + text-align: left; + } + } + } + + input { + &.mainInput { + border-radius: 2px; + background-color: var(--newtab-textbox-background-color); + border: $input-border; + padding: 0 8px; + height: 100%; + font-size: 14px; + width: 50%; + + &.clean { + &:invalid, + &:required { + box-shadow: none; + } + } + + &:focus { + border: $input-border-active; + box-shadow: var(--newtab-textbox-focus-boxshadow); + } + } + } + + &.scene2Alt { + text-align: start; + + .scene2Icon { + flex: 1; + margin-bottom: 0; + } + + .message { + flex: 5; + margin-bottom: 0; + + p { + margin-bottom: 10px; + } + } + + .section-header { + width: 100%; + + .icon { + width: 16px; + height: 16px; + } + } + + .section-title { + font-size: 13px; + } + + .section-title a { + color: var(--newtab-section-header-text-color); + font-weight: inherit; + text-decoration: none; + } + + .innerWrapper { + padding: 0 0 16px; + } + } +} + +.submissionStatus { + text-align: center; + font-size: 14px; + padding: 20px 0; + + .submitStatusTitle { + font-size: 20px; + } +} diff --git a/browser/components/newtab/content-src/asrouter/templates/template-manifest.jsx b/browser/components/newtab/content-src/asrouter/templates/template-manifest.jsx new file mode 100644 index 0000000000..57f8afa6f5 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/template-manifest.jsx @@ -0,0 +1,24 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { EOYSnippet } from "./EOYSnippet/EOYSnippet"; +import { FXASignupSnippet } from "./FXASignupSnippet/FXASignupSnippet"; +import { NewsletterSnippet } from "./NewsletterSnippet/NewsletterSnippet"; +import { + SendToDeviceSnippet, + SendToDeviceScene2Snippet, +} from "./SendToDeviceSnippet/SendToDeviceSnippet"; +import { SimpleBelowSearchSnippet } from "./SimpleBelowSearchSnippet/SimpleBelowSearchSnippet"; +import { SimpleSnippet } from "./SimpleSnippet/SimpleSnippet"; + +// Key names matching schema name of templates +export const SnippetsTemplates = { + simple_snippet: SimpleSnippet, + newsletter_snippet: NewsletterSnippet, + fxa_signup_snippet: FXASignupSnippet, + send_to_device_snippet: SendToDeviceSnippet, + send_to_device_scene2_snippet: SendToDeviceScene2Snippet, + eoy_snippet: EOYSnippet, + simple_below_search_snippet: SimpleBelowSearchSnippet, +}; diff --git a/browser/components/newtab/content-src/components/A11yLinkButton/A11yLinkButton.jsx b/browser/components/newtab/content-src/components/A11yLinkButton/A11yLinkButton.jsx new file mode 100644 index 0000000000..3aab52cdff --- /dev/null +++ b/browser/components/newtab/content-src/components/A11yLinkButton/A11yLinkButton.jsx @@ -0,0 +1,18 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React from "react"; + +export function A11yLinkButton(props) { + // function for merging classes, if necessary + let className = "a11y-link-button"; + if (props.className) { + className += ` ${props.className}`; + } + return ( + + ); +} diff --git a/browser/components/newtab/content-src/components/A11yLinkButton/_A11yLinkButton.scss b/browser/components/newtab/content-src/components/A11yLinkButton/_A11yLinkButton.scss new file mode 100644 index 0000000000..622e5a2b7b --- /dev/null +++ b/browser/components/newtab/content-src/components/A11yLinkButton/_A11yLinkButton.scss @@ -0,0 +1,13 @@ + +.a11y-link-button { + border: 0; + padding: 0; + cursor: pointer; + text-align: unset; + color: var(--newtab-link-primary-color); + + &:hover, + &:focus { + text-decoration: underline; + } +} diff --git a/browser/components/newtab/content-src/components/ASRouterAdmin/ASRouterAdmin.jsx b/browser/components/newtab/content-src/components/ASRouterAdmin/ASRouterAdmin.jsx new file mode 100644 index 0000000000..e0d8e45cc0 --- /dev/null +++ b/browser/components/newtab/content-src/components/ASRouterAdmin/ASRouterAdmin.jsx @@ -0,0 +1,1936 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { actionCreators as ac, actionTypes as at } from "common/Actions.jsm"; +import { ASRouterUtils } from "../../asrouter/asrouter-utils"; +import { connect } from "react-redux"; +import React from "react"; +import { SimpleHashRouter } from "./SimpleHashRouter"; + +const Row = props => ( + + {props.children} + +); + +function relativeTime(timestamp) { + if (!timestamp) { + return ""; + } + const seconds = Math.floor((Date.now() - timestamp) / 1000); + const minutes = Math.floor((Date.now() - timestamp) / 60000); + if (seconds < 2) { + return "just now"; + } else if (seconds < 60) { + return `${seconds} seconds ago`; + } else if (minutes === 1) { + return "1 minute ago"; + } else if (minutes < 600) { + return `${minutes} minutes ago`; + } + return new Date(timestamp).toLocaleString(); +} + +const LAYOUT_VARIANTS = { + basic: "Basic default layout (on by default in nightly)", + staging_spocs: "A layout with all spocs shown", + "dev-test-all": + "A little bit of everything. Good layout for testing all components", + "dev-test-feeds": "Stress testing for slow feeds", +}; + +export class ToggleStoryButton extends React.PureComponent { + constructor(props) { + super(props); + this.handleClick = this.handleClick.bind(this); + } + + handleClick() { + this.props.onClick(this.props.story); + } + + render() { + return ; + } +} + +export class ToggleMessageJSON extends React.PureComponent { + constructor(props) { + super(props); + this.handleClick = this.handleClick.bind(this); + } + + handleClick() { + this.props.toggleJSON(this.props.msgId); + } + + render() { + let iconName = this.props.isCollapsed + ? "icon icon-arrowhead-forward-small" + : "icon icon-arrowhead-down-small"; + return ( + + ); + } +} + +export class TogglePrefCheckbox extends React.PureComponent { + constructor(props) { + super(props); + this.onChange = this.onChange.bind(this); + } + + onChange(event) { + this.props.onChange(this.props.pref, event.target.checked); + } + + render() { + return ( + <> + {" "} + {this.props.pref}{" "} + + ); + } +} + +export class Personalization extends React.PureComponent { + constructor(props) { + super(props); + this.togglePersonalizationVersion = this.togglePersonalizationVersion.bind( + this + ); + } + + togglePersonalizationVersion() { + this.props.dispatch( + ac.OnlyToMain({ + type: at.DISCOVERY_STREAM_PERSONALIZATION_VERSION_TOGGLE, + }) + ); + } + + render() { + const { + lastUpdated, + version, + initialized, + } = this.props.state.Personalization; + return ( + + + + + + + + + + + + + {version === 2 ? ( + + + + + ) : null} + +
Personalization version{version}Personalization Last Updated{relativeTime(lastUpdated) || "(no data)"}Personalization V2 Initialized{initialized ? "true" : "false"}
+
+ ); + } +} + +export class DiscoveryStreamAdmin extends React.PureComponent { + constructor(props) { + super(props); + this.restorePrefDefaults = this.restorePrefDefaults.bind(this); + this.setConfigValue = this.setConfigValue.bind(this); + this.expireCache = this.expireCache.bind(this); + this.refreshCache = this.refreshCache.bind(this); + this.idleDaily = this.idleDaily.bind(this); + this.systemTick = this.systemTick.bind(this); + this.syncRemoteSettings = this.syncRemoteSettings.bind(this); + this.changeEndpointVariant = this.changeEndpointVariant.bind(this); + this.onStoryToggle = this.onStoryToggle.bind(this); + this.state = { + toggledStories: {}, + }; + } + + setConfigValue(name, value) { + this.props.dispatch( + ac.OnlyToMain({ + type: at.DISCOVERY_STREAM_CONFIG_SET_VALUE, + data: { name, value }, + }) + ); + } + + restorePrefDefaults(event) { + this.props.dispatch( + ac.OnlyToMain({ + type: at.DISCOVERY_STREAM_CONFIG_RESET_DEFAULTS, + }) + ); + } + + refreshCache() { + const { config } = this.props.state.DiscoveryStream; + this.props.dispatch( + ac.OnlyToMain({ + type: at.DISCOVERY_STREAM_CONFIG_CHANGE, + data: config, + }) + ); + } + + dispatchSimpleAction(type) { + this.props.dispatch( + ac.OnlyToMain({ + type, + }) + ); + } + + systemTick() { + this.dispatchSimpleAction(at.DISCOVERY_STREAM_DEV_SYSTEM_TICK); + } + + expireCache() { + this.dispatchSimpleAction(at.DISCOVERY_STREAM_DEV_EXPIRE_CACHE); + } + + idleDaily() { + this.dispatchSimpleAction(at.DISCOVERY_STREAM_DEV_IDLE_DAILY); + } + + syncRemoteSettings() { + this.dispatchSimpleAction(at.DISCOVERY_STREAM_DEV_SYNC_RS); + } + + changeEndpointVariant(event) { + const endpoint = this.props.state.DiscoveryStream.config.layout_endpoint; + if (endpoint) { + this.setConfigValue( + "layout_endpoint", + endpoint.replace( + /layout_variant=.+/, + `layout_variant=${event.target.value}` + ) + ); + } + } + + renderComponent(width, component) { + return ( + + + + + + + + + + + {component.feed && this.renderFeed(component.feed)} + +
Type{component.type}Width{width}
+ ); + } + + isCurrentVariant(id) { + const endpoint = this.props.state.DiscoveryStream.config.layout_endpoint; + const isMatch = endpoint && !!endpoint.match(`layout_variant=${id}`); + return isMatch; + } + + renderFeedData(url) { + const { feeds } = this.props.state.DiscoveryStream; + const feed = feeds.data[url].data; + return ( + +

Feed url: {url}

+ + + {feed.recommendations.map(story => this.renderStoryData(story))} + +
+
+ ); + } + + renderFeedsData() { + const { feeds } = this.props.state.DiscoveryStream; + return ( + + {Object.keys(feeds.data).map(url => this.renderFeedData(url))} + + ); + } + + renderSpocs() { + const { spocs } = this.props.state.DiscoveryStream; + let spocsData = []; + if (spocs.data && spocs.data.spocs && spocs.data.spocs.items) { + spocsData = spocs.data.spocs.items || []; + } + + return ( + + + + + + + + + + + + +
spocs_endpoint{spocs.spocs_endpoint}Data last fetched{relativeTime(spocs.lastUpdated)}
+

Spoc data

+ + {spocsData.map(spoc => this.renderStoryData(spoc))} +
+

Spoc frequency caps

+ + + {spocs.frequency_caps.map(spoc => this.renderStoryData(spoc))} + +
+
+ ); + } + + onStoryToggle(story) { + const { toggledStories } = this.state; + this.setState({ + toggledStories: { + ...toggledStories, + [story.id]: !toggledStories[story.id], + }, + }); + } + + renderStoryData(story) { + let storyData = ""; + if (this.state.toggledStories[story.id]) { + storyData = JSON.stringify(story, null, 2); + } + return ( + + + + {story.id}
+
+ + + +
{storyData}
+ + + ); + } + + renderFeed(feed) { + const { feeds } = this.props.state.DiscoveryStream; + if (!feed.url) { + return null; + } + return ( + + + Feed url + {feed.url} + + + Data last fetched + + {relativeTime( + feeds.data[feed.url] ? feeds.data[feed.url].lastUpdated : null + ) || "(no data)"} + + + + ); + } + + render() { + const prefToggles = "enabled hardcoded_layout show_spocs personalized collapsible".split( + " " + ); + const { config, lastUpdated, layout } = this.props.state.DiscoveryStream; + return ( +
+ {" "} + +
+ {" "} + {" "} + +
+ + + + {prefToggles.map(pref => ( + + + + ))} + +
+ +
+

Endpoint variant

+

+ You can also change this manually by changing this pref:{" "} + browser.newtabpage.activity-stream.discoverystream.config +

+ + + {Object.keys(LAYOUT_VARIANTS).map(id => ( + + + + + + ))} + +
+ + {id}{LAYOUT_VARIANTS[id]}
+

Caching info

+ + + + + + + +
Data last fetched{relativeTime(lastUpdated) || "(no data)"}
+

Layout

+ {layout.map((row, rowIndex) => ( +
+ {row.components.map((component, componentIndex) => ( +
+ {this.renderComponent(row.width, component)} +
+ ))} +
+ ))} +

Personalization

+ +

Spocs

+ {this.renderSpocs()} +

Feeds Data

+ {this.renderFeedsData()} +
+ ); + } +} + +export class ASRouterAdminInner extends React.PureComponent { + constructor(props) { + super(props); + this.handleEnabledToggle = this.handleEnabledToggle.bind(this); + this.handleUserPrefToggle = this.handleUserPrefToggle.bind(this); + this.onChangeMessageFilter = this.onChangeMessageFilter.bind(this); + this.onChangeMessageGroupsFilter = this.onChangeMessageGroupsFilter.bind( + this + ); + this.unblockAll = this.unblockAll.bind(this); + this.handleClearAllImpressionsByProvider = this.handleClearAllImpressionsByProvider.bind( + this + ); + this.handleExpressionEval = this.handleExpressionEval.bind(this); + this.onChangeTargetingParameters = this.onChangeTargetingParameters.bind( + this + ); + this.onChangeAttributionParameters = this.onChangeAttributionParameters.bind( + this + ); + this.setAttribution = this.setAttribution.bind(this); + this.onCopyTargetingParams = this.onCopyTargetingParams.bind(this); + this.onNewTargetingParams = this.onNewTargetingParams.bind(this); + this.handleUpdateWNMessages = this.handleUpdateWNMessages.bind(this); + this.handleForceWNP = this.handleForceWNP.bind(this); + this.handleCloseWNP = this.handleCloseWNP.bind(this); + this.resetPanel = this.resetPanel.bind(this); + this.restoreWNMessageState = this.restoreWNMessageState.bind(this); + this.toggleJSON = this.toggleJSON.bind(this); + this.toggleAllMessages = this.toggleAllMessages.bind(this); + this.resetGroups = this.resetGroups.bind(this); + this.onMessageFromParent = this.onMessageFromParent.bind(this); + this.setStateFromParent = this.setStateFromParent.bind(this); + this.setState = this.setState.bind(this); + this.state = { + messageFilter: "all", + messageGroupsFilter: "all", + WNMessages: [], + collapsedMessages: [], + modifiedMessages: [], + evaluationStatus: {}, + stringTargetingParameters: null, + newStringTargetingParameters: null, + copiedToClipboard: false, + attributionParameters: { + source: "addons.mozilla.org", + medium: "referral", + campaign: "non-fx-button", + content: "iridium@particlecore.github.io", + experiment: "ua-onboarding", + variation: "chrome", + ua: "Google Chrome 123", + dltoken: "00000000-0000-0000-0000-000000000000", + }, + }; + } + + onMessageFromParent({ type, data }) { + // These only exists due to onPrefChange events in ASRouter + switch (type) { + case "UpdateAdminState": { + this.setStateFromParent(data); + break; + } + } + } + + setStateFromParent(data) { + this.setState(data); + if (!this.state.stringTargetingParameters) { + const stringTargetingParameters = {}; + for (const param of Object.keys(data.targetingParameters)) { + stringTargetingParameters[param] = JSON.stringify( + data.targetingParameters[param], + null, + 2 + ); + } + this.setState({ stringTargetingParameters }); + } + } + + componentWillMount() { + ASRouterUtils.addListener(this.onMessageFromParent); + const endpoint = ASRouterUtils.getPreviewEndpoint(); + ASRouterUtils.sendMessage({ + type: "ADMIN_CONNECT_STATE", + data: { endpoint }, + }).then(this.setStateFromParent); + } + + handleBlock(msg) { + return () => ASRouterUtils.blockById(msg.id); + } + + handleUnblock(msg) { + return () => ASRouterUtils.unblockById(msg.id); + } + + resetJSON(msg) { + // reset the displayed JSON for the given message + document.getElementById(`${msg.id}-textarea`).value = JSON.stringify( + msg, + null, + 2 + ); + // remove the message from the list of modified IDs + let index = this.state.modifiedMessages.indexOf(msg.id); + this.setState(prevState => ({ + modifiedMessages: [ + ...prevState.modifiedMessages.slice(0, index), + ...prevState.modifiedMessages.slice(index + 1), + ], + })); + } + + resetAllJSON() { + let messageCheckboxes = document.querySelectorAll('input[type="checkbox"]'); + + for (const checkbox of messageCheckboxes) { + let trimmedId = checkbox.id.replace(" checkbox", ""); + + let message = this.state.messages.filter(msg => msg.id === trimmedId); + let msgId = message[0].id; + + document.getElementById(`${msgId}-textarea`).value = JSON.stringify( + message[0], + null, + 2 + ); + } + this.setState({ + WNMessages: [], + }); + } + + resetPanel() { + this.resetAllJSON(); + this.handleCloseWNP(); + } + + handleOverride(id) { + return () => + ASRouterUtils.overrideMessage(id).then(state => { + this.setStateFromParent(state); + this.props.notifyContent({ + message: state.message, + }); + }); + } + + async handleUpdateWNMessages() { + await this.restoreWNMessageState(); + let messages = this.state.WNMessages; + + for (const msg of messages) { + ASRouterUtils.modifyMessageJson(JSON.parse(msg)); + } + } + + handleForceWNP() { + ASRouterUtils.sendMessage({ type: "FORCE_WHATSNEW_PANEL" }); + } + + handleCloseWNP() { + ASRouterUtils.sendMessage({ type: "CLOSE_WHATSNEW_PANEL" }); + } + + expireCache() { + ASRouterUtils.sendMessage({ type: "EXPIRE_QUERY_CACHE" }); + } + + resetPref() { + ASRouterUtils.sendMessage({ type: "RESET_PROVIDER_PREF" }); + } + + resetGroups(id, value) { + ASRouterUtils.sendMessage({ + type: "RESET_GROUPS_STATE", + }).then(this.setStateFromParent); + } + + handleExpressionEval() { + const context = {}; + for (const param of Object.keys(this.state.stringTargetingParameters)) { + const value = this.state.stringTargetingParameters[param]; + context[param] = value ? JSON.parse(value) : null; + } + ASRouterUtils.sendMessage({ + type: "EVALUATE_JEXL_EXPRESSION", + data: { + expression: this.refs.expressionInput.value, + context, + }, + }).then(this.setStateFromParent); + } + + onChangeTargetingParameters(event) { + const { name } = event.target; + const { value } = event.target; + + this.setState(({ stringTargetingParameters }) => { + let targetingParametersError = null; + const updatedParameters = { ...stringTargetingParameters }; + updatedParameters[name] = value; + try { + JSON.parse(value); + } catch (e) { + console.log(`Error parsing value of parameter ${name}`); // eslint-disable-line no-console + targetingParametersError = { id: name }; + } + + return { + copiedToClipboard: false, + evaluationStatus: {}, + stringTargetingParameters: updatedParameters, + targetingParametersError, + }; + }); + } + + unblockAll() { + return ASRouterUtils.sendMessage({ + type: "UNBLOCK_ALL", + }).then(this.setStateFromParent); + } + + handleClearAllImpressionsByProvider() { + const providerId = this.state.messageFilter; + if (!providerId) { + return; + } + const userPrefInfo = this.state.userPrefs; + + const isUserEnabled = + providerId in userPrefInfo ? userPrefInfo[providerId] : true; + + ASRouterUtils.sendMessage({ + type: "DISABLE_PROVIDER", + data: providerId, + }); + if (!isUserEnabled) { + ASRouterUtils.sendMessage({ + type: "SET_PROVIDER_USER_PREF", + data: { id: providerId, value: true }, + }); + } + ASRouterUtils.sendMessage({ + type: "ENABLE_PROVIDER", + data: providerId, + }); + } + + handleEnabledToggle(event) { + const provider = this.state.providerPrefs.find( + p => p.id === event.target.dataset.provider + ); + const userPrefInfo = this.state.userPrefs; + + const isUserEnabled = + provider.id in userPrefInfo ? userPrefInfo[provider.id] : true; + const isSystemEnabled = provider.enabled; + const isEnabling = event.target.checked; + + if (isEnabling) { + if (!isUserEnabled) { + ASRouterUtils.sendMessage({ + type: "SET_PROVIDER_USER_PREF", + data: { id: provider.id, value: true }, + }); + } + if (!isSystemEnabled) { + ASRouterUtils.sendMessage({ + type: "ENABLE_PROVIDER", + data: provider.id, + }); + } + } else { + ASRouterUtils.sendMessage({ + type: "DISABLE_PROVIDER", + data: provider.id, + }); + } + + this.setState({ messageFilter: "all" }); + } + + handleUserPrefToggle(event) { + const action = { + type: "SET_PROVIDER_USER_PREF", + data: { id: event.target.dataset.provider, value: event.target.checked }, + }; + ASRouterUtils.sendMessage(action); + this.setState({ messageFilter: "all" }); + } + + onChangeMessageFilter(event) { + this.setState({ messageFilter: event.target.value }); + } + + onChangeMessageGroupsFilter(event) { + this.setState({ messageGroupsFilter: event.target.value }); + } + + // Simulate a copy event that sets to clipboard all targeting paramters and values + onCopyTargetingParams(event) { + const stringTargetingParameters = { + ...this.state.stringTargetingParameters, + }; + for (const key of Object.keys(stringTargetingParameters)) { + // If the value is not set the parameter will be lost when we stringify + if (stringTargetingParameters[key] === undefined) { + stringTargetingParameters[key] = null; + } + } + const setClipboardData = e => { + e.preventDefault(); + e.clipboardData.setData( + "text", + JSON.stringify(stringTargetingParameters, null, 2) + ); + document.removeEventListener("copy", setClipboardData); + this.setState({ copiedToClipboard: true }); + }; + + document.addEventListener("copy", setClipboardData); + + document.execCommand("copy"); + } + + onNewTargetingParams(event) { + this.setState({ newStringTargetingParameters: event.target.value }); + event.target.classList.remove("errorState"); + this.refs.targetingParamsEval.innerText = ""; + + try { + const stringTargetingParameters = JSON.parse(event.target.value); + this.setState({ stringTargetingParameters }); + } catch (e) { + event.target.classList.add("errorState"); + this.refs.targetingParamsEval.innerText = e.message; + } + } + + toggleJSON(msgId) { + if (this.state.collapsedMessages.includes(msgId)) { + let index = this.state.collapsedMessages.indexOf(msgId); + this.setState(prevState => ({ + collapsedMessages: [ + ...prevState.collapsedMessages.slice(0, index), + ...prevState.collapsedMessages.slice(index + 1), + ], + })); + } else { + this.setState(prevState => ({ + collapsedMessages: prevState.collapsedMessages.concat(msgId), + })); + } + } + + handleChange(msgId) { + if (!this.state.modifiedMessages.includes(msgId)) { + this.setState(prevState => ({ + modifiedMessages: prevState.modifiedMessages.concat(msgId), + })); + } + } + + renderMessageItem(msg) { + const isBlockedByGroup = this.state.groups + .filter(group => msg.groups.includes(group.id)) + .some(group => !group.enabled); + const msgProvider = + this.state.providers.find(provider => provider.id === msg.provider) || {}; + const isProviderExcluded = + msgProvider.exclude && msgProvider.exclude.includes(msg.id); + const isMessageBlocked = + this.state.messageBlockList.includes(msg.id) || + this.state.messageBlockList.includes(msg.campaign); + const isBlocked = + isMessageBlocked || isBlockedByGroup || isProviderExcluded; + const impressions = this.state.messageImpressions[msg.id] + ? this.state.messageImpressions[msg.id].length + : 0; + const isCollapsed = this.state.collapsedMessages.includes(msg.id); + const isModified = this.state.modifiedMessages.includes(msg.id); + + let itemClassName = "message-item"; + if (isBlocked) { + itemClassName += " blocked"; + } + + return ( + + + + {msg.id}
+
+ + + + + + + {// eslint-disable-next-line no-nested-ternary + isBlocked ? null : isModified ? ( + + ) : ( + + )} + {isBlocked ? null : ( + + )} +
({impressions} impressions) + + + {isBlocked && ( + + Block reason: + {isBlockedByGroup && " Blocked by group"} + {isProviderExcluded && " Excluded by provider"} + {isMessageBlocked && " Message blocked"} + + )} + +
+              
+            
+ + + + ); + } + + restoreWNMessageState() { + // check the page for checked boxes, and reset the state of WNMessages based on that. + let tempState = []; + let messageCheckboxes = document.querySelectorAll('input[type="checkbox"]'); + // put the JSON of all the checked checkboxes in the array + for (const checkbox of messageCheckboxes) { + let trimmedId = checkbox.id.replace(" checkbox", ""); + let msg = document.getElementById(`${trimmedId}-textarea`).value; + + if (checkbox.checked) { + tempState.push(msg); + } + } + + this.setState({ + WNMessages: tempState, + }); + } + + modifyJson(content) { + const message = JSON.parse( + document.getElementById(`${content.id}-textarea`).value + ); + return ASRouterUtils.modifyMessageJson(message).then(state => { + this.setStateFromParent(state); + this.props.notifyContent({ + message: state.message, + }); + }); + } + + renderWNMessageItem(msg) { + const isBlocked = + this.state.messageBlockList.includes(msg.id) || + this.state.messageBlockList.includes(msg.campaign); + const impressions = this.state.messageImpressions[msg.id] + ? this.state.messageImpressions[msg.id].length + : 0; + + const isCollapsed = this.state.collapsedMessages.includes(msg.id); + + let itemClassName = "message-item"; + if (isBlocked) { + itemClassName += " blocked"; + } + + return ( + + + + {msg.id}
+
({impressions} impressions) +
+ + + + + + + + +
+            
+          
+ + + ); + } + + toggleAllMessages(messagesToShow) { + if (this.state.collapsedMessages.length) { + this.setState({ + collapsedMessages: [], + }); + } else { + Array.prototype.forEach.call(messagesToShow, msg => { + this.setState(prevState => ({ + collapsedMessages: prevState.collapsedMessages.concat(msg.id), + })); + }); + } + } + + renderMessages() { + if (!this.state.messages) { + return null; + } + const messagesToShow = + this.state.messageFilter === "all" + ? this.state.messages + : this.state.messages.filter( + message => message.provider === this.state.messageFilter + ); + + return ( +
+ +

+ {" "} + + To modify a message, change the JSON and click 'Modify' to see your + changes. Click 'Reset' to restore the JSON to the original. + +

+ + + {messagesToShow.map(msg => this.renderMessageItem(msg))} + +
+
+ ); + } + + renderMessagesByGroup() { + if (!this.state.messages) { + return null; + } + const messagesToShow = + this.state.messageGroupsFilter === "all" + ? this.state.messages.filter(m => m.groups.length) + : this.state.messages.filter(message => + message.groups.includes(this.state.messageGroupsFilter) + ); + + return ( + + {messagesToShow.map(msg => this.renderMessageItem(msg))} +
+ ); + } + + renderWNMessages() { + if (!this.state.messages) { + return null; + } + const messagesToShow = this.state.messages.filter( + message => message.provider === "whats-new-panel" && message.content.body + ); + return ( + + + {messagesToShow.map(msg => this.renderWNMessageItem(msg))} + +
+ ); + } + + renderMessageFilter() { + if (!this.state.providers) { + return null; + } + + return ( +

+ + {/* eslint-disable-next-line prettier/prettier */} + Show messages from {/* eslint-disable-next-line jsx-a11y/no-onchange */} + + {this.state.messageFilter !== "all" && + !this.state.messageFilter.includes("_local_testing") ? ( + + ) : null} +

+ ); + } + + renderMessageGroupsFilter() { + if (!this.state.groups) { + return null; + } + + return ( +

+ Show messages from {/* eslint-disable-next-line jsx-a11y/no-onchange */} + +

+ ); + } + + renderTableHead() { + return ( + + + + Provider ID + Source + Cohort + Last Updated + + + ); + } + + renderProviders() { + const providersConfig = this.state.providerPrefs; + const providerInfo = this.state.providers; + const userPrefInfo = this.state.userPrefs; + + return ( + + {this.renderTableHead()} + + {providersConfig.map((provider, i) => { + const isTestProvider = provider.id.includes("_local_testing"); + const info = providerInfo.find(p => p.id === provider.id) || {}; + const isUserEnabled = + provider.id in userPrefInfo ? userPrefInfo[provider.id] : true; + const isSystemEnabled = isTestProvider || provider.enabled; + + let label = "local"; + if (provider.type === "remote") { + label = ( + + endpoint ( + + {info.url} + + ) + + ); + } else if (provider.type === "remote-settings") { + label = `remote settings (${provider.bucket})`; + } else if (provider.type === "remote-experiments") { + label = ( + + remote settings ( + + nimbus-desktop-experiments + + ) + + ); + } + + let reasonsDisabled = []; + if (!isSystemEnabled) { + reasonsDisabled.push("system pref"); + } + if (!isUserEnabled) { + reasonsDisabled.push("user pref"); + } + if (reasonsDisabled.length) { + label = `disabled via ${reasonsDisabled.join(", ")}`; + } + + return ( + + + + + + + + ); + })} + +
+ {isTestProvider ? ( + + ) : ( + + )} + {provider.id} + + {label} + + {provider.cohort} + {info.lastUpdated + ? new Date(info.lastUpdated).toLocaleString() + : ""} +
+ ); + } + + renderTargetingParameters() { + // There was no error and the result is truthy + const success = + this.state.evaluationStatus.success && + !!this.state.evaluationStatus.result; + const result = + JSON.stringify(this.state.evaluationStatus.result, null, 2) || + "(Empty result)"; + + return ( + + + + + + +
+

Evaluate JEXL expression

+
+

+