Repository: mozilla/activity-stream Branch: master Commit: 15181967965c Files: 426 Total size: 4.1 MB Directory structure: gitextract_p813z3cw/ ├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .mcignore ├── .nvmrc ├── .prettierrc ├── .sass-lint.yml ├── .taskcluster.yml ├── .travis.yml ├── AboutNewTabService.jsm ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── aboutlibrary/ │ ├── content/ │ │ └── aboutlibrary.xhtml │ ├── jar.mn │ └── moz.build ├── bin/ │ ├── bootstrap │ ├── download-firefox-artifact │ ├── prepare-mochitests-dev │ ├── render-activity-stream-html.js │ ├── try-runner.js │ └── vendor.js ├── common/ │ ├── Actions.jsm │ ├── Dedupe.jsm │ ├── PerfService.jsm │ └── Reducers.jsm ├── components.conf ├── content-src/ │ ├── .eslintrc.js │ ├── aboutlibrary/ │ │ ├── aboutlibrary.jsx │ │ └── aboutlibrary.scss │ ├── activity-stream.jsx │ ├── asrouter/ │ │ ├── README.md │ │ ├── asrouter-content.jsx │ │ ├── components/ │ │ │ ├── Button/ │ │ │ │ ├── Button.jsx │ │ │ │ └── _Button.scss │ │ │ ├── ConditionalWrapper/ │ │ │ │ └── ConditionalWrapper.jsx │ │ │ ├── FxASignupForm/ │ │ │ │ ├── FxASignupForm.jsx │ │ │ │ └── _FxASignupForm.scss │ │ │ ├── ImpressionsWrapper/ │ │ │ │ └── ImpressionsWrapper.jsx │ │ │ ├── ModalOverlay/ │ │ │ │ ├── ModalOverlay.jsx │ │ │ │ └── _ModalOverlay.scss │ │ │ ├── RichText/ │ │ │ │ └── RichText.jsx │ │ │ └── SnippetBase/ │ │ │ ├── SnippetBase.jsx │ │ │ └── _SnippetBase.scss │ │ ├── docs/ │ │ │ ├── debugging-docs.md │ │ │ ├── experiment-guide.md │ │ │ ├── targeting-attributes.md │ │ │ ├── targeting-guide.md │ │ │ └── user-actions.md │ │ ├── rich-text-strings.js │ │ ├── schemas/ │ │ │ ├── message-format.md │ │ │ ├── panel/ │ │ │ │ └── cfr-fxa-bookmark.schema.json │ │ │ └── provider-response.schema.json │ │ ├── template-utils.js │ │ └── templates/ │ │ ├── CFR/ │ │ │ └── templates/ │ │ │ └── ExtensionDoorhanger.schema.json │ │ ├── EOYSnippet/ │ │ │ ├── EOYSnippet.jsx │ │ │ ├── EOYSnippet.schema.json │ │ │ └── _EOYSnippet.scss │ │ ├── FXASignupSnippet/ │ │ │ ├── FXASignupSnippet.jsx │ │ │ └── FXASignupSnippet.schema.json │ │ ├── FirstRun/ │ │ │ ├── FirstRun.jsx │ │ │ ├── Interrupt.jsx │ │ │ ├── Triplets.jsx │ │ │ └── addUtmParams.js │ │ ├── FullPageInterrupt/ │ │ │ ├── FullPageInterrupt.jsx │ │ │ └── _FullPageInterrupt.scss │ │ ├── NewsletterSnippet/ │ │ │ ├── NewsletterSnippet.jsx │ │ │ └── NewsletterSnippet.schema.json │ │ ├── OnboardingMessage/ │ │ │ ├── OnboardingMessage.jsx │ │ │ ├── OnboardingMessage.schema.json │ │ │ ├── ToolbarBadgeMessage.schema.json │ │ │ ├── UpdateAction.schema.json │ │ │ ├── WhatsNewMessage.schema.json │ │ │ └── _OnboardingMessage.scss │ │ ├── ReturnToAMO/ │ │ │ ├── ReturnToAMO.jsx │ │ │ └── _ReturnToAMO.scss │ │ ├── SendToDeviceSnippet/ │ │ │ ├── SendToDeviceSnippet.jsx │ │ │ ├── SendToDeviceSnippet.schema.json │ │ │ └── isEmailOrPhoneNumber.js │ │ ├── SimpleBelowSearchSnippet/ │ │ │ ├── SimpleBelowSearchSnippet.jsx │ │ │ ├── SimpleBelowSearchSnippet.schema.json │ │ │ └── _SimpleBelowSearchSnippet.scss │ │ ├── SimpleSnippet/ │ │ │ ├── SimpleSnippet.jsx │ │ │ ├── SimpleSnippet.schema.json │ │ │ └── _SimpleSnippet.scss │ │ ├── SubmitFormSnippet/ │ │ │ ├── SubmitFormSnippet.jsx │ │ │ ├── SubmitFormSnippet.schema.json │ │ │ └── _SubmitFormSnippet.scss │ │ ├── Trailhead/ │ │ │ ├── Trailhead.jsx │ │ │ └── _Trailhead.scss │ │ └── template-manifest.jsx │ ├── components/ │ │ ├── A11yLinkButton/ │ │ │ ├── A11yLinkButton.jsx │ │ │ └── _A11yLinkButton.scss │ │ ├── ASRouterAdmin/ │ │ │ ├── ASRouterAdmin.jsx │ │ │ ├── ASRouterAdmin.scss │ │ │ └── SimpleHashRouter.jsx │ │ ├── Base/ │ │ │ ├── Base.jsx │ │ │ └── _Base.scss │ │ ├── Card/ │ │ │ ├── Card.jsx │ │ │ ├── _Card.scss │ │ │ └── types.js │ │ ├── CollapsibleSection/ │ │ │ ├── CollapsibleSection.jsx │ │ │ └── _CollapsibleSection.scss │ │ ├── ComponentPerfTimer/ │ │ │ └── ComponentPerfTimer.jsx │ │ ├── ConfirmDialog/ │ │ │ ├── ConfirmDialog.jsx │ │ │ └── _ConfirmDialog.scss │ │ ├── ContextMenu/ │ │ │ ├── ContextMenu.jsx │ │ │ ├── ContextMenuButton.jsx │ │ │ └── _ContextMenu.scss │ │ ├── DiscoveryStreamBase/ │ │ │ ├── DiscoveryStreamBase.jsx │ │ │ └── _DiscoveryStreamBase.scss │ │ ├── DiscoveryStreamComponents/ │ │ │ ├── CardGrid/ │ │ │ │ ├── CardGrid.jsx │ │ │ │ └── _CardGrid.scss │ │ │ ├── DSCard/ │ │ │ │ ├── DSCard.jsx │ │ │ │ └── _DSCard.scss │ │ │ ├── DSContextFooter/ │ │ │ │ ├── DSContextFooter.jsx │ │ │ │ └── _DSContextFooter.scss │ │ │ ├── DSDismiss/ │ │ │ │ ├── DSDismiss.jsx │ │ │ │ └── _DSDismiss.scss │ │ │ ├── DSEmptyState/ │ │ │ │ ├── DSEmptyState.jsx │ │ │ │ └── _DSEmptyState.scss │ │ │ ├── DSImage/ │ │ │ │ ├── DSImage.jsx │ │ │ │ └── _DSImage.scss │ │ │ ├── DSLinkMenu/ │ │ │ │ ├── DSLinkMenu.jsx │ │ │ │ └── _DSLinkMenu.scss │ │ │ ├── DSMessage/ │ │ │ │ ├── DSMessage.jsx │ │ │ │ └── _DSMessage.scss │ │ │ ├── DSPrivacyModal/ │ │ │ │ ├── DSPrivacyModal.jsx │ │ │ │ └── _DSPrivacyModal.scss │ │ │ ├── DSTextPromo/ │ │ │ │ ├── DSTextPromo.jsx │ │ │ │ └── _DSTextPromo.scss │ │ │ ├── Hero/ │ │ │ │ ├── Hero.jsx │ │ │ │ └── _Hero.scss │ │ │ ├── Highlights/ │ │ │ │ ├── Highlights.jsx │ │ │ │ └── _Highlights.scss │ │ │ ├── HorizontalRule/ │ │ │ │ ├── HorizontalRule.jsx │ │ │ │ └── _HorizontalRule.scss │ │ │ ├── List/ │ │ │ │ ├── List.jsx │ │ │ │ └── _List.scss │ │ │ ├── Navigation/ │ │ │ │ ├── Navigation.jsx │ │ │ │ └── _Navigation.scss │ │ │ ├── SafeAnchor/ │ │ │ │ └── SafeAnchor.jsx │ │ │ ├── SectionTitle/ │ │ │ │ ├── SectionTitle.jsx │ │ │ │ └── _SectionTitle.scss │ │ │ └── TopSites/ │ │ │ ├── TopSites.jsx │ │ │ └── _TopSites.scss │ │ ├── DiscoveryStreamImpressionStats/ │ │ │ ├── ImpressionStats.jsx │ │ │ └── _ImpressionStats.scss │ │ ├── ErrorBoundary/ │ │ │ ├── ErrorBoundary.jsx │ │ │ └── _ErrorBoundary.scss │ │ ├── FluentOrText/ │ │ │ └── FluentOrText.jsx │ │ ├── LinkMenu/ │ │ │ └── LinkMenu.jsx │ │ ├── MoreRecommendations/ │ │ │ ├── MoreRecommendations.jsx │ │ │ └── _MoreRecommendations.scss │ │ ├── PocketLoggedInCta/ │ │ │ ├── PocketLoggedInCta.jsx │ │ │ └── _PocketLoggedInCta.scss │ │ ├── Search/ │ │ │ ├── Search.jsx │ │ │ └── _Search.scss │ │ ├── SectionMenu/ │ │ │ └── SectionMenu.jsx │ │ ├── Sections/ │ │ │ ├── Sections.jsx │ │ │ └── _Sections.scss │ │ ├── TopSites/ │ │ │ ├── SearchShortcutsForm.jsx │ │ │ ├── TopSite.jsx │ │ │ ├── TopSiteForm.jsx │ │ │ ├── TopSiteFormInput.jsx │ │ │ ├── TopSites.jsx │ │ │ ├── TopSitesConstants.js │ │ │ └── _TopSites.scss │ │ └── Topics/ │ │ ├── Topics.jsx │ │ └── _Topics.scss │ ├── lib/ │ │ ├── constants.js │ │ ├── detect-user-session-start.js │ │ ├── init-store.js │ │ ├── link-menu-options.js │ │ ├── screenshot-utils.js │ │ ├── section-menu-options.js │ │ └── selectLayoutRender.js │ └── styles/ │ ├── _activity-stream.scss │ ├── _icons.scss │ ├── _mixins.scss │ ├── _normalize.scss │ ├── _theme.scss │ ├── _variables.scss │ ├── activity-stream-linux.scss │ ├── activity-stream-mac.scss │ └── activity-stream-windows.scss ├── contributing.md ├── data/ │ └── content/ │ └── tippytop/ │ └── top_sites.json ├── docs/ │ ├── ISSUE_TEMPLATE.md │ ├── index.rst │ └── v2-system-addon/ │ ├── 1.GETTING_STARTED.md │ ├── data_dictionary.md │ ├── data_events.md │ ├── geo_locale.md │ ├── mochitests.md │ ├── preferences.md │ ├── remote_cfr.md │ ├── sections.md │ ├── telemetry.md │ ├── test-merges.md │ ├── tippytop.md │ └── unit_testing_guide.md ├── hooks/ │ ├── post-commit │ └── pre-commit ├── jar.mn ├── karma.mc.config.js ├── lib/ │ ├── ASRouter.jsm │ ├── ASRouterFeed.jsm │ ├── ASRouterPreferences.jsm │ ├── ASRouterTargeting.jsm │ ├── ASRouterTriggerListeners.jsm │ ├── AboutPreferences.jsm │ ├── ActivityStream.jsm │ ├── ActivityStreamMessageChannel.jsm │ ├── ActivityStreamPrefs.jsm │ ├── ActivityStreamStorage.jsm │ ├── BookmarkPanelHub.jsm │ ├── CFRMessageProvider.jsm │ ├── CFRPageActions.jsm │ ├── DiscoveryStreamFeed.jsm │ ├── DownloadsManager.jsm │ ├── FaviconFeed.jsm │ ├── FilterAdult.jsm │ ├── HighlightsFeed.jsm │ ├── LinksCache.jsm │ ├── NaiveBayesTextTagger.jsm │ ├── NewTabInit.jsm │ ├── NmfTextTagger.jsm │ ├── OnboardingMessageProvider.jsm │ ├── PanelTestProvider.jsm │ ├── PersistentCache.jsm │ ├── PersonalityProvider.jsm │ ├── PlacesFeed.jsm │ ├── PrefsFeed.jsm │ ├── RecipeExecutor.jsm │ ├── RemoteL10n.jsm │ ├── Screenshots.jsm │ ├── SearchShortcuts.jsm │ ├── SectionsManager.jsm │ ├── ShortURL.jsm │ ├── SiteClassifier.jsm │ ├── SnippetsTestMessageProvider.jsm │ ├── Store.jsm │ ├── SystemTickFeed.jsm │ ├── TelemetryFeed.jsm │ ├── TippyTopProvider.jsm │ ├── Tokenize.jsm │ ├── ToolbarBadgeHub.jsm │ ├── ToolbarPanelHub.jsm │ ├── TopSitesFeed.jsm │ ├── TopStoriesFeed.jsm │ ├── UTEventReporting.jsm │ └── UserDomainAffinityProvider.jsm ├── loaders/ │ └── inject-loader.js ├── mochitest.sh ├── moz.build ├── nsIAboutNewTabService.idl ├── package.json ├── ping-centre/ │ └── PingCentre.jsm ├── test/ │ ├── .eslintrc.js │ ├── browser/ │ │ ├── blue_page.html │ │ ├── browser.ini │ │ ├── browser_aboutwelcome.js │ │ ├── browser_as_load_location.js │ │ ├── browser_as_render.js │ │ ├── browser_asrouter_bookmarkpanel.js │ │ ├── browser_asrouter_cfr.js │ │ ├── browser_asrouter_snippets.js │ │ ├── browser_asrouter_targeting.js │ │ ├── browser_asrouter_toolbarbadge.js │ │ ├── browser_asrouter_trigger_listeners.js │ │ ├── browser_asrouter_whatsnewpanel.js │ │ ├── browser_discovery_render.js │ │ ├── browser_discovery_styles.js │ │ ├── browser_enabled_newtabpage.js │ │ ├── browser_getScreenshots.js │ │ ├── browser_highlights_section.js │ │ ├── browser_newtab_overrides.js │ │ ├── browser_onboarding_rtamo.js │ │ ├── browser_topsites_contextMenu_options.js │ │ ├── browser_topsites_section.js │ │ ├── head.js │ │ └── red_page.html │ ├── schemas/ │ │ └── pings.js │ ├── unit/ │ │ ├── asrouter/ │ │ │ ├── ASRouter.test.js │ │ │ ├── ASRouterFeed.test.js │ │ │ ├── ASRouterPreferences.test.js │ │ │ ├── ASRouterTargeting.test.js │ │ │ ├── ASRouterTriggerListeners.test.js │ │ │ ├── CFRMessageProvider.test.js │ │ │ ├── CFRPageActions.test.js │ │ │ ├── MessageLoaderUtils.test.js │ │ │ ├── ModalOverlay.test.jsx │ │ │ ├── PanelTestProvider.test.js │ │ │ ├── RemoteL10n.test.js │ │ │ ├── RichText.test.jsx │ │ │ ├── SnippetsTestMessageProvider.test.js │ │ │ ├── TargetingDocs.test.js │ │ │ ├── asrouter-content.test.jsx │ │ │ ├── compatibility-reference/ │ │ │ │ ├── fx57-compat.test.js │ │ │ │ └── snippets-fx57.js │ │ │ ├── constants.js │ │ │ ├── schemas/ │ │ │ │ └── panel/ │ │ │ │ └── cfr-fxa-bookmark.schema.test.js │ │ │ ├── template-utils.test.js │ │ │ └── templates/ │ │ │ ├── EOYSnippet.test.jsx │ │ │ ├── ExtensionDoorhanger.test.jsx │ │ │ ├── FXASignupSnippet.test.jsx │ │ │ ├── FirstRun.test.jsx │ │ │ ├── FullPageInterrupt.test.jsx │ │ │ ├── FxASignupForm.test.jsx │ │ │ ├── Interrupt.test.jsx │ │ │ ├── NewsletterSnippet.test.jsx │ │ │ ├── OnboardingMessage.test.jsx │ │ │ ├── SendToDeviceSnippet.test.jsx │ │ │ ├── SimpleBelowSearchSnippet.test.jsx │ │ │ ├── SimpleSnippet.test.jsx │ │ │ ├── SubmitFormSnippet.test.jsx │ │ │ ├── Trailhead.test.jsx │ │ │ ├── Triplets.test.jsx │ │ │ └── isEmailOrPhoneNumber.test.js │ │ ├── common/ │ │ │ ├── Actions.test.js │ │ │ ├── Dedupe.test.js │ │ │ ├── PerfService.test.js │ │ │ └── Reducers.test.js │ │ ├── content-src/ │ │ │ ├── components/ │ │ │ │ ├── ASRouterAdmin.test.jsx │ │ │ │ ├── Base.test.jsx │ │ │ │ ├── Card.test.jsx │ │ │ │ ├── CollapsibleSection.test.jsx │ │ │ │ ├── ComponentPerfTimer.test.jsx │ │ │ │ ├── ConfirmDialog.test.jsx │ │ │ │ ├── ContextMenu.test.jsx │ │ │ │ ├── DiscoveryStreamBase.test.jsx │ │ │ │ ├── DiscoveryStreamComponents/ │ │ │ │ │ ├── CardGrid.test.jsx │ │ │ │ │ ├── DSCard.test.jsx │ │ │ │ │ ├── DSContextFooter.test.jsx │ │ │ │ │ ├── DSDismiss.test.jsx │ │ │ │ │ ├── DSEmptyState.test.jsx │ │ │ │ │ ├── DSImage.test.jsx │ │ │ │ │ ├── DSLinkMenu.test.jsx │ │ │ │ │ ├── DSMessage.test.jsx │ │ │ │ │ ├── DSPrivacyModal.test.jsx │ │ │ │ │ ├── DSTextPromo.test.jsx │ │ │ │ │ ├── Hero.test.jsx │ │ │ │ │ ├── Highlights.test.jsx │ │ │ │ │ ├── HorizontalRule.test.jsx │ │ │ │ │ ├── ImpressionStats.test.jsx │ │ │ │ │ ├── List.test.jsx │ │ │ │ │ ├── Navigation.test.jsx │ │ │ │ │ ├── SafeAnchor.test.jsx │ │ │ │ │ ├── SectionTitle.test.jsx │ │ │ │ │ └── TopSites.test.jsx │ │ │ │ ├── ErrorBoundary.test.jsx │ │ │ │ ├── FluentOrText.test.jsx │ │ │ │ ├── LinkMenu.test.jsx │ │ │ │ ├── MoreRecommendations.test.jsx │ │ │ │ ├── PocketLoggedInCta.test.jsx │ │ │ │ ├── ReturnToAMO.test.jsx │ │ │ │ ├── Search.test.jsx │ │ │ │ ├── SectionMenu.test.jsx │ │ │ │ ├── Sections.test.jsx │ │ │ │ ├── TopSites/ │ │ │ │ │ └── SearchShortcutsForm.test.jsx │ │ │ │ ├── TopSites.test.jsx │ │ │ │ ├── Topics.test.jsx │ │ │ │ └── addUtmParams.test.js │ │ │ └── lib/ │ │ │ ├── detect-user-session-start.test.js │ │ │ ├── init-store.test.js │ │ │ ├── screenshot-utils.test.js │ │ │ └── selectLayoutRender.test.js │ │ ├── lib/ │ │ │ ├── AboutPreferences.test.js │ │ │ ├── ActivityStream.test.js │ │ │ ├── ActivityStreamMessageChannel.test.js │ │ │ ├── ActivityStreamPrefs.test.js │ │ │ ├── ActivityStreamStorage.test.js │ │ │ ├── BookmarkPanelHub.test.js │ │ │ ├── DiscoveryStreamFeed.test.js │ │ │ ├── DownloadsManager.test.js │ │ │ ├── FaviconFeed.test.js │ │ │ ├── FilterAdult.test.js │ │ │ ├── HighlightsFeed.test.js │ │ │ ├── LinksCache.test.js │ │ │ ├── NaiveBayesTextTagger.test.js │ │ │ ├── NewTabInit.test.js │ │ │ ├── NmfTextTagger.test.js │ │ │ ├── PersistentCache.test.js │ │ │ ├── PersonalityProvider.test.js │ │ │ ├── PlacesFeed.test.js │ │ │ ├── PrefsFeed.test.js │ │ │ ├── RecipeExecutor.test.js │ │ │ ├── Screenshots.test.js │ │ │ ├── SectionsManager.test.js │ │ │ ├── ShortUrl.test.js │ │ │ ├── SiteClassifier.test.js │ │ │ ├── Store.test.js │ │ │ ├── SystemTickFeed.test.js │ │ │ ├── TelemetryFeed.test.js │ │ │ ├── TippyTopProvider.test.js │ │ │ ├── Tokenize.test.js │ │ │ ├── ToolbarBadgeHub.test.js │ │ │ ├── ToolbarPanelHub.test.js │ │ │ ├── TopSitesFeed.test.js │ │ │ ├── TopStoriesFeed.test.js │ │ │ ├── UTEventReporting.test.js │ │ │ └── UserDomainAffinityProvider.test.js │ │ ├── ping-centre/ │ │ │ └── PingCentre.test.js │ │ ├── unit-entry.js │ │ └── utils.js │ └── xpcshell/ │ ├── test_ASRouterTargeting_attribution.js │ ├── test_AboutNewTabService.js │ └── xpcshell.ini ├── vendor/ │ ├── PROP_TYPES_LICENSE │ ├── REACT_AND_REACT_DOM_LICENSE │ ├── REACT_REDUX_LICENSE │ ├── REACT_TRANSITION_GROUP_LICENSE │ ├── REDUX_LICENSE │ ├── Redux.jsm │ ├── prop-types.js │ ├── react-dev.js │ ├── react-dom-dev.js │ ├── react-dom.js │ ├── react-redux.js │ ├── react-transition-group.js │ ├── react.js │ └── redux.js ├── webpack.aboutlibrary.config.js ├── webpack.system-addon.config.js └── yamscripts.yml ================================================ FILE CONTENTS ================================================ ================================================ FILE: .eslintignore ================================================ data/ logs/ vendor/ ================================================ FILE: .eslintrc.js ================================================ module.exports = { // When adding items to this file please check for effects on sub-directories. "parser": "babel-eslint", "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") // Temporarily disabled since they aren't vendored into in mozilla central yet // "react-hooks", // require("react-hooks") ], "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") ], "globals": { // Remove this when m-c updates their eslint: See https://github.com/mozilla/activity-stream/pull/4219 "RPMSendAsyncMessage": true, "NewTabPagePreloading": true, }, "overrides": [ { // These files use fluent-dom to insert content "files": [ "content-src/asrouter/templates/OnboardingMessage/**", "content-src/asrouter/templates/FirstRun/**", "content-src/asrouter/templates/Trailhead/**", "content-src/asrouter/templates/FullPageInterrupt/FullPageInterrupt.jsx", "content-src/asrouter/components/FxASignupForm/FxASignupForm.jsx", "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" ], "rules": { "jsx-a11y/anchor-has-content": 0, "jsx-a11y/heading-has-content": 0, } }, { // Use a configuration that's more appropriate for JSMs "files": "**/*.jsm", "parserOptions": { "sourceType": "script" }, "env": { "node": false }, "rules": { "no-implicit-globals": 0 } } ], "rules": { // "react-hooks/rules-of-hooks": 2, "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"] } }; ================================================ FILE: .gitignore ================================================ node_modules npm-debug.log .DS_Store .eslintcache *.sw[po] *.xpi *.pyc logs/ dist/ firefox/ *.update.rdf data/content/activity-stream.bundle.js css/*.css prerendered/ aboutlibrary/content/aboutlibrary.bundle.js aboutlibrary/content/*.map aboutlibrary/content/*.css ================================================ FILE: .mcignore ================================================ npm-debug.log .DS_Store *.sw[po] *.xpi *.pyc *.update.rdf .gitignore .eslintcache /.git/ /dist/ /logs/ /node_modules/ # ignore README since it's GitHub specific /README.md # also ignores ping centre tests ping-centre/ # ignore things from about:library for now aboutlibrary/ content-src/aboutlibrary/ ================================================ FILE: .nvmrc ================================================ 8.16 ================================================ FILE: .prettierrc ================================================ { "printWidth": 80, "tabWidth": 2, "trailingComma": "es5" } ================================================ FILE: .sass-lint.yml ================================================ 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 ================================================ FILE: .taskcluster.yml ================================================ version: 1 policy: pullRequests: public tasks: $if: 'tasks_for in ["github-push", "github-pull-request"]' then: $let: repo_url: $if: 'tasks_for == "github-push"' then: ${event.repository.clone_url} else: ${event.pull_request.head.repo.clone_url} ref: $if: 'tasks_for == "github-push"' then: ${event.after} else: ${event.pull_request.head.sha} in: - provisionerId: proj-misc workerType: ci deadline: ${fromNow('1 day')} payload: maxRunTime: 7200 image: piatra/asmochitests command: - /bin/bash - '--login' - '-c' - >- git clone ${repo_url} /activity-stream && cd /activity-stream && git checkout ${ref} && bash ./mochitest.sh metadata: name: activitystream description: run mochitests for PRs owner: noreply@mozilla.com source: ${repo_url} ================================================ FILE: .travis.yml ================================================ language: node_js node_js: # when changing this, be sure to edit .nvrmc and package.json too - 8 python: - "2.7" addons: # Run unit tests in Nightly to be in line with what Firefox tests would run against firefox: "latest-nightly" cache: directories: - node_modules before_install: # see https://docs.travis-ci.com/user/gui-and-headless-browsers/#Using-xvfb-to-Run-Tests-That-Require-a-GUI - "export DISPLAY=:99.0" - "/sbin/start-stop-daemon --start --quiet --pidfile /tmp/custom_xvfb_99.pid --make-pidfile --background --exec /usr/bin/Xvfb -- :99 -ac -screen 0 1280x1024x16 -extension RANDR" - export PATH="$PATH:$HOME/.rvm/bin" - export PATH="$PATH:./node_modules/.bin" - sleep 3 install: - npm config set spin false - npm install script: - npm test notifications: email: false ================================================ FILE: AboutNewTabService.jsm ================================================ /* * 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 { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); const { AppConstants } = ChromeUtils.import( "resource://gre/modules/AppConstants.jsm" ); const { E10SUtils } = ChromeUtils.import( "resource://gre/modules/E10SUtils.jsm" ); ChromeUtils.defineModuleGetter( this, "AboutNewTab", "resource:///modules/AboutNewTab.jsm" ); const TOPIC_APP_QUIT = "quit-application-granted"; const TOPIC_CONTENT_DOCUMENT_INTERACTIVE = "content-document-interactive"; const ABOUT_URL = "about:newtab"; const BASE_URL = "resource://activity-stream/"; const ACTIVITY_STREAM_PAGES = new Set(["home", "newtab", "welcome"]); const IS_MAIN_PROCESS = Services.appinfo.processType === Services.appinfo.PROCESS_TYPE_DEFAULT; const IS_PRIVILEGED_PROCESS = Services.appinfo.remoteType === E10SUtils.PRIVILEGEDABOUT_REMOTE_TYPE; const IS_RELEASE_OR_BETA = AppConstants.RELEASE_OR_BETA; const PREF_SEPARATE_PRIVILEGEDABOUT_CONTENT_PROCESS = "browser.tabs.remote.separatePrivilegedContentProcess"; const PREF_ACTIVITY_STREAM_DEBUG = "browser.newtabpage.activity-stream.debug"; function AboutNewTabService() { Services.obs.addObserver(this, TOPIC_APP_QUIT); Services.prefs.addObserver( PREF_SEPARATE_PRIVILEGEDABOUT_CONTENT_PROCESS, this ); if (!IS_RELEASE_OR_BETA) { Services.prefs.addObserver(PREF_ACTIVITY_STREAM_DEBUG, this); } // More initialization happens here this.toggleActivityStream(true); this.initialized = true; this.alreadyRecordedTopsitesPainted = false; if (IS_MAIN_PROCESS) { AboutNewTab.init(); } else if (IS_PRIVILEGED_PROCESS) { Services.obs.addObserver(this, TOPIC_CONTENT_DOCUMENT_INTERACTIVE); } } /* * A service that allows for the overriding, at runtime, of the newtab page's url. * * There is tight coupling with browser/about/AboutRedirector.cpp. * * 1. Browser chrome access: * * When the user issues a command to open a new tab page, usually clicking a button * in the browser chrome or using shortcut keys, the browser chrome code invokes the * service to obtain the newtab URL. It then loads that URL in a new tab. * * When not overridden, the default URL emitted by the service is "about:newtab". * When overridden, it returns the overriden URL. * * 2. Redirector Access: * * When the URL loaded is about:newtab, the default behavior, or when entered in the * URL bar, the redirector is hit. The service is then called to return the * appropriate activity stream url based on prefs. * * NOTE: "about:newtab" will always result in a default newtab page, and never an overridden URL. * * Access patterns: * * The behavior is different when accessing the service via browser chrome or via redirector * largely to maintain compatibility with expectations of add-on developers. * * Loading a chrome resource, or an about: URL in the redirector with either the * LOAD_NORMAL or LOAD_REPLACE flags yield unexpected behaviors, so a roundtrip * to the redirector from browser chrome is avoided. */ AboutNewTabService.prototype = { _newTabURL: ABOUT_URL, _activityStreamEnabled: false, _activityStreamDebug: false, _privilegedAboutContentProcess: false, _overridden: false, willNotifyUser: false, classID: Components.ID("{dfcd2adc-7867-4d3a-ba70-17501f208142}"), QueryInterface: ChromeUtils.generateQI([ Ci.nsIAboutNewTabService, Ci.nsIObserver, ]), observe(subject, topic, data) { switch (topic) { case "nsPref:changed": if (data === PREF_SEPARATE_PRIVILEGEDABOUT_CONTENT_PROCESS) { this._privilegedAboutContentProcess = Services.prefs.getBoolPref( PREF_SEPARATE_PRIVILEGEDABOUT_CONTENT_PROCESS ); this.notifyChange(); } else if (!IS_RELEASE_OR_BETA && data === PREF_ACTIVITY_STREAM_DEBUG) { this._activityStreamDebug = Services.prefs.getBoolPref( PREF_ACTIVITY_STREAM_DEBUG, false ); this.notifyChange(); } break; case TOPIC_CONTENT_DOCUMENT_INTERACTIVE: { const win = subject.defaultView; // It seems like "content-document-interactive" is triggered multiple // times for a single window. The first event always seems to be an // HTMLDocument object that contains a non-null window reference // whereas the remaining ones seem to be proxied objects. // https://searchfox.org/mozilla-central/rev/d2966246905102b36ef5221b0e3cbccf7ea15a86/devtools/server/actors/object.js#100-102 if (win === null) { break; } // We use win.location.pathname instead of win.location.toString() // because we want to account for URLs that contain the location hash // property or query strings (e.g. about:newtab#foo, about:home?bar). // Asserting here would be ideal, but this code path is also taken // by the view-source:// scheme, so we should probably just bail out // and do nothing. if (!ACTIVITY_STREAM_PAGES.has(win.location.pathname)) { break; } const onLoaded = () => { const debugString = this._activityStreamDebug ? "-dev" : ""; // This list must match any similar ones in render-activity-stream-html.js. const scripts = [ "chrome://browser/content/contentSearchUI.js", "chrome://browser/content/contentTheme.js", `${BASE_URL}vendor/react${debugString}.js`, `${BASE_URL}vendor/react-dom${debugString}.js`, `${BASE_URL}vendor/prop-types.js`, `${BASE_URL}vendor/react-transition-group.js`, `${BASE_URL}vendor/redux.js`, `${BASE_URL}vendor/react-redux.js`, `${BASE_URL}data/content/activity-stream.bundle.js`, ]; for (let script of scripts) { Services.scriptloader.loadSubScript(script, win); // Synchronous call } }; subject.addEventListener("DOMContentLoaded", onLoaded, { once: true }); // There is a possibility that DOMContentLoaded won't be fired. This // unload event (which cannot be cancelled) will attempt to remove // the listener for the DOMContentLoaded event. const onUnloaded = () => { subject.removeEventListener("DOMContentLoaded", onLoaded); }; subject.addEventListener("unload", onUnloaded, { once: true }); break; } case TOPIC_APP_QUIT: this.uninit(); if (IS_MAIN_PROCESS) { AboutNewTab.uninit(); } else if (IS_PRIVILEGED_PROCESS) { Services.obs.removeObserver(this, TOPIC_CONTENT_DOCUMENT_INTERACTIVE); } break; } }, notifyChange() { Services.obs.notifyObservers(null, "newtab-url-changed", this._newTabURL); }, /** * React to changes to the activity stream being enabled or not. * * This will only act if there is a change of state and if not overridden. * * @returns {Boolean} Returns if there has been a state change * * @param {Boolean} stateEnabled activity stream enabled state to set to * @param {Boolean} forceState force state change */ toggleActivityStream(stateEnabled, forceState = false) { if ( !forceState && (this.overridden || stateEnabled === this.activityStreamEnabled) ) { // exit there is no change of state return false; } if (stateEnabled) { this._activityStreamEnabled = true; } else { this._activityStreamEnabled = false; } this._privilegedAboutContentProcess = Services.prefs.getBoolPref( PREF_SEPARATE_PRIVILEGEDABOUT_CONTENT_PROCESS ); if (!IS_RELEASE_OR_BETA) { this._activityStreamDebug = Services.prefs.getBoolPref( PREF_ACTIVITY_STREAM_DEBUG, false ); } this._newtabURL = ABOUT_URL; return true; }, /* * 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._privilegedAboutContentProcess ? "-debug" : "", this._privilegedAboutContentProcess ? "-noscripts" : "", ".html", ].join(""); }, /* * Returns the about:welcome URL * * This is calculated in the same way the default URL is. */ get welcomeURL() { return this.defaultURL; }, get newTabURL() { return this._newTabURL; }, set newTabURL(aNewTabURL) { let newTabURL = aNewTabURL.trim(); if (newTabURL === ABOUT_URL) { // avoid infinite redirects in case one sets the URL to about:newtab this.resetNewTabURL(); return; } else if (newTabURL === "") { newTabURL = "about:blank"; } this.toggleActivityStream(false); this._newTabURL = newTabURL; this._overridden = true; this.notifyChange(); }, get overridden() { return this._overridden; }, get activityStreamEnabled() { return this._activityStreamEnabled; }, get activityStreamDebug() { return this._activityStreamDebug; }, resetNewTabURL() { this._overridden = false; this._newTabURL = ABOUT_URL; this.toggleActivityStream(true, true); this.notifyChange(); }, maybeRecordTopsitesPainted(timestamp) { if (this.alreadyRecordedTopsitesPainted) { return; } const SCALAR_KEY = "timestamps.about_home_topsites_first_paint"; let startupInfo = Services.startup.getStartupInfo(); let processStartTs = startupInfo.process.getTime(); let delta = Math.round(timestamp - processStartTs); Services.telemetry.scalarSet(SCALAR_KEY, delta); this.alreadyRecordedTopsitesPainted = true; }, uninit() { if (!this.initialized) { return; } Services.obs.removeObserver(this, TOPIC_APP_QUIT); Services.prefs.removeObserver( PREF_SEPARATE_PRIVILEGEDABOUT_CONTENT_PROCESS, this ); if (!IS_RELEASE_OR_BETA) { Services.prefs.removeObserver(PREF_ACTIVITY_STREAM_DEBUG, this); } this.initialized = false; }, }; const EXPORTED_SYMBOLS = ["AboutNewTabService"]; ================================================ FILE: CODE_OF_CONDUCT.md ================================================ # Community Participation Guidelines This repository is governed by Mozilla's code of conduct and etiquette guidelines. For more details, please read the [Mozilla Community Participation Guidelines](https://www.mozilla.org/about/governance/policies/participation/). ## How to Report For more information on how to report violations of the Community Participation Guidelines, please read our '[How to Report](https://www.mozilla.org/about/governance/policies/participation/reporting/)' page. ================================================ FILE: LICENSE ================================================ Mozilla Public License Version 2.0 ================================== 1. Definitions -------------- 1.1. "Contributor" means each individual or legal entity that creates, contributes to the creation of, or owns Covered Software. 1.2. "Contributor Version" means the combination of the Contributions of others (if any) used by a Contributor and that particular Contributor's Contribution. 1.3. "Contribution" means Covered Software of a particular Contributor. 1.4. "Covered Software" means Source Code Form to which the initial Contributor has attached the notice in Exhibit A, the Executable Form of such Source Code Form, and Modifications of such Source Code Form, in each case including portions thereof. 1.5. "Incompatible With Secondary Licenses" means (a) that the initial Contributor has attached the notice described in Exhibit B to the Covered Software; or (b) that the Covered Software was made available under the terms of version 1.1 or earlier of the License, but not also under the terms of a Secondary License. 1.6. "Executable Form" means any form of the work other than Source Code Form. 1.7. "Larger Work" means a work that combines Covered Software with other material, in a separate file or files, that is not Covered Software. 1.8. "License" means this document. 1.9. "Licensable" means having the right to grant, to the maximum extent possible, whether at the time of the initial grant or subsequently, any and all of the rights conveyed by this License. 1.10. "Modifications" means any of the following: (a) any file in Source Code Form that results from an addition to, deletion from, or modification of the contents of Covered Software; or (b) any new file in Source Code Form that contains any Covered Software. 1.11. "Patent Claims" of a Contributor means any patent claim(s), including without limitation, method, process, and apparatus claims, in any patent Licensable by such Contributor that would be infringed, but for the grant of the License, by the making, using, selling, offering for sale, having made, import, or transfer of either its Contributions or its Contributor Version. 1.12. "Secondary License" means either the GNU General Public License, Version 2.0, the GNU Lesser General Public License, Version 2.1, the GNU Affero General Public License, Version 3.0, or any later versions of those licenses. 1.13. "Source Code Form" means the form of the work preferred for making modifications. 1.14. "You" (or "Your") means an individual or a legal entity exercising rights under this License. For legal entities, "You" includes any entity that controls, is controlled by, or is under common control with You. For purposes of this definition, "control" means (a) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (b) ownership of more than fifty percent (50%) of the outstanding shares or beneficial ownership of such entity. 2. License Grants and Conditions -------------------------------- 2.1. Grants Each Contributor hereby grants You a world-wide, royalty-free, non-exclusive license: (a) under intellectual property rights (other than patent or trademark) Licensable by such Contributor to use, reproduce, make available, modify, display, perform, distribute, and otherwise exploit its Contributions, either on an unmodified basis, with Modifications, or as part of a Larger Work; and (b) under Patent Claims of such Contributor to make, use, sell, offer for sale, have made, import, and otherwise transfer either its Contributions or its Contributor Version. 2.2. Effective Date The licenses granted in Section 2.1 with respect to any Contribution become effective for each Contribution on the date the Contributor first distributes such Contribution. 2.3. Limitations on Grant Scope The licenses granted in this Section 2 are the only rights granted under this License. No additional rights or licenses will be implied from the distribution or licensing of Covered Software under this License. Notwithstanding Section 2.1(b) above, no patent license is granted by a Contributor: (a) for any code that a Contributor has removed from Covered Software; or (b) for infringements caused by: (i) Your and any other third party's modifications of Covered Software, or (ii) the combination of its Contributions with other software (except as part of its Contributor Version); or (c) under Patent Claims infringed by Covered Software in the absence of its Contributions. This License does not grant any rights in the trademarks, service marks, or logos of any Contributor (except as may be necessary to comply with the notice requirements in Section 3.4). 2.4. Subsequent Licenses No Contributor makes additional grants as a result of Your choice to distribute the Covered Software under a subsequent version of this License (see Section 10.2) or under the terms of a Secondary License (if permitted under the terms of Section 3.3). 2.5. Representation Each Contributor represents that the Contributor believes its Contributions are its original creation(s) or it has sufficient rights to grant the rights to its Contributions conveyed by this License. 2.6. Fair Use This License is not intended to limit any rights You have under applicable copyright doctrines of fair use, fair dealing, or other equivalents. 2.7. Conditions Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted in Section 2.1. 3. Responsibilities ------------------- 3.1. Distribution of Source Form All distribution of Covered Software in Source Code Form, including any Modifications that You create or to which You contribute, must be under the terms of this License. You must inform recipients that the Source Code Form of the Covered Software is governed by the terms of this License, and how they can obtain a copy of this License. You may not attempt to alter or restrict the recipients' rights in the Source Code Form. 3.2. Distribution of Executable Form If You distribute Covered Software in Executable Form then: (a) such Covered Software must also be made available in Source Code Form, as described in Section 3.1, and You must inform recipients of the Executable Form how they can obtain a copy of such Source Code Form by reasonable means in a timely manner, at a charge no more than the cost of distribution to the recipient; and (b) You may distribute such Executable Form under the terms of this License, or sublicense it under different terms, provided that the license for the Executable Form does not attempt to limit or alter the recipients' rights in the Source Code Form under this License. 3.3. Distribution of a Larger Work You may create and distribute a Larger Work under terms of Your choice, provided that You also comply with the requirements of this License for the Covered Software. If the Larger Work is a combination of Covered Software with a work governed by one or more Secondary Licenses, and the Covered Software is not Incompatible With Secondary Licenses, this License permits You to additionally distribute such Covered Software under the terms of such Secondary License(s), so that the recipient of the Larger Work may, at their option, further distribute the Covered Software under the terms of either this License or such Secondary License(s). 3.4. Notices You may not remove or alter the substance of any license notices (including copyright notices, patent notices, disclaimers of warranty, or limitations of liability) contained within the Source Code Form of the Covered Software, except that You may alter any license notices to the extent required to remedy known factual inaccuracies. 3.5. Application of Additional Terms You may choose to offer, and to charge a fee for, warranty, support, indemnity or liability obligations to one or more recipients of Covered Software. However, You may do so only on Your own behalf, and not on behalf of any Contributor. You must make it absolutely clear that any such warranty, support, indemnity, or liability obligation is offered by You alone, and You hereby agree to indemnify every Contributor for any liability incurred by such Contributor as a result of warranty, support, indemnity or liability terms You offer. You may include additional disclaimers of warranty and limitations of liability specific to any jurisdiction. 4. Inability to Comply Due to Statute or Regulation --------------------------------------------------- If it is impossible for You to comply with any of the terms of this License with respect to some or all of the Covered Software due to statute, judicial order, or regulation then You must: (a) comply with the terms of this License to the maximum extent possible; and (b) describe the limitations and the code they affect. Such description must be placed in a text file included with all distributions of the Covered Software under this License. Except to the extent prohibited by statute or regulation, such description must be sufficiently detailed for a recipient of ordinary skill to be able to understand it. 5. Termination -------------- 5.1. The rights granted under this License will terminate automatically if You fail to comply with any of its terms. However, if You become compliant, then the rights granted under this License from a particular Contributor are reinstated (a) provisionally, unless and until such Contributor explicitly and finally terminates Your grants, and (b) on an ongoing basis, if such Contributor fails to notify You of the non-compliance by some reasonable means prior to 60 days after You have come back into compliance. Moreover, Your grants from a particular Contributor are reinstated on an ongoing basis if such Contributor notifies You of the non-compliance by some reasonable means, this is the first time You have received notice of non-compliance with this License from such Contributor, and You become compliant prior to 30 days after Your receipt of the notice. 5.2. If You initiate litigation against any entity by asserting a patent infringement claim (excluding declaratory judgment actions, counter-claims, and cross-claims) alleging that a Contributor Version directly or indirectly infringes any patent, then the rights granted to You by any and all Contributors for the Covered Software under Section 2.1 of this License shall terminate. 5.3. In the event of termination under Sections 5.1 or 5.2 above, all end user license agreements (excluding distributors and resellers) which have been validly granted by You or Your distributors under this License prior to termination shall survive termination. ************************************************************************ * * * 6. Disclaimer of Warranty * * ------------------------- * * * * Covered Software is provided under this License on an "as is" * * basis, without warranty of any kind, either expressed, implied, or * * statutory, including, without limitation, warranties that the * * Covered Software is free of defects, merchantable, fit for a * * particular purpose or non-infringing. The entire risk as to the * * quality and performance of the Covered Software is with You. * * Should any Covered Software prove defective in any respect, You * * (not any Contributor) assume the cost of any necessary servicing, * * repair, or correction. This disclaimer of warranty constitutes an * * essential part of this License. No use of any Covered Software is * * authorized under this License except under this disclaimer. * * * ************************************************************************ ************************************************************************ * * * 7. Limitation of Liability * * -------------------------- * * * * Under no circumstances and under no legal theory, whether tort * * (including negligence), contract, or otherwise, shall any * * Contributor, or anyone who distributes Covered Software as * * permitted above, be liable to You for any direct, indirect, * * special, incidental, or consequential damages of any character * * including, without limitation, damages for lost profits, loss of * * goodwill, work stoppage, computer failure or malfunction, or any * * and all other commercial damages or losses, even if such party * * shall have been informed of the possibility of such damages. This * * limitation of liability shall not apply to liability for death or * * personal injury resulting from such party's negligence to the * * extent applicable law prohibits such limitation. Some * * jurisdictions do not allow the exclusion or limitation of * * incidental or consequential damages, so this exclusion and * * limitation may not apply to You. * * * ************************************************************************ 8. Litigation ------------- Any litigation relating to this License may be brought only in the courts of a jurisdiction where the defendant maintains its principal place of business and such litigation shall be governed by laws of that jurisdiction, without reference to its conflict-of-law provisions. Nothing in this Section shall prevent a party's ability to bring cross-claims or counter-claims. 9. Miscellaneous ---------------- This License represents the complete agreement concerning the subject matter hereof. If any provision of this License is held to be unenforceable, such provision shall be reformed only to the extent necessary to make it enforceable. Any law or regulation which provides that the language of a contract shall be construed against the drafter shall not be used to construe this License against a Contributor. 10. Versions of the License --------------------------- 10.1. New Versions Mozilla Foundation is the license steward. Except as provided in Section 10.3, no one other than the license steward has the right to modify or publish new versions of this License. Each version will be given a distinguishing version number. 10.2. Effect of New Versions You may distribute the Covered Software under the terms of the version of the License under which You originally received the Covered Software, or under the terms of any subsequent version published by the license steward. 10.3. Modified Versions If you create software not governed by this License, and you want to create a new license for such software, you may create and use a modified version of this License if you rename the license and remove any references to the name of the license steward (except to note that such modified license differs from this License). 10.4. Distributing Source Code Form that is Incompatible With Secondary Licenses If You choose to distribute Source Code Form that is Incompatible With Secondary Licenses under the terms of this version of the License, the notice described in Exhibit B of this License must be attached. Exhibit A - Source Code Form License Notice ------------------------------------------- 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/. If it is not possible or desirable to put the notice in a particular file, then You may include the notice in a location (such as a LICENSE file in a relevant directory) where a recipient would be likely to look for such a notice. You may add additional accurate notices of copyright ownership. Exhibit B - "Incompatible With Secondary Licenses" Notice --------------------------------------------------------- This Source Code Form is "Incompatible With Secondary Licenses", as defined by the Mozilla Public License, v. 2.0. ================================================ FILE: README.md ================================================ # Firefox Home (New Tab) [Deprecated Version] This repository is no longer updated or used. We're keeping it around for those few occasions when it's useful for doing code & bugfix archaeology by looking at issues and PRs. Please do not file new issues or PRs; they will not be triaged. Issues are now tracked on Bugzilla, in `Firefox: New Tab Page` and `Firefox: Messaging System`. More current links: * Docs for [Firefox Home (New Tab)](https://firefox-source-docs.mozilla.org/browser/components/newtab/docs/index.html) * Docs for [Messaging System](https://firefox-source-docs.mozilla.org/browser/components/newtab/content-src/asrouter/docs/index.html) * [Code](https://searchfox.org/mozilla-central/source/browser/components/newtab) -------------- The files in this directory, including vendor dependencies, are exported to the browser/components/newtab/ directory in mozilla central. Read [docs/v2-system-addon](https://github.com/mozilla/activity-stream/tree/master/docs/v2-system-addon/1.GETTING_STARTED.md) for more detail on how to develop on and use this repository. ## Where should I file bugs? We regularly check the ActivityStream:NewTab component on Bugzilla. ## For Developers If you are interested in contributing, take a look at [this guide](contributing.md) on where to find us and how to contribute, and [this guide](docs/v2-system-addon/1.GETTING_STARTED.md) for getting your development environment set up. ## For Localizers Firefox Home localization is managed via [Pontoon](https://pontoon.mozilla.org/projects/activity-stream-new-tab/), not direct pull requests to the repository. If you want to fix a typo, add a new language, or simply know more about localization, please get in touch with the [existing localization team](https://pontoon.mozilla.org/teams/) for your language, or Mozilla’s [l10n-drivers](https://wiki.mozilla.org/L10n:Mozilla_Team#Mozilla_Corporation) for guidance. ================================================ FILE: aboutlibrary/content/aboutlibrary.xhtml ================================================ Library ================================================ FILE: aboutlibrary/jar.mn ================================================ # 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/. browser.jar: content/browser/ (content/*) ================================================ FILE: aboutlibrary/moz.build ================================================ # -*- 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/. JAR_MANIFESTS += ['jar.mn'] FINAL_LIBRARY = 'browsercomps' with Files('**'): BUG_COMPONENT = ('Firefox', 'Library') ================================================ FILE: bin/bootstrap ================================================ #!/bin/sh -x # bootstrap an activity-stream repo ln -s ../../hooks/pre-commit .git/hooks/pre-commit ln -s ../../hooks/post-commit .git/hooks/post-commit ================================================ FILE: bin/download-firefox-artifact ================================================ #!/usr/bin/env bash -x # Forked from https://github.com/devtools-html/debugger.html/blob/master/bin/download-firefox-artifact # # This looks for a mozilla-central artifact build as a sibling of the # activity-stream tree. If it's not there, it creates it. If it is there, it # updates it. # If AS_GIT_BIN_REPO (the git repo from which prepare-mochitests-dev and # friends will be executed) isn't set in the environment, just use the repo # we're running from. if [ -z ${AS_GIT_BIN_REPO+x} ]; then ROOT=`dirname $0` AS_GIT_BIN_REPO="../../../../activity-stream" else ROOT=${AS_GIT_BIN_REPO}/bin fi # Compute the mozilla-central path based on whether AS_PINE_TEST_DIR is set # (i.e. whether this script has been called from test-merges.js) if [ -z ${AS_PINE_TEST_DIR+x} ]; then FIREFOX_PATH="$ROOT/../../mozilla-central" else FIREFOX_PATH=${AS_PINE_TEST_DIR}/mozilla-central fi # check that mercurial is installed if [ -z "`command -v hg`" ]; then echo >&2 "mercurial is required for mochitests, use 'brew install mercurial' on MacOS"; exit 1; fi if [ -d "$FIREFOX_PATH" ]; then # convert path to absolute path FIREFOX_PATH=$(cd "$FIREFOX_PATH"; pwd) # If we already have Firefox locally, just update it cd "$FIREFOX_PATH"; if [ -n "`hg status`" ]; then read -p "There are local changes to Firefox which will be overwritten. Are you sure? [Y/n] " -r if [[ $REPLY == "n" ]]; then exit 0; fi hg revert -a fi hg pull hg update -C else echo "Downloading Firefox source code, requires about 10-30min depending on connection" hg clone https://hg.mozilla.org/mozilla-central/ "$FIREFOX_PATH" # if somebody cancels (ctrl-c) out of the long download don't continue exit_code=$? if [ $exit_code -ne 0 ]; then exit $exit_code fi cd "$FIREFOX_PATH" # Make an artifact build so it builds much faster echo " ac_add_options --enable-artifact-builds mk_add_options AUTOCLOBBER=1 mk_add_options MOZ_OBJDIR=./objdir-frontend " > .mozconfig fi ================================================ FILE: bin/prepare-mochitests-dev ================================================ #!/usr/bin/env bash -x -e # # -e means "exit on error", so that we don't have to constantly # check exit codes # # Forked from https://github.com/devtools-html/debugger.html/blob/master/bin/prepare-mochitests-dev # # This sets up a mozilla-central build for local mochitest development with an # exported activity-stream tree and test directory. # If AS_GIT_BIN_REPO (the git repo from which prepare-mochitests-dev and # friends will be executed) isn't set in the environment, just use the repo # we're running from. if [ -z ${AS_GIT_BIN_REPO+x} ]; then ROOT=`dirname $0` AS_GIT_BIN_REPO="../activity-stream" # as seen from mozilla-central else ROOT=${AS_GIT_BIN_REPO}/bin fi # Compute the mozilla-central path based on whether AS_PINE_TEST_DIR is set # (i.e. whether this script has been called from test-merges.js) if [ -z ${AS_PINE_TEST_DIR+x} ]; then FIREFOX_PATH="$ROOT/../../mozilla-central" else FIREFOX_PATH=${AS_PINE_TEST_DIR}/mozilla-central fi MC_MODULE_PATH="$FIREFOX_PATH/browser/components/newtab" # By default, just use mozilla-central + the export. If ENABLE_MC_AS is set to # 1, patch on top of mozilla-central + the export to turn on the AS pref and # turn on the tests. Once AS is on by default in mozilla-central, stuff # related to ENABLE_MC_AS can go away entirely. ENABLE_MC_AS=${ENABLE_MC_AS-0} # This will either download or update the local Firefox repo "$ROOT/download-firefox-artifact" # blow away any old bits in order to workaround bug 1335976 for users # who are using the default objdir-frontend rm -f ${FIREFOX_PATH}/objdir-frontend/dist/bin/browser/features/@activity-streams/* # Clean, package, and copy the activity stream files. npm run buildmc # Patch mozilla-central (on top of the export) so that AS is preffed on, and # the mochitests are turned on. shopt -s nullglob # don't explode if there are no patches right now if [ $ENABLE_MC_AS ]; then PATCHES=$AS_GIT_BIN_REPO/mozilla-central-patches/*.diff for p in $PATCHES do patch --directory="$FIREFOX_PATH" -p1 --force --no-backup-if-mismatch \ --input=$p done fi shopt -u nullglob # Be sure that we've built, and that the test glop in the objdir has been # created. # cd "$FIREFOX_PATH" ./mach build exit $? ================================================ FILE: bin/render-activity-stream-html.js ================================================ /* 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" : ""; const scripts = [ "chrome://browser/content/contentSearchUI.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`, ]; // 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(); ================================================ FILE: bin/try-runner.js ================================================ /* 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 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; } return { out: out && out.toString(), err: err && err.toString() }; } function logStart(name) { console.log(`TEST START | ${name}`); } function karma() { logStart("karma"); const { 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; } 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); const failed = results.summary.failed === 0; let errors = []; // 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 ); let errs = failedTests.map(test => { return `${test.suite.join(":")} ${test.description}: ${test.log[0]}`; }); errors = errors.concat(errs); } logErrors("karma", errors); return failed; } function sasslint() { logStart("sasslint"); const { out } = execOut("npm", [ "run", "--silent", "lint:sasslint", "--", "--format", "json", ]); if (!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); return errors.length === 0; } const karmaPassed = karma(); const sasslintPassed = sasslint(); const success = karmaPassed && sasslintPassed; console.log({ karmaPassed, sasslintPassed, }); process.exitCode = success ? 0 : 1; console.log("CODE", process.exitCode); ================================================ FILE: bin/vendor.js ================================================ #!/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-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`); ================================================ FILE: common/Actions.jsm ================================================ /* 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 [ "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_CONFIG_CHANGE", "DISCOVERY_STREAM_CONFIG_RESET_DEFAULTS", "DISCOVERY_STREAM_CONFIG_SETUP", "DISCOVERY_STREAM_CONFIG_SET_VALUE", "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_RETRY_FEED", "DISCOVERY_STREAM_SPOCS_CAPS", "DISCOVERY_STREAM_SPOCS_ENDPOINT", "DISCOVERY_STREAM_SPOCS_FILL", "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", "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_PERFORMANCE_EVENT", "TELEMETRY_UNDESIRED_EVENT", "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", "TRAILHEAD_ENROLL_EVENT", "UNINIT", "UPDATE_PINNED_SEARCH_SHORTCUTS", "UPDATE_SEARCH_SHORTCUTS", "UPDATE_SECTION_PREFS", "WEBEXT_CLICK", "WEBEXT_DISMISS", ]) { actionTypes[type] = type; } // These are acceptable actions for AS Router messages to have. They can show up // as call-to-action buttons in snippets, onboarding tour, etc. const ASRouterActions = {}; for (const type of [ "HIGHLIGHT_FEATURE", "INSTALL_ADDON_FROM_URL", "OPEN_APPLICATIONS_MENU", "OPEN_PRIVATE_BROWSER_WINDOW", "OPEN_URL", "OPEN_ABOUT_PAGE", "OPEN_PREFERENCES_PAGE", "SHOW_FIREFOX_ACCOUNTS", "PIN_CURRENT_TAB", "ENABLE_FIREFOX_MONITOR", "OPEN_PROTECTION_PANEL", "OPEN_PROTECTION_REPORT", "DISABLE_STP_DOORHANGERS", "SHOW_MIGRATION_WIZARD", ]) { ASRouterActions[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, }); } /** * DiscoveryStreamSpocsFill - A telemetry ping indicating a SPOCS Fill event. * * @param {object} data Fields to include in the ping (spoc_fills, etc.) * @param {int} importContext (For testing) Override the import context for testing. * @return {object} An AlsoToMain action */ function DiscoveryStreamSpocsFill(data, importContext = globalImportContext) { const action = { type: actionTypes.DISCOVERY_STREAM_SPOCS_FILL, data, }; return importContext === UI_CODE ? AlsoToMain(action) : action; } /** * UndesiredEvent - A telemetry ping indicating an undesired state. * * @param {object} data Fields to include in the ping (value, etc.) * @param {int} importContext (For testing) Override the import context for testing. * @return {object} An action. For UI code, a AlsoToMain action. */ function UndesiredEvent(data, importContext = globalImportContext) { const action = { type: actionTypes.TELEMETRY_UNDESIRED_EVENT, data, }; return importContext === UI_CODE ? AlsoToMain(action) : action; } /** * PerfEvent - A telemetry ping indicating a performance-related event. * * @param {object} data Fields to include in the ping (value, etc.) * @param {int} importContext (For testing) Override the import context for testing. * @return {object} An action. For UI code, a AlsoToMain action. */ function PerfEvent(data, importContext = globalImportContext) { const action = { type: actionTypes.TELEMETRY_PERFORMANCE_EVENT, data, }; return importContext === UI_CODE ? AlsoToMain(action) : action; } /** * 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.ASRouterActions = ASRouterActions; this.actionCreators = { BroadcastToContent, UserEvent, ASRouterUserEvent, UndesiredEvent, PerfEvent, ImpressionStats, AlsoToOneContent, OnlyToOneContent, AlsoToMain, OnlyToMain, AlsoToPreloaded, SetPref, WebExtEvent, DiscoveryStreamImpressionStats, DiscoveryStreamLoadedContent, DiscoveryStreamSpocsFill, }; // 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", "ASRouterActions", "globalImportContext", "UI_CODE", "BACKGROUND_PROCESS", "MAIN_MESSAGE_TYPE", "CONTENT_MESSAGE_TYPE", "PRELOAD_MESSAGE_TYPE", ]; ================================================ FILE: common/Dedupe.jsm ================================================ /* 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"]; ================================================ FILE: common/PerfService.jsm ================================================ /* 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"; if (typeof ChromeUtils !== "undefined") { // Use a var here instead of let outside to avoid creating a locally scoped // variable that hides the global, which we modify for testing. // eslint-disable-next-line no-var, vars-on-top var { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); } let usablePerfObj; /* istanbul ignore else */ // eslint-disable-next-line block-scoped-var if (typeof Services !== "undefined") { // Borrow the high-resolution timer from the hidden window.... // eslint-disable-next-line block-scoped-var usablePerfObj = Services.appShell.hiddenDOMWindow.performance; } else { // we must be running in content space // eslint-disable-next-line no-undef usablePerfObj = performance; } function _PerfService(options) { // For testing, so that we can use a fake Window.performance object with // known state. if (options && options.performanceObj) { this._perf = options.performanceObj; } else { this._perf = usablePerfObj; } } _PerfService.prototype = { /** * Calls the underlying mark() method on the appropriate Window.performance * object to add a mark with the given name to the appropriate performance * timeline. * * @param {String} name the name to give the current mark * @return {void} */ mark: function mark(str) { this._perf.mark(str); }, /** * Calls the underlying getEntriesByName on the appropriate Window.performance * object. * * @param {String} name * @param {String} type eg "mark" * @return {Array} Performance* objects */ getEntriesByName: function getEntriesByName(name, type) { return this._perf.getEntriesByName(name, type); }, /** * The timeOrigin property from the appropriate performance object. * Used to ensure that timestamps from the add-on code and the content code * are comparable. * * @note If this is called from a context without a window * (eg a JSM in chrome), it will return the timeOrigin of the XUL hidden * window, which appears to be the first created window (and thus * timeOrigin) in the browser. Note also, however, there is also a private * hidden window, presumably for private browsing, which appears to be * created dynamically later. Exactly how/when that shows up needs to be * investigated. * * @return {Number} A double of milliseconds with a precision of 0.5us. */ get timeOrigin() { return this._perf.timeOrigin; }, /** * Returns the "absolute" version of performance.now(), i.e. one that * should ([bug 1401406](https://bugzilla.mozilla.org/show_bug.cgi?id=1401406) * be comparable across both chrome and content. * * @return {Number} */ absNow: function absNow() { return this.timeOrigin + this._perf.now(); }, /** * This returns the absolute startTime from the most recent performance.mark() * with the given name. * * @param {String} name the name to lookup the start time for * * @return {Number} the returned start time, as a DOMHighResTimeStamp * * @throws {Error} "No Marks with the name ..." if none are available * * @note Always surround calls to this by try/catch. Otherwise your code * may fail when the `privacy.resistFingerprinting` pref is true. When * this pref is set, all attempts to get marks will likely fail, which will * cause this method to throw. * * See [bug 1369303](https://bugzilla.mozilla.org/show_bug.cgi?id=1369303) * for more info. */ getMostRecentAbsMarkStartByName(name) { let entries = this.getEntriesByName(name, "mark"); if (!entries.length) { throw new Error(`No marks with the name ${name}`); } let mostRecentEntry = entries[entries.length - 1]; return this._perf.timeOrigin + mostRecentEntry.startTime; }, }; this.perfService = new _PerfService(); const EXPORTED_SYMBOLS = ["_PerfService", "perfService"]; ================================================ FILE: common/Reducers.jsm ================================================ /* 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 dedupe = new Dedupe(site => site && site.url); const INITIAL_STATE = { App: { // Have we received real data from the app yet? initialized: false, }, 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, feeds: { data: { // "https://foo.com/feed1": {lastUpdated: 123, data: []} }, loaded: false, }, spocs: { spocs_endpoint: "", spocs_per_domain: 1, lastUpdated: null, data: {}, // {spocs: []} loaded: false, frequency_caps: [], blocked: [], placements: [], }, }, 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 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.length) { return; } result[placement.name] = handleSites(placementSpocs); }; 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.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, spocs_per_domain: action.data.spocs_per_domain || INITIAL_STATE.DiscoveryStream.spocs.spocs_per_domain, }, }; 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)); 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, DiscoveryStream, Search, }; const EXPORTED_SYMBOLS = [ "reducers", "INITIAL_STATE", "insertPinned", "TOP_SITES_DEFAULT_ROWS", "TOP_SITES_MAX_SITES_PER_ROW", ]; ================================================ FILE: components.conf ================================================ # -*- 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': 'AboutNewTabService', }, ] ================================================ FILE: content-src/.eslintrc.js ================================================ module.exports = { rules: { "import/no-commonjs": 2 } } ================================================ FILE: content-src/aboutlibrary/aboutlibrary.jsx ================================================ import React from "react"; import ReactDOM from "react-dom"; class LibraryRouter extends React.PureComponent { render() { return
; } } ReactDOM.render(, document.body); ================================================ FILE: content-src/aboutlibrary/aboutlibrary.scss ================================================ .under-construction { background-image: url('chrome://browser/content/illustrations/under-construction.svg'); background-repeat: no-repeat; background-position: center; min-height: 300px; min-width: 300px; margin-top: 10%; } ================================================ FILE: content-src/activity-stream.jsx ================================================ /* 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"; const store = initStore(reducers); new DetectUserSessionStart(store).sendEventOrAddListener(); store.dispatch(ac.AlsoToMain({ type: at.NEW_TAB_STATE_REQUEST })); ReactDOM.hydrate( , document.getElementById("root") ); ================================================ FILE: content-src/asrouter/README.md ================================================ # Activity Stream Router ## Preferences `browser.newtab.activity-stream.asrouter.*` Name | Used for | Type | Example value --- | --- | --- | --- `whitelistHosts` | Whitelist 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) ================================================ FILE: content-src/asrouter/asrouter-content.jsx ================================================ /* 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, ASRouterActions as ra, } from "common/Actions.jsm"; import { OUTGOING_MESSAGE_NAME as AS_GENERAL_OUTGOING_MESSAGE_NAME } from "content-src/lib/init-store"; 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"; import { FirstRun } from "./templates/FirstRun/FirstRun"; const INCOMING_MESSAGE_NAME = "ASRouter:parent-to-child"; const OUTGOING_MESSAGE_NAME = "ASRouter:child-to-parent"; const TEMPLATES_ABOVE_PAGE = [ "trailhead", "full_page_interrupt", "return_to_amo_overlay", "extended_triplets", ]; const FIRST_RUN_TEMPLATES = TEMPLATES_ABOVE_PAGE; const TEMPLATES_BELOW_SEARCH = ["simple_below_search_snippet"]; export const ASRouterUtils = { addListener(listener) { if (global.RPMAddMessageListener) { global.RPMAddMessageListener(INCOMING_MESSAGE_NAME, listener); } }, removeListener(listener) { if (global.RPMRemoveMessageListener) { global.RPMRemoveMessageListener(INCOMING_MESSAGE_NAME, listener); } }, sendMessage(action) { if (global.RPMSendAsyncMessage) { global.RPMSendAsyncMessage(OUTGOING_MESSAGE_NAME, action); } }, blockById(id, options) { ASRouterUtils.sendMessage({ type: "BLOCK_MESSAGE_BY_ID", data: { id, ...options }, }); }, dismissById(id) { ASRouterUtils.sendMessage({ type: "DISMISS_MESSAGE_BY_ID", data: { id } }); }, executeAction(button_action) { ASRouterUtils.sendMessage({ type: "USER_ACTION", data: button_action, }); }, unblockById(id) { ASRouterUtils.sendMessage({ type: "UNBLOCK_MESSAGE_BY_ID", data: { id } }); }, unblockBundle(bundle) { ASRouterUtils.sendMessage({ type: "UNBLOCK_BUNDLE", data: { bundle } }); }, overrideMessage(id) { ASRouterUtils.sendMessage({ type: "OVERRIDE_MESSAGE", data: { id } }); }, sendTelemetry(ping) { if (global.RPMSendAsyncMessage) { const payload = ac.ASRouterUserEvent(ping); global.RPMSendAsyncMessage(AS_GENERAL_OUTGOING_MESSAGE_NAME, payload); } }, getPreviewEndpoint() { if (global.location && global.location.href.includes("endpoint")) { const params = new URLSearchParams( global.location.href.slice(global.location.href.indexOf("endpoint")) ); try { const endpoint = new URL(params.get("endpoint")); return { url: endpoint.href, snippetId: params.get("snippetId"), theme: this.getPreviewTheme(), }; } catch (e) {} } return null; }, getPreviewTheme() { return new URLSearchParams( global.location.href.slice(global.location.href.indexOf("theme")) ).get("theme"); }, }; // 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.onMessageFromParent = this.onMessageFromParent.bind(this); 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.state = { message: {}, interruptCleared: false }; if (props.document) { this.headerPortal = props.document.getElementById( "header-asrouter-container" ); this.footerPortal = props.document.getElementById( "footer-asrouter-container" ); } } async fetchFlowParams(params = {}) { let result = {}; const { fxaEndpoint, dispatch } = 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 dispatch( ac.OnlyToMain({ type: at.TELEMETRY_UNDESIRED_EVENT, data: { event: "FXA_METRICS_FETCH_ERROR", value: response.status, }, }) ); } } catch (error) { console.error(error); // eslint-disable-line no-console dispatch( ac.OnlyToMain({ type: at.TELEMETRY_UNDESIRED_EVENT, data: { event: "FXA_METRICS_ERROR" }, }) ); } return result; } sendUserActionTelemetry(extraProps = {}) { const { message } = this.state; const eventType = `${message.provider}_user_event`; ASRouterUtils.sendTelemetry({ message_id: message.id, source: extraProps.id, action: eventType, ...extraProps, }); } sendImpression(extraProps) { if (this.state.message.provider === "preview") { return; } ASRouterUtils.sendMessage({ type: "IMPRESSION", data: this.state.message }); this.sendUserActionTelemetry({ event: "IMPRESSION", ...extraProps }); } // 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 metric = { event_context: event.target.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 action = { type: event.target.dataset.action, data: { args: event.target.dataset.args }, }; if (action.type) { ASRouterUtils.executeAction(action); } if ( !this.state.message.content.do_not_autoblock && !event.target.dataset.do_not_autoblock ) { ASRouterUtils.blockById(this.state.message.id); } if (this.state.message.provider !== "preview") { this.sendUserActionTelemetry({ event: "CLICK_BUTTON", ...metric }); } } onBlockById(id) { return options => ASRouterUtils.blockById(id, options); } onDismissById(id) { return () => ASRouterUtils.dismissById(id); } clearMessage(id) { // Request new set of dynamic triplet cards when click on a card CTA clear // message and 'id' matches one of the cards in message bundle if ( this.state.message && this.state.message.bundle && this.state.message.bundle.find(card => card.id === id) ) { this.requestMessage(); } if (id === this.state.message.id) { this.setState({ message: {} }); // Remove any styles related to the RTAMO message document.body.classList.remove("welcome", "hide-main", "amo"); } } onMessageFromParent({ data: action }) { switch (action.type) { case "SET_MESSAGE": this.setState({ message: action.data }); break; case "CLEAR_INTERRUPT": this.setState({ interruptCleared: true }); break; case "CLEAR_MESSAGE": this.clearMessage(action.data.id); break; case "CLEAR_PROVIDER": if (action.data.id === this.state.message.provider) { this.setState({ message: {} }); } break; case "CLEAR_ALL": this.setState({ message: {} }); break; case "AS_ROUTER_TARGETING_UPDATE": action.data.forEach(id => this.clearMessage(id)); break; } } requestMessage(endpoint) { // If we are loading about:welcome we want to trigger the onboarding messages if ( this.props.document && this.props.document.location.href === "about:welcome" ) { ASRouterUtils.sendMessage({ type: "TRIGGER", data: { trigger: { id: "firstRun" } }, }); } else { ASRouterUtils.sendMessage({ type: "NEWTAB_MESSAGE_REQUEST", data: { endpoint }, }); } } componentWillMount() { const endpoint = ASRouterUtils.getPreviewEndpoint(); if (endpoint && endpoint.theme === "dark") { global.window.dispatchEvent( new CustomEvent("LightweightTheme:Set", { detail: { data: NEWTAB_DARK_THEME }, }) ); } ASRouterUtils.addListener(this.onMessageFromParent); this.requestMessage(endpoint); } componentWillUnmount() { ASRouterUtils.removeListener(this.onMessageFromParent); } 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 ra.ENABLE_FIREFOX_MONITOR: const url = await this.getMonitorUrl(action.data.args); ASRouterUtils.executeAction({ type: ra.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
); } renderFirstRun() { const { message } = this.state; if (FIRST_RUN_TEMPLATES.includes(message.template)) { return ( ); } return null; } render() { const { message } = this.state; if (!message.id) { return null; } const shouldRenderBelowSearch = TEMPLATES_BELOW_SEARCH.includes( message.template ); const shouldRenderInHeader = TEMPLATES_ABOVE_PAGE.includes( message.template ); return shouldRenderBelowSearch ? ( // Render special below search snippets in place;
{this.renderSnippets()}
) : ( // For onboarding, regular snippets etc. we should render // everything in our footer container. ReactDOM.createPortal( <> {this.renderPreviewBanner()} {this.renderFirstRun()} {this.renderSnippets()} , shouldRenderInHeader ? this.headerPortal : this.footerPortal ) ); } } ASRouterUISurface.defaultProps = { document: global.document }; ================================================ FILE: content-src/asrouter/components/Button/Button.jsx ================================================ /* 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 ( ); }; ================================================ FILE: content-src/asrouter/components/Button/_Button.scss ================================================ .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; } &.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; } } } [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; } } } } ================================================ FILE: content-src/asrouter/components/ConditionalWrapper/ConditionalWrapper.jsx ================================================ /* 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(children) : children; export default ConditionalWrapper; ================================================ FILE: content-src/asrouter/components/FxASignupForm/FxASignupForm.jsx ================================================ /* 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 } from "common/Actions.jsm"; import { addUtmParams, BASE_PARAMS, } from "../../templates/FirstRun/addUtmParams"; import React from "react"; export class FxASignupForm extends React.PureComponent { constructor(props) { super(props); this.onSubmit = this.onSubmit.bind(this); this.onInputChange = this.onInputChange.bind(this); this.onInputInvalid = this.onInputInvalid.bind(this); this.handleSignIn = this.handleSignIn.bind(this); this.state = { emailInput: "", }; } get email() { return this.props.document .getElementById("fxaSignupForm") .querySelector("input[name=email]"); } onSubmit(event) { let userEvent = "SUBMIT_EMAIL"; const { email } = event.target.elements; if (email.disabled) { userEvent = "SUBMIT_SIGNIN"; } else if (!email.value.length) { email.required = true; email.checkValidity(); event.preventDefault(); return; } // Report to telemetry additional information about the form submission. const value = { has_flow_params: !!this.props.flowParams.flowId.length }; this.props.dispatch(ac.UserEvent({ event: userEvent, value })); global.addEventListener("visibilitychange", this.props.onClose); } handleSignIn(event) { // Set disabled to prevent email from appearing in url resulting in the wrong page this.email.disabled = true; } componentDidMount() { // Start with focus in the email input box if (this.email) { this.email.focus(); } } onInputChange(e) { let error = e.target.previousSibling; this.setState({ emailInput: e.target.value }); error.classList.remove("active"); e.target.classList.remove("invalid"); } onInputInvalid(e) { let error = e.target.previousSibling; error.classList.add("active"); e.target.classList.add("invalid"); e.preventDefault(); // Override built-in form validation popup e.target.focus(); } render() { const { content, UTMTerm } = this.props; return ( )}
); } } FxASignupForm.defaultProps = { document: global.document }; ================================================ FILE: content-src/asrouter/components/FxASignupForm/_FxASignupForm.scss ================================================ .fxaSignupForm { min-width: 260px; text-align: center; a { color: $white; text-decoration: underline; } input, button { border-radius: 4px; padding: 10px; } h3 { font-size: 36px; font-weight: 200; line-height: 46px; margin: 12px 0 4px; } p { font-size: 15px; line-height: 22px; margin: 0 0 20px; } .fxa-terms { margin: 4px 30px 20px; a, & { color: $white-70; font-size: 12px; line-height: 20px; } } .fxa-signin { font-size: 16px; margin-top: 19px; span { margin-inline-end: 5px; } button { background-color: initial; text-decoration: underline; color: $white; display: inline; padding: 0; width: auto; &:hover, &:focus, &:active { background-color: initial; } } } form { position: relative; .error.active { inset-inline-start: 0; z-index: 0; } } button, input { width: 100%; } input { background-color: $white; border: 1px solid $grey-50; box-shadow: none; color: $grey-70; font-size: 15px; transition: border-color 150ms, box-shadow 150ms; &:hover { border-color: $grey-90; } &:focus { border-color: $blue-50; box-shadow: 0 0 0 3px $email-input-focus; } &.invalid { border-color: $red-60; } &.invalid:focus { box-shadow: 0 0 0 3px $email-input-invalid; } } button { background-color: $blue-60; border: 0; cursor: pointer; display: block; font-size: 15px; font-weight: 400; padding: 14px; &:hover, &:focus { background-color: $trailhead-blue-60; } &:focus { outline: dotted 1px; } &:active { background-color: $trailhead-blue-70; } } } ================================================ FILE: content-src/asrouter/components/ImpressionsWrapper/ImpressionsWrapper.jsx ================================================ /* 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, }; ================================================ FILE: content-src/asrouter/components/ModalOverlay/ModalOverlay.jsx ================================================ /* 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"); this.header = this.props.document.getElementById( "header-asrouter-container" ); if (this.header) { this.header.classList.add("modal-scroll"); this.props.document.getElementById("root").classList.add("modal-height"); } } componentWillUnmount() { this.props.document.removeEventListener("keydown", this.onKeyDown); this.props.document.body.classList.remove("modal-open"); if (this.header) { this.header.classList.remove("modal-scroll"); this.props.document .getElementById("root") .classList.remove("modal-height"); } } render() { const { props } = this; let className = props.unstyled ? "" : "modalOverlayInner active"; if (props.innerClassName) { className += ` ${props.innerClassName}`; } return (
); } } ModalOverlayWrapper.defaultProps = { document: global.document }; export class ModalOverlay extends React.PureComponent { render() { const { title, button_label } = this.props; return (

{title}

{this.props.children}
); } } ================================================ FILE: content-src/asrouter/components/ModalOverlay/_ModalOverlay.scss ================================================ .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: 1100; &.active { display: block; } } .modal-scroll { position: absolute; width: 100%; height: 100%; overflow: auto; } .modal-height { // "Welcome header" has 40px of padding and 36px font size that get neglected using position absolute // causing this to visually collide with the newtab searchbar padding-top: 80px; } .modalOverlayInner { width: 960px; position: fixed; top: 15%; left: calc(50% - 480px); // halfway across minus half the width of the modal background: var(--newtab-modal-color); box-shadow: 0 1px 15px 0 $black-30; border-radius: 4px; display: none; z-index: 1101; // modal takes over entire screen @media(max-width: 960px) { width: 100%; height: 100%; top: 0; left: 0; box-shadow: none; border-radius: 0; } // if modal is short enough, reduce the top margin @media(max-height: 730px) { top: 5%; } &.active { display: block; } .icon-dismiss { border: 0; cursor: pointer; inset-inline-end: 0; padding: 20px; fill: $white; position: absolute; &:focus { border: 1px dotted; } } 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; } } } } ================================================ FILE: content-src/asrouter/components/RichText/RichText.jsx ================================================ /* 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} ); } ================================================ FILE: content-src/asrouter/components/SnippetBase/SnippetBase.jsx ================================================ /* 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 schema from "../../templates/SimpleSnippet/SimpleSnippet.schema.json"; 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 || schema.properties.block_button_text.default; 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: schema.properties.monthly_checkbox_label_text.default, locale: schema.properties.locale.default, currency_code: schema.properties.currency_code.default, selected_button: schema.properties.selected_button.default, ...props.content, }; return ( ); }; ================================================ FILE: content-src/asrouter/templates/EOYSnippet/EOYSnippet.schema.json ================================================ { "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"] } } ================================================ FILE: content-src/asrouter/templates/EOYSnippet/_EOYSnippet.scss ================================================ .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; } } ================================================ FILE: content-src/asrouter/templates/FXASignupSnippet/FXASignupSnippet.jsx ================================================ /* 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 schema from "./FXASignupSnippet.schema.json"; 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: schema.properties.scene1_button_label.default, retry_button_label: schema.properties.retry_button_label.default, scene2_email_placeholder_text: schema.properties.scene2_email_placeholder_text.default, scene2_button_label: schema.properties.scene2_button_label.default, scene2_dismiss_button_text: schema.properties.scene2_dismiss_button_text.default, ...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 ( ); }; ================================================ FILE: content-src/asrouter/templates/FXASignupSnippet/FXASignupSnippet.schema.json ================================================ { "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"] } } ================================================ FILE: content-src/asrouter/templates/FirstRun/FirstRun.jsx ================================================ /* 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 { Interrupt } from "./Interrupt"; import { Triplets } from "./Triplets"; import { BASE_PARAMS } from "./addUtmParams"; // Note: should match the transition time on .trailheadCards in _Trailhead.scss const TRANSITION_LENGTH = 500; export const FLUENT_FILES = [ "branding/brand.ftl", "browser/branding/brandings.ftl", "browser/branding/sync-brand.ftl", "browser/newtab/onboarding.ftl", ]; export const helpers = { selectInterruptAndTriplets(message = {}, interruptCleared) { const hasInterrupt = interruptCleared === true ? false : Boolean(message.content); const hasTriplets = Boolean(message.bundle && message.bundle.length); // Allow 1) falsy to not render a header 2) default welcome 3) custom header const tripletsHeaderId = message.tripletsHeaderId === undefined ? "onboarding-welcome-header" : message.tripletsHeaderId; let UTMTerm = message.utm_term || ""; UTMTerm = message.utm_term && message.trailheadTriplet ? `${message.utm_term}-${message.trailheadTriplet}` : UTMTerm; return { hasTriplets, hasInterrupt, interrupt: hasInterrupt ? message : null, triplets: hasTriplets ? message.bundle : null, tripletsHeaderId, UTMTerm, }; }, addFluent(document) { FLUENT_FILES.forEach(file => { const link = document.head.appendChild(document.createElement("link")); link.href = file; link.rel = "localization"; }); }, }; export class FirstRun extends React.PureComponent { constructor(props) { super(props); this.didLoadFlowParams = false; this.state = { prevMessage: undefined, hasInterrupt: false, hasTriplets: false, interrupt: undefined, triplets: undefined, tripletsHeaderId: "", isInterruptVisible: false, isTripletsContainerVisible: false, isTripletsContentVisible: false, UTMTerm: "", flowParams: undefined, }; this.closeInterrupt = this.closeInterrupt.bind(this); this.closeTriplets = this.closeTriplets.bind(this); helpers.addFluent(this.props.document); // Update utm campaign parameters by appending channel for // differentiating campaign in amplitude if (this.props.appUpdateChannel) { BASE_PARAMS.utm_campaign += `-${this.props.appUpdateChannel}`; } } static getDerivedStateFromProps(props, state) { const { message, interruptCleared } = props; const cardIds = message && message.bundle && message.bundle.map(card => card.id).join(","); if ( interruptCleared !== state.prevInterruptCleared || (message && message.id !== state.prevMessageId) || cardIds !== state.prevCardIds ) { const { hasTriplets, hasInterrupt, interrupt, triplets, tripletsHeaderId, UTMTerm, } = helpers.selectInterruptAndTriplets(message, interruptCleared); return { prevMessageId: message.id, prevInterruptCleared: interruptCleared, prevCardIds: cardIds, hasInterrupt, hasTriplets, interrupt, triplets, tripletsHeaderId, isInterruptVisible: hasInterrupt, isTripletsContainerVisible: hasTriplets, isTripletsContentVisible: !(hasInterrupt || !hasTriplets), UTMTerm, }; } return null; } async fetchFlowParams() { const { fxaEndpoint, fetchFlowParams } = this.props; const { UTMTerm } = this.state; if (fxaEndpoint && UTMTerm && !this.didLoadFlowParams) { this.didLoadFlowParams = true; const flowParams = await fetchFlowParams({ ...BASE_PARAMS, entrypoint: "activity-stream-firstrun", form_type: "email", utm_term: UTMTerm, }); this.setState({ flowParams }); } } removeHideMain() { if (!this.state.hasInterrupt) { // We need to remove hide-main since we should show it underneath everything that has rendered this.props.document.body.classList.remove("hide-main", "welcome"); } } componentDidMount() { this.fetchFlowParams(); this.removeHideMain(); } componentDidUpdate() { // In case we didn't have FXA info immediately, try again when we receive it. this.fetchFlowParams(); this.removeHideMain(); } closeInterrupt() { this.setState(prevState => ({ isInterruptVisible: false, isTripletsContainerVisible: prevState.hasTriplets, isTripletsContentVisible: prevState.hasTriplets, })); } closeTriplets() { this.setState({ isTripletsContainerVisible: false }); // Closing triplets should prevent any future extended triplets from showing up setTimeout(() => { this.props.onBlockById("EXTENDED_TRIPLETS_1"); }, TRANSITION_LENGTH); } render() { const { props } = this; const { sendUserActionTelemetry, fxaEndpoint, dispatch, executeAction, } = props; const { interrupt, triplets, tripletsHeaderId, isInterruptVisible, isTripletsContainerVisible, isTripletsContentVisible, hasTriplets, UTMTerm, flowParams, } = this.state; return ( <> {isInterruptVisible ? ( ) : null} {hasTriplets ? ( ) : null} ); } } ================================================ FILE: content-src/asrouter/templates/FirstRun/Interrupt.jsx ================================================ /* 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 { Trailhead } from "../Trailhead/Trailhead"; import { ReturnToAMO } from "../ReturnToAMO/ReturnToAMO"; import { FullPageInterrupt } from "../FullPageInterrupt/FullPageInterrupt"; import { LocalizationProvider } from "fluent-react"; import { generateBundles } from "../../rich-text-strings"; export class Interrupt extends React.PureComponent { render() { const { cards, onDismiss, onNextScene, message, sendUserActionTelemetry, executeAction, dispatch, fxaEndpoint, UTMTerm, flowParams, } = this.props; switch (message.template) { case "return_to_amo_overlay": return ( ); case "full_page_interrupt": return ( ); case "trailhead": return ( ); default: throw new Error(`${message.template} is not a valid FirstRun message`); } } } ================================================ FILE: content-src/asrouter/templates/FirstRun/Triplets.jsx ================================================ /* 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 { OnboardingCard } from "../../templates/OnboardingMessage/OnboardingMessage"; import { addUtmParams } from "./addUtmParams"; export class Triplets extends React.PureComponent { constructor(props) { super(props); this.onCardAction = this.onCardAction.bind(this); this.onHideContainer = this.onHideContainer.bind(this); } componentWillMount() { global.document.body.classList.add("inline-onboarding"); } componentWillUnmount() { this.props.document.body.classList.remove("inline-onboarding"); } onCardAction(action, message) { let actionUpdates = {}; const { flowParams, UTMTerm } = this.props; if (action.type === "OPEN_URL") { let url = new URL(action.data.args); addUtmParams(url, UTMTerm); if (action.addFlowParams) { url.searchParams.append("device_id", flowParams.deviceId); url.searchParams.append("flow_id", flowParams.flowId); url.searchParams.append("flow_begin_time", flowParams.flowBeginTime); } actionUpdates = { data: { ...action.data, args: url.toString() } }; } this.props.onAction({ ...action, ...actionUpdates }); // Only block if message is in dynamic triplets experiment if (message.blockOnClick) { this.props.onBlockById(message.id, { preloadedOnly: true }); } } onHideContainer() { const { sendUserActionTelemetry, cards, hideContainer } = this.props; hideContainer(); sendUserActionTelemetry({ event: "DISMISS", id: "onboarding-cards", message_id: cards.map(m => m.id).join(","), action: "onboarding_user_event", }); } render() { const { cards, headerId, showCardPanel, showContent, sendUserActionTelemetry, } = this.props; return (
{headerId &&

}
{cards.map(card => ( ))}
{showCardPanel && (

); } } ================================================ FILE: content-src/asrouter/templates/FirstRun/addUtmParams.js ================================================ /* 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; } ================================================ FILE: content-src/asrouter/templates/FullPageInterrupt/FullPageInterrupt.jsx ================================================ /* 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 { addUtmParams } from "../FirstRun/addUtmParams"; import { FxASignupForm } from "../../components/FxASignupForm/FxASignupForm"; import { OnboardingCard } from "../../templates/OnboardingMessage/OnboardingMessage"; import React from "react"; export const FxAccounts = ({ document, content, dispatch, fxaEndpoint, flowParams, removeOverlay, url, UTMTerm, }) => (

); export const FxCards = ({ cards, onCardAction, sendUserActionTelemetry }) => ( {cards.map(card => ( ))} ); export class FullPageInterrupt extends React.PureComponent { constructor(props) { super(props); this.removeOverlay = this.removeOverlay.bind(this); this.onCardAction = this.onCardAction.bind(this); } componentWillMount() { global.document.body.classList.add("trailhead-fullpage"); } componentDidMount() { // Hide the page content from screen readers while the full page interrupt is open this.props.document .getElementById("root") .setAttribute("aria-hidden", "true"); } removeOverlay() { window.removeEventListener("visibilitychange", this.removeOverlay); document.body.classList.remove("hide-main", "trailhead-fullpage"); // Re-enable the document for screen readers this.props.document .getElementById("root") .setAttribute("aria-hidden", "false"); this.props.onBlock(); document.body.classList.remove("welcome"); } onCardAction(action, message) { let actionUpdates = {}; const { flowParams, UTMTerm } = this.props; if (action.type === "OPEN_URL") { let url = new URL(action.data.args); addUtmParams(url, UTMTerm); if (action.addFlowParams) { url.searchParams.append("device_id", flowParams.deviceId); url.searchParams.append("flow_id", flowParams.flowId); url.searchParams.append("flow_begin_time", flowParams.flowBeginTime); } actionUpdates = { data: { ...action.data, args: url.toString() } }; } this.props.onAction({ ...action, ...actionUpdates }); // Only block if message is in dynamic triplets experiment if (message.blockOnClick) { this.props.onBlockById(message.id, { preloadedOnly: true }); } this.removeOverlay(); } render() { const { props } = this; const { content } = props.message; const cards = ( ); const accounts = ( ); // By default we show accounts section on top and // cards section in bottom half of the full page interrupt const cardsFirst = content && content.className === "fullPageCardsAtTop"; const firstContainerClassName = [ "container", content && content.className, ].join(" "); return (

{cardsFirst ? cards : accounts}
{cardsFirst ? accounts : cards}
); } } FullPageInterrupt.defaultProps = { flowParams: { deviceId: "", flowId: "", flowBeginTime: "" }, }; ================================================ FILE: content-src/asrouter/templates/FullPageInterrupt/_FullPageInterrupt.scss ================================================ .activity-stream { &.welcome { overflow: hidden; } &:not(.welcome) { .fullpage-wrapper { display: none; } } } .fullpage-wrapper { $responsive-breakpoint: 975px; $responsive-width: 300px; $header-size: 36px; $form-text-size: 16px; align-content: center; display: flex; flex-direction: column; overflow-x: auto; position: absolute; top: 0; left: 0; width: 100vw; height: 100vh; z-index: 21000; background-color: $ghost-white; + div { opacity: 0; } .fullpage-icon { background-position-x: left; background-repeat: no-repeat; background-size: contain; &:dir(rtl) { background-position-x: right; } @media screen and (max-width: $responsive-breakpoint) { background-position: center; } } .brand-logo { background-image: url('chrome://branding/content/about-logo.png'); margin: 20px 10px 10px 20px; padding-bottom: 50px; } .welcome-title, .welcome-subtitle { align-self: center; margin: 0; @media screen and (max-width: $responsive-breakpoint) { text-align: center; } } .welcome-title { color: $trailhead-purple-80; font-size: 46px; font-weight: 600; line-height: 62px; } .welcome-subtitle { color: $trailhead-violet; font-size: 20px; line-height: 27px; } .container { display: flex; align-self: center; padding: 50px 0; @media screen and (max-width: $responsive-breakpoint) { flex-direction: column; width: $responsive-width; text-align: center; } } .fullpage-left-section { position: relative; width: 538px; font-size: 18px; line-height: 30px; @media screen and (max-width: $responsive-breakpoint) { width: $responsive-width; } .fullpage-left-content { color: $grey-60; display: inline; margin: 0; margin-inline-end: 2px; } .fullpage-left-link { color: $blue-60; display: block; text-decoration: underline; margin-bottom: 30px; &:hover, &:active, &:focus { color: $blue-60; } } .fullpage-left-title { margin: 0; color: $trailhead-purple-80; font-size: $header-size; line-height: 48px; } .fx-systems-icons { height: 33px; display: block; background-image: url('#{$image-path}trailhead/firefox-systems.png'); margin-bottom: 20px; } } .fullpage-form { position: relative; text-align: center; margin-inline-start: $header-size; @media screen and (max-width: $responsive-breakpoint) { margin-inline-start: 0; } .fxaSignupForm { width: 356px; padding: 25px; box-shadow: 0 0 16px 0 $black-15; border-radius: 6px; background: $white; } .fxa-terms { margin: 4px 0 20px; a, & { color: $grey-60; font-size: 12px; line-height: $form-text-size; } } .fxa-signin { color: $grey-60; line-height: 30px; opacity: 0.77; button { color: $blue-60; } } h3 { color: $trailhead-purple-80; font-weight: 400; font-size: $header-size; line-height: $header-size; margin: 0; padding: 8px; } h3 + p { color: $grey-60; font-size: $form-text-size; line-height: 20px; opacity: 0.77; } input { background: $white; border: 1px solid $grey-30; border-radius: 2px; &:hover { border-color: $grey-50; } &.invalid { border-color: $red-60; } } button { color: $white; font-size: $form-text-size; &:focus { outline: dotted 1px $grey-50; } } } .section-divider::after { content: ''; display: block; border-bottom: 0.5px solid $grey-30; } .trailheadCard { box-shadow: none; background: none; text-align: center; width: 320px; padding: 18px; .onboardingTitle { color: $grey-90; } .onboardingText { font-weight: normal; color: $grey-60; margin-top: 4px; } .onboardingButton { color: $grey-60; background: $grey-90-10; &:focus, &:hover { background: $grey-90-20; } &:active { background: $grey-90-30; } } .onboardingMessageImage { height: 112px; width: 154px; } @media screen and (max-width: $responsive-breakpoint) { width: $responsive-width; } } } ================================================ FILE: content-src/asrouter/templates/NewsletterSnippet/NewsletterSnippet.jsx ================================================ /* 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 schema from "./NewsletterSnippet.schema.json"; import { SubmitFormSnippet } from "../SubmitFormSnippet/SubmitFormSnippet.jsx"; export const NewsletterSnippet = props => { const extendedContent = { scene1_button_label: schema.properties.scene1_button_label.default, retry_button_label: schema.properties.retry_button_label.default, scene2_email_placeholder_text: schema.properties.scene2_email_placeholder_text.default, scene2_button_label: schema.properties.scene2_button_label.default, scene2_dismiss_button_text: schema.properties.scene2_dismiss_button_text.default, scene2_newsletter: schema.properties.scene2_newsletter.default, ...props.content, hidden_inputs: { newsletters: props.content.scene2_newsletter || schema.properties.scene2_newsletter.default, fmt: schema.properties.hidden_inputs.properties.fmt.default, lang: props.content.locale || schema.properties.locale.default, source_url: `https://snippets.mozilla.com/show/${props.id}`, ...props.content.hidden_inputs, }, }; return ( ); }; ================================================ FILE: content-src/asrouter/templates/NewsletterSnippet/NewsletterSnippet.schema.json ================================================ { "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"] } } ================================================ FILE: content-src/asrouter/templates/OnboardingMessage/OnboardingMessage.jsx ================================================ /* 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 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 (

); } } ================================================ FILE: content-src/asrouter/templates/OnboardingMessage/OnboardingMessage.schema.json ================================================ { "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"] } ================================================ FILE: content-src/asrouter/templates/OnboardingMessage/ToolbarBadgeMessage.schema.json ================================================ { "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"] } ================================================ FILE: content-src/asrouter/templates/OnboardingMessage/UpdateAction.schema.json ================================================ { "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"] } ================================================ FILE: content-src/asrouter/templates/OnboardingMessage/WhatsNewMessage.schema.json ================================================ { "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"] }, "icon_url": { "description": "(optional) URL for the What's New message icon.", "type": "string", "format": "uri" }, "icon_alt": { "description": "Alt text for image.", "type": "string" } }, "additionalProperties": false, "required": ["published_date", "title", "body", "cta_url", "bucket_id"], "dependencies": { "layout": ["layout_title_content_variable"] } } ================================================ FILE: content-src/asrouter/templates/OnboardingMessage/_OnboardingMessage.scss ================================================ .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; } } // Also used for Trailhead .onboardingMessageImage { height: 112px; width: 120px; 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; } @media (min-width: $break-point-widest) { width: 250px; background-size: auto 140px; } &.addons { background-image: url('#{$image-path}illustration-addons@2x.png'); } &.privatebrowsing { background-image: url('#{$image-path}illustration-privatebrowsing@2x.png'); } &.screenshots { background-image: url('#{$image-path}illustration-screenshots@2x.png'); } &.gift { background-image: url('#{$image-path}illustration-gift@2x.png'); } &.sync { background-image: url('#{$image-path}illustration-sync@2x.png'); } &.devices { background-image: url('#{$image-path}trailhead/card-illo-devices.svg'); } &.fbcont { background-image: url('#{$image-path}trailhead/card-illo-fbcont.svg'); } &.import { background-image: url('#{$image-path}trailhead/card-illo-import.svg'); } &.ffmonitor { background-image: url('#{$image-path}trailhead/card-illo-ffmonitor.svg'); } &.ffsend { background-image: url('#{$image-path}trailhead/card-illo-ffsend.svg'); } &.lockwise { background-image: url('#{$image-path}trailhead/card-illo-lockwise.svg'); } &.mobile { background-image: url('#{$image-path}trailhead/card-illo-mobile.svg'); } &.pledge { background-image: url('#{$image-path}trailhead/card-illo-pledge.svg'); } &.pocket { background-image: url('#{$image-path}trailhead/card-illo-pocket.svg'); } &.private { background-image: url('#{$image-path}trailhead/card-illo-private.svg'); } &.sendtab { background-image: url('#{$image-path}trailhead/card-illo-sendtab.svg'); } &.tracking { background-image: url('#{$image-path}trailhead/card-illo-tracking.svg'); } } ================================================ FILE: content-src/asrouter/templates/ReturnToAMO/ReturnToAMO.jsx ================================================ /* 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 { RichText } from "../../components/RichText/RichText"; // Alt text if available; in the future this should come from the server. See bug 1551711 const ICON_ALT_TEXT = ""; export class ReturnToAMO extends React.PureComponent { constructor(props) { super(props); this.onClickAddExtension = this.onClickAddExtension.bind(this); this.onBlockButton = this.onBlockButton.bind(this); } componentWillMount() { global.document.body.classList.add("amo"); } componentDidMount() { this.props.sendUserActionTelemetry({ event: "IMPRESSION", id: this.props.UISurface, }); // Hide the page content from screen readers while the modal is open this.props.document .getElementById("root") .setAttribute("aria-hidden", "true"); } onClickAddExtension() { this.props.onAction(this.props.content.primary_button.action); this.props.sendUserActionTelemetry({ event: "INSTALL", id: this.props.UISurface, }); } onBlockButton() { this.props.onBlock(); document.body.classList.remove("welcome", "hide-main", "amo"); this.props.sendUserActionTelemetry({ event: "BLOCK", id: this.props.UISurface, }); // Re-enable the document for screen readers this.props.document .getElementById("root") .setAttribute("aria-hidden", "false"); } renderText() { const customElement = ( {ICON_ALT_TEXT} ); return ( ); } render() { const { content } = this.props; return (

{content.header}

{content.title}

{this.renderText()}
); } } ================================================ FILE: content-src/asrouter/templates/ReturnToAMO/_ReturnToAMO.scss ================================================ .ReturnToAMOOverlay, .amo + body.hide-main { // sass-lint:disable-line no-qualifying-elements background: $grey-10; height: 100%; position: fixed; top: 0; width: 100%; display: flex; justify-content: center; align-items: center; z-index: 2100; .ReturnToAMOText { color: $grey-90; line-height: 32px; font-size: 23px; width: 100%; img { margin-inline-start: 6px; margin-inline-end: 6px; } } h2 { color: $grey-60; font-weight: 100; margin: 0 0 36px; font-size: 36px; line-height: 48px; letter-spacing: 1.2px; } p { color: $grey-60; font-size: 14px; line-height: 18px; margin-bottom: 16px; } .puffy { border-radius: 4px; height: 48px; padding: 0 16px; font-size: 15px; } .blue { border: 0; color: $white; background-color: $blue-60; &:hover { box-shadow: none; background-color: $blue-70; } &:active { background-color: $blue-80; } } .default { border-radius: 2px; height: 40px; padding: 0 12px; font-size: 15px; } .grey { border: 0; background-color: $grey-90-10; &:hover { box-shadow: none; background-color: $grey-90-20; } &:active { background-color: $grey-90-30; } } .ReturnToAMOGetStarted { margin-top: 40px; float: right; &:dir(rtl) { float: left; } } .ReturnToAMOAddExtension { margin-top: 20px; } .ReturnToAMOContainer { width: 960px; background: $white; box-shadow: 0 1px 15px 0 $black-30; border-radius: 4px; display: flex; padding: 64px 64px 72px; } .ReturnToAMOAddonContents { width: 560px; margin-top: 32px; margin-inline-end: 24px; } .ReturnToAMOIcon { width: 292px; height: 254px; background-size: 292px 254px; background-position: center center; background-repeat: no-repeat; background-image: url('resource://activity-stream/data/content/assets/gift-extension.svg'); } .icon-add { fill: $white; vertical-align: sub; } } ================================================ FILE: content-src/asrouter/templates/SendToDeviceSnippet/SendToDeviceSnippet.jsx ================================================ /* 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 schema from "./SendToDeviceSnippet.schema.json"; 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: schema.properties.scene1_button_label.default, retry_button_label: schema.properties.retry_button_label.default, scene2_dismiss_button_text: schema.properties.scene2_dismiss_button_text.default, scene2_button_label: schema.properties.scene2_button_label.default, scene2_input_placeholder: schema.properties.scene2_input_placeholder.default, locale: schema.properties.locale.default, country: schema.properties.country.default, message_id_email: "", include_sms: schema.properties.include_sms.default, ...props.content, }, }; } export const SendToDeviceSnippet = props => { const propsWithDefaults = addDefaultValues(props); return ( ); }; ================================================ FILE: content-src/asrouter/templates/SendToDeviceSnippet/SendToDeviceSnippet.schema.json ================================================ { "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"] } } ================================================ FILE: content-src/asrouter/templates/SendToDeviceSnippet/isEmailOrPhoneNumber.js ================================================ /* 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 ""; } ================================================ FILE: content-src/asrouter/templates/SimpleBelowSearchSnippet/SimpleBelowSearchSnippet.jsx ================================================ /* 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()}
}
); } } ================================================ FILE: content-src/asrouter/templates/SimpleBelowSearchSnippet/SimpleBelowSearchSnippet.schema.json ================================================ { "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"] } } ================================================ FILE: content-src/asrouter/templates/SimpleBelowSearchSnippet/_SimpleBelowSearchSnippet.scss ================================================ .below-search-snippet { margin: 0 auto 16px; &.withButton { padding: 0 25px; margin: auto; min-height: 60px; background-color: transparent; // Add more padding if discovery stream is enabled. .ds-outer-wrapper-breakpoint-override & { padding: 0 50px; @media (max-width: 865px) { .buttonContainer { margin: auto; } } } .snippet-hover-wrapper { min-height: 60px; border-radius: 4px; &:hover { background-color: var(--newtab-element-hover-color); .blockButton { display: block; opacity: 1; // larger inset if discovery stream is enabled. .ds-outer-wrapper-breakpoint-override & { inset-inline-end: -8%; @media (max-width: 865px) { inset-inline-end: 2%; } } } } } } } .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; } @media (min-width: $break-point-medium) { @include full-width-styles; } // 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 60px; } @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; } // Disable breakpoints for now if discovery stream is enabled. .ds-outer-wrapper-breakpoint-override & { @include full-width-styles; 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; } // Disable breakpoints for now if discovery stream is enabled. .ds-outer-wrapper-breakpoint-override & { @include full-width-styles; } } &.withButton { line-height: 20px; margin-bottom: 10px; min-height: 60px; background-color: transparent; .blockButton { display: block; inset-inline-end: -15%; 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%; } .ds-outer-wrapper-breakpoint-override & { inset-inline-end: -10%; margin: auto; @media (max-width: 865px) { 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; } // Disable breakpoints for now if discovery stream is enabled. .ds-outer-wrapper-breakpoint-override & { margin: 12px 0; } a { font-weight: 600; } } } ================================================ FILE: content-src/asrouter/templates/SimpleSnippet/SimpleSnippet.jsx ================================================ /* 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 } = 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"); 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 ); } 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()}
}
); } } ================================================ FILE: content-src/asrouter/templates/SimpleSnippet/SimpleSnippet.schema.json ================================================ { "title": "SimpleSnippet", "description": "A simple template with an icon, text, and optional button.", "version": "1.1.1", "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_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"] } } ================================================ FILE: content-src/asrouter/templates/SimpleSnippet/_SimpleSnippet.scss ================================================ $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; } } } ================================================ FILE: content-src/asrouter/templates/SubmitFormSnippet/SubmitFormSnippet.jsx ================================================ /* 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"; // 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", value: "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", value: "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", value: "subscribe-error", id: "NEWTAB_FOOTER_BAR_CONTENT", }); } this.setState({ disableForm: false }); } expandSnippet() { this.props.sendUserActionTelemetry({ event: "CLICK_BUTTON", value: "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); } renderInput() { const placholder = this.props.content.scene2_email_placeholder_text || this.props.content.scene2_input_placeholder; return ( ); } renderSignupView() { const { content } = this.props; const containerClass = `SubmitFormSnippet ${this.props.className}`; return ( {content.scene2_icon ? (
{content.scene2_icon_alt_text {content.scene2_icon_alt_text
) : null}

{content.scene2_title && (

{content.scene2_title}

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

{this.renderHiddenFormInputs()}
{this.renderInput()}
{this.renderFormPrivacyNotice() || this.renderDisclaimer()}
); } 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(); } if (this.state.expanded) { return this.renderSignupView(); } return ( ); } } ================================================ FILE: content-src/asrouter/templates/SubmitFormSnippet/SubmitFormSnippet.schema.json ================================================ { "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"] } } ================================================ FILE: content-src/asrouter/templates/SubmitFormSnippet/_SubmitFormSnippet.scss ================================================ .SubmitFormSnippet { flex-direction: column; flex: 1 1 100%; width: 100%; .disclaimerText { margin: 20px 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 { max-width: 670px; 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); } } } } .submissionStatus { text-align: center; font-size: 14px; padding: 20px 0; .submitStatusTitle { font-size: 20px; } } ================================================ FILE: content-src/asrouter/templates/Trailhead/Trailhead.jsx ================================================ /* 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 } from "common/Actions.jsm"; import { ModalOverlayWrapper } from "../../components/ModalOverlay/ModalOverlay"; import { FxASignupForm } from "../../components/FxASignupForm/FxASignupForm"; import { addUtmParams } from "../FirstRun/addUtmParams"; import React from "react"; // From resource://devtools/client/shared/focus.js const FOCUSABLE_SELECTOR = [ "a[href]:not([tabindex='-1'])", "button:not([disabled]):not([tabindex='-1'])", "iframe:not([tabindex='-1'])", "input:not([disabled]):not([tabindex='-1'])", "select:not([disabled]):not([tabindex='-1'])", "textarea:not([disabled]):not([tabindex='-1'])", "[tabindex]:not([tabindex='-1'])", ].join(", "); export class Trailhead extends React.PureComponent { constructor(props) { super(props); this.closeModal = this.closeModal.bind(this); this.onStartBlur = this.onStartBlur.bind(this); } get dialog() { return this.props.document.getElementById("trailheadDialog"); } componentDidMount() { // We need to remove hide-main since we should show it underneath everything that has rendered this.props.document.body.classList.remove("hide-main"); // The rest of the page is "hidden" to screen readers when the modal is open this.props.document .getElementById("root") .setAttribute("aria-hidden", "true"); } onStartBlur(event) { // Make sure focus stays within the dialog when tabbing from the button const { dialog } = this; if ( event.relatedTarget && !( dialog.compareDocumentPosition(event.relatedTarget) & dialog.DOCUMENT_POSITION_CONTAINED_BY ) ) { dialog.querySelector(FOCUSABLE_SELECTOR).focus(); } } closeModal(ev) { global.removeEventListener("visibilitychange", this.closeModal); this.props.document.body.classList.remove("welcome"); this.props.document.getElementById("root").removeAttribute("aria-hidden"); this.props.onNextScene(); // If closeModal() was triggered by a visibilitychange event, the user actually // submitted the email form so we don't send a SKIPPED_SIGNIN ping. if (!ev || ev.type !== "visibilitychange") { this.props.dispatch( ac.UserEvent({ event: "SKIPPED_SIGNIN", ...this._getFormInfo() }) ); } // Bug 1190882 - Focus in a disappearing dialog confuses screen readers this.props.document.activeElement.blur(); } /** * Report to telemetry additional information about the form submission. */ _getFormInfo() { const value = { has_flow_params: !!this.props.flowParams.flowId.length }; return { value }; } render() { const { props } = this; const { UTMTerm } = props; const { content } = props.message; const innerClassName = ["trailhead", content && content.className] .filter(v => v) .join(" "); return (

{content.subtitle && (

)}

    {content.benefits.map(item => (
  • ))}

); } ================================================ FILE: content-src/components/A11yLinkButton/_A11yLinkButton.scss ================================================ .a11y-link-button { border: 0; padding: 0; cursor: pointer; text-align: unset; color: var(--newtab-link-primary-color); &:hover, &:focus { text-decoration: underline; } } ================================================ FILE: content-src/components/ASRouterAdmin/ASRouterAdmin.jsx ================================================ /* 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-content"; import { connect } from "react-redux"; import { ModalOverlay } from "../../asrouter/components/ModalOverlay/ModalOverlay"; 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 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 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.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, }) ); } expireCache() { const { config } = this.props.state; this.props.dispatch( ac.OnlyToMain({ type: at.DISCOVERY_STREAM_CONFIG_CHANGE, data: config, }) ); } changeEndpointVariant(event) { const endpoint = this.props.state.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.config.layout_endpoint; const isMatch = endpoint && !!endpoint.match(`layout_variant=${id}`); return isMatch; } renderFeedData(url) { const { feeds } = this.props.state; const feed = feeds.data[url].data; return (

Feed url: {url}

{feed.recommendations.map(story => this.renderStoryData(story))}
); } renderFeedsData() { const { feeds } = this.props.state; return ( {Object.keys(feeds.data).map(url => this.renderFeedData(url))} ); } renderSpocs() { const { spocs } = this.props.state; let spocsData = []; if (spocs.data && spocs.data.spocs && spocs.data.spocs.length) { spocsData = spocs.data.spocs; } 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; 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; 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)}
))}
))}

Feeds Data

{this.renderFeedsData()}

Spocs

{this.renderSpocs()}
); } } export class ASRouterAdminInner extends React.PureComponent { constructor(props) { super(props); this.onMessage = this.onMessage.bind(this); this.handleEnabledToggle = this.handleEnabledToggle.bind(this); this.handleUserPrefToggle = this.handleUserPrefToggle.bind(this); this.onChangeMessageFilter = this.onChangeMessageFilter.bind(this); this.findOtherBundledMessagesOfSameTemplate = this.findOtherBundledMessagesOfSameTemplate.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.onPasteTargetingParams = this.onPasteTargetingParams.bind(this); this.onNewTargetingParams = this.onNewTargetingParams.bind(this); this.state = { messageFilter: "all", evaluationStatus: {}, trailhead: {}, stringTargetingParameters: null, newStringTargetingParameters: null, copiedToClipboard: false, pasteFromClipboard: false, attributionParameters: { source: "addons.mozilla.org", campaign: "non-fx-button", content: "iridium@particlecore.github.io", }, }; } onMessage({ data: action }) { if (action.type === "ADMIN_SET_STATE") { this.setState(action.data); if (!this.state.stringTargetingParameters) { const stringTargetingParameters = {}; for (const param of Object.keys(action.data.targetingParameters)) { stringTargetingParameters[param] = JSON.stringify( action.data.targetingParameters[param], null, 2 ); } this.setState({ stringTargetingParameters }); } } } componentWillMount() { const endpoint = ASRouterUtils.getPreviewEndpoint(); ASRouterUtils.sendMessage({ type: "ADMIN_CONNECT_STATE", data: { endpoint }, }); ASRouterUtils.addListener(this.onMessage); } componentWillUnmount() { ASRouterUtils.removeListener(this.onMessage); } findOtherBundledMessagesOfSameTemplate(template) { return this.state.messages.filter( msg => msg.template === template && msg.bundled ); } handleBlock(msg) { if (msg.bundled) { // If we are blocking a message that belongs to a bundle, block all other messages that are bundled of that same template let bundle = this.findOtherBundledMessagesOfSameTemplate(msg.template); return () => ASRouterUtils.blockBundle(bundle); } return () => ASRouterUtils.blockById(msg.id); } handleUnblock(msg) { if (msg.bundled) { // If we are unblocking a message that belongs to a bundle, unblock all other messages that are bundled of that same template let bundle = this.findOtherBundledMessagesOfSameTemplate(msg.template); return () => ASRouterUtils.unblockBundle(bundle); } return () => ASRouterUtils.unblockById(msg.id); } handleOverride(id) { return () => ASRouterUtils.overrideMessage(id); } expireCache() { ASRouterUtils.sendMessage({ type: "EXPIRE_QUERY_CACHE" }); } resetPref() { ASRouterUtils.sendMessage({ type: "RESET_PROVIDER_PREF" }); } 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, }, }); } 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, }; }); } 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 }); } // 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"); } // Copy all clipboard data to targeting parameters onPasteTargetingParams(event) { this.setState(({ pasteFromClipboard }) => ({ pasteFromClipboard: !pasteFromClipboard, newStringTargetingParameters: "", })); } 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; } } renderMessageItem(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; let itemClassName = "message-item"; if (isBlocked) { itemClassName += " blocked"; } return ( {msg.id}
{isBlocked ? null : ( )}
({impressions} impressions)
{JSON.stringify(msg, null, 2)}
); } 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 ( {messagesToShow.map(msg => this.renderMessageItem(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 */}

); } 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})`; } 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() : ""}
); } renderPasteModal() { if (!this.state.pasteFromClipboard) { return null; } const errors = this.refs.targetingParamsEval && this.refs.targetingParamsEval.innerText.length; return (