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 (
{props.children}
);
};
================================================
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 (
{props.hasDismissIcon && (
)}
{props.children}
);
}
}
ModalOverlayWrapper.defaultProps = { document: global.document };
export class ModalOverlay extends React.PureComponent {
render() {
const { title, button_label } = this.props;
return (
{title}
{this.props.children}
{" "}
{button_label}{" "}
);
}
}
================================================
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 (
{this.props.content.scene2_dismiss_button_text}
);
}
const label =
this.props.content.block_button_text ||
schema.properties.block_button_text.default;
return (
);
}
render() {
const { props } = this;
const { blockButtonHover } = this.state;
const containerClassName = `SnippetBaseContainer${
props.className ? ` ${props.className}` : ""
}${blockButtonHover ? " active" : ""}`;
return (
{props.children}
{this.renderDismissButton()}
);
}
}
================================================
FILE: content-src/asrouter/components/SnippetBase/_SnippetBase.scss
================================================
.SnippetBaseContainer {
position: fixed;
z-index: 2;
bottom: 0;
left: 0;
right: 0;
background-color: var(--newtab-snippets-background-color);
color: var(--newtab-text-primary-color);
font-size: 14px;
line-height: 20px;
border-top: 1px solid var(--newtab-snippets-hairline-color);
box-shadow: $shadow-secondary;
display: flex;
align-items: center;
a {
cursor: pointer;
color: var(--newtab-link-primary-color);
&:hover {
text-decoration: underline;
}
[lwt-newtab-brighttext] & {
font-weight: bold;
}
}
input {
&[type='checkbox'] {
margin-inline-start: 0;
}
}
.innerWrapper {
margin: 0 auto;
display: flex;
align-items: center;
padding: 12px $section-horizontal-padding;
// This is to account for the block button on smaller screens
padding-inline-end: 36px;
@media (min-width: $break-point-large) {
padding-inline-end: $section-horizontal-padding;
}
max-width: $wrapper-max-width-large + ($section-horizontal-padding * 2);
@media (min-width: $break-point-widest) {
max-width: $wrapper-max-width-widest + ($section-horizontal-padding * 2);
}
}
.blockButton {
display: none;
background: none;
border: 0;
position: absolute;
top: 50%;
inset-inline-end: 12px;
height: 16px;
width: 16px;
background-image: url('resource://activity-stream/data/content/assets/glyph-dismiss-16.svg');
-moz-context-properties: fill;
fill: var(--newtab-icon-primary-color);
opacity: 0.5;
margin-top: -8px;
padding: 0;
cursor: pointer;
@media (min-width: 766px) {
inset-inline-end: 24px;
}
}
&:hover .blockButton {
display: block;
}
.icon {
height: 42px;
width: 42px;
margin-inline-end: 12px;
flex-shrink: 0;
}
}
.snippets-preview-banner {
font-size: 15px;
line-height: 42px;
color: $grey-60-70;
background: $grey-30-60;
text-align: center;
position: absolute;
top: 0;
width: 100%;
span {
vertical-align: middle;
}
}
// We show snippet icons for both themes and conditionally hide
// based on which theme is currently active
body {
&:not([lwt-newtab-brighttext]) {
.icon-dark-theme,
.icon.icon-dark-theme,
.scene2Icon .icon-dark-theme {
display: none;
}
}
&[lwt-newtab-brighttext] {
.icon-light-theme,
.icon.icon-light-theme,
.scene2Icon .icon-light-theme {
display: none;
}
}
}
================================================
FILE: content-src/asrouter/docs/debugging-docs.md
================================================
# Using ASRouter Devtools
## How to enable ASRouter devtools
- In `about:config`, set `browser.newtabpage.activity-stream.asrouter.devtoolsEnabled` to `true`
- Visit `about:newtab#asrouter` to see the devtools.
## Overview of ASRouter devtools

## How to enable/disable a provider
To enable a provider such as `snippets`, Look at the list of "Message Providers" at the top of the page. Make sure the checkbox is checked next to the provider you want to enable.
To disable it, uncheck the checkbox. You should see a red label indicating the provider is now disabled.
## How to see all messages from a provider
(Only available in Firefox 65+)
In order to see all active messages for a current provider such as `snippets`, use the drop down selector under the "Messages" section. Select the name of the provider you are interested in.
The messages on the page should now be filtered to include only the provider you selected.
## How to test telemetry pings
To test telemetry pings, complete the the following steps:
- In about:config, set:
- `browser.newtabpage.activity-stream.telemetry` to `true`
- `browser.ping-centre.log` to `true`
- Open the Browser Toolbox devtools (Tools > Web Developer > Browser Toolbox) and switch to the console tab. Add a filter for for `activity-stream` to only display relevant pings:

You should now see pings show up as you view/interact with ASR messages/templates.
## Snippets debugging
### How to view preview URLs
Follow these steps to view preview URLs (e.g. `about:newtab?endpoint=https://gist.githubusercontent.com/piatra/d193ca7e0f513cc19fc6a1d396c214f7/raw/8bcaf9548212e4c613577e839198cc14e7317630/newsletter_snippet.json&theme=dark`)
You can preview in the two different themes (light and dark) by adding `&theme=dark` or `&theme=light` at the end of the url.
#### IMPORTANT NOTES
- Links to URLs starting with `about:newtab` cannot be clicked on directly. They must be copy and pasted into the address bar.
- Previews should only be tested in `Firefox 64` and later.
- The endpoint must be HTTPS, the host must be whitelisted (see testing instructions below)
- Errors are surfaced in the `Console` tab of the `Browser Toolbox`
#### Testing instructions
- If your endpoint URL has a host name of `snippets-admin.mozilla.org`, you can paste the URL into the address bar view it without any further steps.
- If your endpoint URL starts with some other host name, it must be **whitelisted**. Open the Browser Toolbox devtools (Tools > Developer > Browser Toolbox) and paste the following code (where `gist.githubusercontent.com` is the hostname of your endpoint URL):
```js
Services.prefs.setStringPref(
"browser.newtab.activity-stream.asrouter.whitelistHosts",
"[\"gist.githubusercontent.com\"]"
);
```
- Restart the browser
- You should now be able to paste the URL into the address bar and view it.
================================================
FILE: content-src/asrouter/docs/experiment-guide.md
================================================
# How to run experiments with ASRouter
This guide will tell you how to run an experiment with ASRouter messages.
Note that the actual experiment process and infrastructure is handled by
the experiments team (#ask-experimenter).
## Why run an experiment
* To measure the effect of a message on a Firefox metric (e.g. retention)
* To test a potentially risky message on a smaller group of users
* To compare the performance of multiple variants of messages in a controlled way
## Choose cohort IDs and request an experiment
First you should decide on a cohort ID (this can be any arbitrary unique string) for each
individual group you need to segment for your experiment.
For example, if I want to test two variants of an FXA Snippet, I might have two cohort IDs,
`FXA_SNIPPET_V1` and `FXA_SNIPPET_V2`.
You will then [request](https://experimenter.services.mozilla.com/) a new "pref-flip" study with the Firefox Experiments team.
The preferences you will submit will be based on the cohort IDs you chose.
For the FXA Snippet example, your preference name would be `browser.newtabpage.activity-stream.asrouter.providers.snippets` and values would be:
Control (default value)
```json
{"id":"snippets","enabled":true,"type":"remote","url":"https://snippets.cdn.mozilla.net/%STARTPAGE_VERSION%/%NAME%/%VERSION%/%APPBUILDID%/%BUILD_TARGET%/%LOCALE%/release/%OS_VERSION%/%DISTRIBUTION%/%DISTRIBUTION_VERSION%/","updateCycleInMs":14400000}
```
Variant 1:
```json
{"id":"snippets", "cohort": "FXA_SNIPPET_V1", "enabled":true,"type":"remote","url":"https://snippets.cdn.mozilla.net/%STARTPAGE_VERSION%/%NAME%/%VERSION%/%APPBUILDID%/%BUILD_TARGET%/%LOCALE%/release/%OS_VERSION%/%DISTRIBUTION%/%DISTRIBUTION_VERSION%/","updateCycleInMs":14400000}
```
Variant 2:
```json
{"id":"snippets", "cohort": "FXA_SNIPPET_V1", "enabled":true,"type":"remote","url":"https://snippets.cdn.mozilla.net/%STARTPAGE_VERSION%/%NAME%/%VERSION%/%APPBUILDID%/%BUILD_TARGET%/%LOCALE%/release/%OS_VERSION%/%DISTRIBUTION%/%DISTRIBUTION_VERSION%/","updateCycleInMs":14400000}
```
## Add targeting to your messages
You must now check for the cohort ID in the `targeting` expression of the messages you want to include in your experiments.
For the previous example, you wold include the following to target the first cohort:
```json
{
"targeting": "providerCohorts.snippets == \"FXA_SNIPPET_V1\""
}
```
================================================
FILE: content-src/asrouter/docs/targeting-attributes.md
================================================
# Targeting attributes
When you create ASRouter messages such as snippets, contextual feature recommendations, or onboarding cards, you may choose to include **targeting information** with those messages.
Targeting information must be captured in [an expression](./targeting-guide.md) that has access to the following attributes. You may combine and compare any of these attributes as needed.
Please note that some targeting attributes require stricter controls on the telemetry than can be colleted, so when in doubt, ask for review.
## Available attributes
* [addonsInfo](#addonsinfo)
* [attributionData](#attributiondata)
* [browserSettings](#browsersettings)
* [currentDate](#currentdate)
* [devToolsOpenedCount](#devtoolsopenedcount)
* [isDefaultBrowser](#isdefaultbrowser)
* [firefoxVersion](#firefoxversion)
* [locale](#locale)
* [localeLanguageCode](#localelanguagecode)
* [needsUpdate](#needsupdate)
* [pinnedSites](#pinnedsites)
* [previousSessionEnd](#previoussessionend)
* [profileAgeCreated](#profileagecreated)
* [profileAgeReset](#profileagereset)
* [providerCohorts](#providercohorts)
* [region](#region)
* [searchEngines](#searchengines)
* [sync](#sync)
* [topFrecentSites](#topfrecentsites)
* [totalBookmarksCount](#totalbookmarkscount)
* [trailheadInterrupt](#trailheadinterrupt)
* [trailheadTriplet](#trailheadtriplet)
* [usesFirefoxSync](#usesfirefoxsync)
* [isFxAEnabled](#isFxAEnabled)
* [xpinstallEnabled](#xpinstallEnabled)
* [hasPinnedTabs](#haspinnedtabs)
* [hasAccessedFxAPanel](#hasaccessedfxapanel)
* [isWhatsNewPanelEnabled](#iswhatsnewpanelenabled)
* [isFxABadgeEnabled](#isfxabadgeenabled)
* [totalBlockedCount](#totalblockedcount)
* [recentBookmarks](#recentbookmarks)
* [userPrefs](#userprefs)
* [attachedFxAOAuthClients](#attachedfxaoauthclients)
* [platformName](#platformname)
* [scores](#scores)
* [scoreThreshold](#scorethreshold)
* [messageImpressions](#messageimpressions)
* [blockedCountByType](#blockedcountbytype)
## Detailed usage
### `addonsInfo`
Provides information about the add-ons the user has installed.
Note that the `name`, `userDisabled`, and `installDate` is only available if `isFullData` is `true` (this is usually not the case right at start-up).
**Due to an existing bug, `userDisabled` is not currently available**
#### Examples
* Has the user installed the unicorn addon?
```java
addonsInfo.addons["unicornaddon@mozilla.org"]
```
* Has the user installed and disabled the unicorn addon?
```java
addonsInfo.isFullData && addonsInfo.addons["unicornaddon@mozilla.org"].userDisabled
```
#### Definition
```ts
declare const addonsInfo: Promise;
interface AddonsInfoResponse {
// Does this include extra information requiring I/O?
isFullData: boolean;
// addonId should be something like activity-stream@mozilla.org
[addonId: string]: {
// Version of the add-on
version: string;
// (string) e.g. "extension"
type: AddonType;
// Version of the add-on
isSystem: boolean;
// Is the add-on a webextension?
isWebExtension: boolean;
// The name of the add-on
name: string;
// Is the add-on disabled?
// CURRENTLY UNAVAILABLE due to an outstanding bug
userDisabled: boolean;
// When was it installed? e.g. "2018-03-10T03:41:06.000Z"
installDate: string;
};
}
```
### `attributionData`
An object containing information on exactly how Firefox was downloaded
#### Examples
* Was the browser installed via the `"back_to_school"` campaign?
```java
attributionData && attributionData.campaign == "back_to_school"
```
#### Definition
```ts
declare const attributionData: AttributionCode;
interface AttributionCode {
// Descriptor for where the download started from
campaign: string,
// A source, like addons.mozilla.org, or google.com
source: string,
// The medium for the download, like if this was referral
medium: string,
// Additional content, like an addonID for instance
content: string
}
```
### `browserSettings`
Includes two properties:
* `attribution`, which indicates how Firefox was downloaded - DEPRECATED - please use [attributionData](#attributiondata)
* `update`, which has information about how Firefox updates
Note that attribution can be `undefined`, so you should check that it exists first.
#### Examples
* Is updating enabled?
```java
browserSettings.update.enabled
```
#### Definition
```ts
declare const browserSettings: {
attribution: undefined | {
// Referring partner domain, when install happens via a known partner
// e.g. google.com
source: string;
// category of the source, such as "organic" for a search engine
// e.g. organic
medium: string;
// identifier of the particular campaign that led to the download of the product
// e.g. back_to_school
campaign: string;
// identifier to indicate the particular link within a campaign
// e.g. https://mozilla.org/some-page
content: string;
},
update: {
// Is auto-downloading enabled?
autoDownload: boolean;
// What release channel, e.g. "nightly"
channel: string;
// Is updating enabled?
enabled: boolean;
}
}
```
### `currentDate`
The current date at the moment message targeting is checked.
#### Examples
* Is the current date after Oct 3, 2018?
```java
currentDate > "Wed Oct 03 2018 00:00:00"|date
```
#### Definition
```ts
declare const currentDate; ECMA262DateString;
// ECMA262DateString = Date.toString()
type ECMA262DateString = string;
```
### `devToolsOpenedCount`
Number of usages of the web console.
#### Examples
* Has the user opened the web console more than 10 times?
```java
devToolsOpenedCount > 10
```
#### Definition
```ts
declare const devToolsOpenedCount: number;
```
### `isDefaultBrowser`
Is Firefox the user's default browser?
#### Definition
```ts
declare const isDefaultBrowser: boolean;
```
### `firefoxVersion`
The major Firefox version of the browser
#### Examples
* Is the version of the browser greater than 63?
```java
firefoxVersion > 63
```
#### Definition
```ts
declare const firefoxVersion: number;
```
### `locale`
The current locale of the browser including country code, e.g. `en-US`.
#### Examples
* Is the locale of the browser either English (US) or German (Germany)?
```java
locale in ["en-US", "de-DE"]
```
#### Definition
```ts
declare const locale: string;
```
### `localeLanguageCode`
The current locale of the browser NOT including country code, e.g. `en`.
This is useful for matching all countries of a particular language.
#### Examples
* Is the locale of the browser any English locale?
```java
localeLanguageCode == "en"
```
#### Definition
```ts
declare const localeLanguageCode: string;
```
### `needsUpdate`
Does the client have the latest available version installed
```ts
declare const needsUpdate: boolean;
```
### `pinnedSites`
The sites (including search shortcuts) that are pinned on a user's new tab page.
#### Examples
* Has the user pinned any site on `foo.com`?
```java
"foo.com" in pinnedSites|mapToProperty("host")
```
* Does the user have a pinned `duckduckgo.com` search shortcut?
```java
"duckduckgo.com" in pinnedSites[.searchTopSite == true]|mapToProperty("host")
```
#### Definition
```ts
interface PinnedSite {
// e.g. https://foo.mozilla.com/foo/bar
url: string;
// e.g. foo.mozilla.com
host: string;
// is the pin a search shortcut?
searchTopSite: boolean;
}
declare const pinnedSites: Array
```
### `previousSessionEnd`
Timestamp of the previously closed session.
#### Definition
```ts
declare const previousSessionEnd: UnixEpochNumber;
// UnixEpochNumber is UNIX Epoch timestamp, e.g. 1522843725924
type UnixEpochNumber = number;
```
### `profileAgeCreated`
The date the profile was created as a UNIX Epoch timestamp.
#### Definition
```ts
declare const profileAgeCreated: UnixEpochNumber;
// UnixEpochNumber is UNIX Epoch timestamp, e.g. 1522843725924
type UnixEpochNumber = number;
```
### `profileAgeReset`
The date the profile was reset as a UNIX Epoch timestamp (if it was reset).
#### Examples
* Was the profile never reset?
```java
!profileAgeReset
```
#### Definition
```ts
// profileAgeReset can be undefined if the profile was never reset
// UnixEpochNumber is number, e.g. 1522843725924
declare const profileAgeReset: undefined | UnixEpochNumber;
// UnixEpochNumber is UNIX Epoch timestamp, e.g. 1522843725924
type UnixEpochNumber = number;
```
### `providerCohorts`
Information about cohort settings (from prefs, including shield studies) for each provider.
#### Examples
* Is the user in the "foo_test" cohort for snippets?
```java
providerCohorts.snippets == "foo_test"
```
#### Definition
```ts
declare const providerCohorts: {
[providerId: string]: string;
}
```
### `region`
Country code retrieved from `location.services.mozilla.com`. Can be `""` if request did not finish or encountered an error.
#### Examples
* Is the user in Canada?
```java
region == "CA"
```
#### Definition
```ts
declare const region: string;
```
### `searchEngines`
Information about the current and available search engines.
#### Examples
* Is the current default search engine set to google?
```java
searchEngines.current == "google"
```
#### Definition
```ts
declare const searchEngines: Promise;
interface SearchEnginesResponse: {
current: SearchEngineId;
installed: Array;
}
// This is an identifier for a search engine such as "google" or "amazondotcom"
type SearchEngineId = string;
```
### `sync`
Information about synced devices.
#### Examples
* Is at least 1 mobile device synced to this profile?
```java
sync.mobileDevices > 0
```
#### Definition
```ts
declare const sync: {
desktopDevices: number;
mobileDevices: number;
totalDevices: number;
}
```
### `topFrecentSites`
Information about the browser's top 25 frecent sites.
**Please note this is a restricted targeting property that influences what telemetry is allowed to be collected may not be used without review**
#### Examples
* Is mozilla.com in the user's top frecent sites with a frececy greater than 400?
```java
"mozilla.com" in topFrecentSites[.frecency >= 400]|mapToProperty("host")
```
#### Definition
```ts
declare const topFrecentSites: Promise>
interface TopSite {
// e.g. https://foo.mozilla.com/foo/bar
url: string;
// e.g. foo.mozilla.com
host: string;
frecency: number;
lastVisitDate: UnixEpochNumber;
}
// UnixEpochNumber is UNIX Epoch timestamp, e.g. 1522843725924
type UnixEpochNumber = number;
```
### `totalBookmarksCount`
Total number of bookmarks.
#### Definition
```ts
declare const totalBookmarksCount: number;
```
### `trailheadInterrupt`
(67.05+ only) Experiment branch for "interrupt" study
### `trailheadTriplet`
(67.05+ only) Experiment branch for "triplet" study
### `usesFirefoxSync`
Does the user use Firefox sync?
#### Definition
```ts
declare const usesFirefoxSync: boolean;
```
### `isFxAEnabled`
Does the user have Firefox sync enabled? The service could potentially be turned off [for enterprise builds](https://searchfox.org/mozilla-central/rev/b59a99943de4dd314bae4e44ab43ce7687ccbbec/browser/components/enterprisepolicies/Policies.jsm#327).
#### Definition
```ts
declare const isFxAEnabled: boolean;
```
### `xpinstallEnabled`
Pref used by system administrators to disallow add-ons from installed altogether.
#### Definition
```ts
declare const xpinstallEnabled: boolean;
```
### `hasPinnedTabs`
Does the user have any pinned tabs in any windows.
#### Definition
```ts
declare const hasPinnedTabs: boolean;
```
### `hasAccessedFxAPanel`
Boolean pref that gets set the first time the user opens the FxA toolbar panel
#### Definition
```ts
declare const hasAccessedFxAPanel: boolean;
```
### `isWhatsNewPanelEnabled`
Boolean pref that controls if the What's New panel feature is enabled
#### Definition
```ts
declare const isWhatsNewPanelEnabled: boolean;
```
### `isFxABadgeEnabled`
Boolean pref that controls if the FxA toolbar button is badged by Messaging System.
#### Definition
```ts
declare const isFxABadgeEnabled: boolean;
```
### `totalBlockedCount`
Total number of events from the content blocking database
#### Definition
```ts
declare const totalBlockedCount: number;
```
### `recentBookmarks`
An array of GUIDs of recent bookmarks as provided by [`NewTabUtils.getRecentBookmarks`](https://searchfox.org/mozilla-central/rev/e0b0c38ee83f99d3cf868bad525ace4a395039f1/toolkit/modules/NewTabUtils.jsm#1087)
#### Definition
```ts
interface Bookmark {
bookmarkGuid: string;
url: string;
title: string;
...
}
declare const recentBookmarks: Array
```
### `userPrefs`
Information about user facing prefs configurable from `about:preferences`.
#### Examples
```java
userPrefs.cfrFeatures == false
```
#### Definition
```ts
declare const userPrefs: {
cfrFeatures: boolean;
cfrAddons: boolean;
snippets: boolean;
}
```
### `attachedFxAOAuthClients`
Information about connected services associated with the FxA Account.
Return an empty array if no account is found or an error occurs.
#### Definition
```
interface OAuthClient {
// OAuth client_id of the service
// https://docs.telemetry.mozilla.org/datasets/fxa_metrics/attribution.html#service-attribution
id: string;
lastAccessedDaysAgo: number;
}
declare const attachedFxAOAuthClients: Promise
```
#### Examples
```javascript
{
id: "7377719276ad44ee",
name: "Pocket",
lastAccessTime: 1513599164000
}
```
### `platformName`
[Platform information](https://searchfox.org/mozilla-central/rev/05a22d864814cb1e4352faa4004e1f975c7d2eb9/toolkit/modules/AppConstants.jsm#156).
#### Definition
```
declare const platformName = "linux" | "win" | "macosx" | "android" | "other";
```
### `scores`
#### Definition
See more in [CFR Machine Learning Experiment](https://bugzilla.mozilla.org/show_bug.cgi?id=1594422).
```
declare const scores = { [cfrId: string]: number (integer); }
```
### `scoreThreshold`
#### Definition
See more in [CFR Machine Learning Experiment](https://bugzilla.mozilla.org/show_bug.cgi?id=1594422).
```
declare const scoreThreshold = integer;
```
### `messageImpressions`
Dictionary that maps message ids to impression timestamps. Timestamps are stored in
consecutive order. Can be used to detect first impression of a message, number of
impressions. Can be used in targeting to show a message if another message has been
seen.
Impressions are used for frequency capping so we only store them if the message has
`frequency` configured.
Impressions for badges might not work as expected: we add a badge for every opened
window so the number of impressions stored might be higher than expected. Additionally
not all badges have `frequency` cap so `messageImpressions` might not be defined.
Badge impressions should not be used for targeting.
#### Definition
```
declare const messageImpressions: { [key: string]: Array };
```
### `blockedCountByType`
Returns a breakdown by category of all blocked resources in the past 42 days.
#### Definition
```
declare const messageImpressions: { [key: string]: number };
```
#### Examples
```javascript
Object {
trackerCount: 0,
cookieCount: 34,
cryptominerCount: 0,
fingerprinterCount: 3,
socialCount: 2
}
```
================================================
FILE: content-src/asrouter/docs/targeting-guide.md
================================================
# Guide to targeting with JEXL
For a more in-depth explanation of JEXL syntax you can read the [Normady project docs](https://mozilla.github.io/normandy/user/filters.html?highlight=jexl).
### How to write JEXL targeting expressions
A message needs to contain the `targeting` property (JEXL string) which is evaluated against the provided attributes.
Examples:
```javascript
{
"id": "7864",
"content": {...},
// simple equality check
"targeting": "usesFirefoxSync == true"
}
{
"id": "7865",
"content": {...},
// using JEXL transforms and combining two attributes
"targeting": "usesFirefoxSync == true && profileAgeCreated > '2018-01-07'|date"
}
{
"id": "7866",
"content": {...},
// targeting addon information
"targeting": "addonsInfo.addons['activity-stream@mozilla.org'].name == 'Activity Stream'"
}
{
"id": "7866",
"content": {...},
// targeting based on time
"targeting": "currentDate > '2018-08-08'|date"
}
```
================================================
FILE: content-src/asrouter/docs/user-actions.md
================================================
# User Actions
A subset of actions are available to messages via fields like `button_action` for snippets, or `primary_action` for CFRs.
## Usage
For snippets, you should add the action type in `button_action` and any additional parameters in `button_action_args. For example:
```json
{
"button_action": "OPEN_ABOUT_PAGE",
"button_action_args": "config"
}
```
## Available Actions
### `OPEN_APPLICATIONS_MENU`
* args: (none)
Opens the applications menu.
### `OPEN_PRIVATE_BROWSER_WINDOW`
* args: (none)
Opens a new private browsing window.
### `OPEN_URL`
* args: `string` (a url)
Opens a given url.
Example:
```json
{
"button_action": "OPEN_URL",
"button_action_args": "https://foo.com"
}
```
### `OPEN_ABOUT_PAGE`
* args: `string` (a valid about page without the `about:` prefix)
Opens a given about page
Example:
```json
{
"button_action": "OPEN_ABOUT_PAGE",
"button_action_args": "config"
}
```
### `OPEN_PREFERENCES_PAGE`
* args: `string` (a category accessible via a `#`)
Opens `about:preferences` with an optional category accessible via a `#` in the URL (e.g. `about:preferences#home`).
Example:
```json
{
"button_action": "OPEN_PREFERENCES_PAGE",
"button_action_args": "home"
}
```
### `SHOW_FIREFOX_ACCOUNTS`
* args: (none)
Opens Firefox accounts sign-up page. Encodes some information that the origin was from snippets by default.
### `SHOW_MIGRATION_WIZARD`
* args: (none)
Opens import wizard to bring in settings and data from another browser.
### `PIN_CURRENT_TAB`
* args: (none)
Pins the currently focused tab.
### `ENABLE_FIREFOX_MONITOR`
* args:
```ts
{
url: string;
flowRequestParams: {
entrypoint: string;
utm_term: string;
form_type: string;
}
}
```
Opens an oauth flow to enable Firefox Monitor at a given `url` and adds Firefox metrics that user given a set of `flowRequestParams`.
### `url`
The URL should start with `https://monitor.firefox.com/oauth/init` and add various metrics tags as search params, including:
* `utm_source`
* `utm_campaign`
* `form_type`
* `entrypoint`
You should verify the values of these search params with whoever is doing the data analysis (e.g. Leif Oines).
### `flowRequestParams`
These params are used by Firefox to add information specific to that individual user to the final oauth URL. You should include:
* `entrypoint`
* `utm_term`
* `form_type`
The `entrypoint` and `form_type` values should match the encoded values in your `url`.
You should verify the values with whoever is doing the data analysis (e.g. Leif Oines).
### Example
```json
{
"button_action": "ENABLE_FIREFOX_MONITOR",
"button_action_args": {
"url": "https://monitor.firefox.com/oauth/init?utm_source=snippets&utm_campaign=monitor-snippet-test&form_type=email&entrypoint=newtab",
"flowRequestParams": {
"entrypoint": "snippets",
"utm_term": "monitor",
"form_type": "email"
}
}
}
```
================================================
FILE: content-src/asrouter/rich-text-strings.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/. */
import { FluentBundle } from "fluent";
/**
* Properties that allow rich text MUST be added to this list.
* key: the localization_id that should be used
* value: a property or array of properties on the message.content object
*/
const RICH_TEXT_CONFIG = {
text: ["text", "scene1_text"],
success_text: "success_text",
error_text: "error_text",
scene2_text: "scene2_text",
amo_html: "amo_html",
privacy_html: "scene2_privacy_html",
disclaimer_html: "scene2_disclaimer_html",
};
export const RICH_TEXT_KEYS = Object.keys(RICH_TEXT_CONFIG);
/**
* Generates an array of messages suitable for fluent's localization provider
* including all needed strings for rich text.
* @param {object} content A .content object from an ASR message (i.e. message.content)
* @returns {FluentBundle[]} A array containing the fluent message context
*/
export function generateBundles(content) {
const bundle = new FluentBundle("en-US");
RICH_TEXT_KEYS.forEach(key => {
const attrs = RICH_TEXT_CONFIG[key];
const attrsToTry = Array.isArray(attrs) ? [...attrs] : [attrs];
let string = "";
while (!string && attrsToTry.length) {
const attr = attrsToTry.pop();
string = content[attr];
}
bundle.addMessages(`${key} = ${string}`);
});
return [bundle];
}
================================================
FILE: content-src/asrouter/schemas/message-format.md
================================================
## Activity Stream Router message format
Field name | Type | Required | Description | Example / Note
--- | --- | --- | --- | ---
`id` | `string` | Yes | A unique identifier for the message that should not conflict with any other previous message | `ONBOARDING_1`
`template` | `string` | Yes | An id matching an existing Activity Stream Router template | [See example](https://github.com/mozilla/activity-stream/blob/33669c67c2269078a6d3d6d324fb48175d98f634/system-addon/content-src/message-center/templates/SimpleSnippet.jsx)
`content` | `object` | Yes | An object containing all variables/props to be rendered in the template. Subset of allowed tags detailed below. | [See example below](#html-subset)
`bundled` | `integer` | No | The number of messages of the same template this one should be shown with | [See example below](#a-bundled-message-example)
`order` | `integer` | No | If bundled with other messages of the same template, which order should this one be placed in? Defaults to 0 if no order is desired | [See example below](#a-bundled-message-example)
`campaign` | `string` | No | Campaign id that the message belongs to | `RustWebAssembly`
`targeting` | `string` `JEXL` | No | A [JEXL expression](http://normandy.readthedocs.io/en/latest/user/filter_expressions.html#jexl-basics) with all targeting information needed in order to decide if the message is shown | Not yet implemented, [Examples](#targeting-attributes)
`trigger` | `string` | No | An event or condition upon which the message will be immediately shown. This can be combined with `targeting`. Messages that define a trigger will not be shown during non-trigger-based passive message rotation.
`trigger.params` | `[string]` | No | A set of hostnames passed down as parameters to the trigger condition. Used to restrict the number of domains where the trigger/message is valid. | [See example below](#trigger-params)
`trigger.patterns` | `[string]` | No | A set of patterns that match multiple hostnames passed down as parameters to the trigger condition. Used to restrict the number of domains where the trigger/message is valid. | [See example below](#trigger-patterns)
`frequency` | `object` | No | A definition for frequency cap information for the message
`frequency.lifetime` | `integer` | No | The maximum number of lifetime impressions for the message.
`frequency.custom` | `array` | No | An array of frequency cap definition objects including `period`, a time period in milliseconds, and `cap`, a max number of impressions for that period.
### Message example
```javascript
{
id: "ONBOARDING_1",
template: "simple_snippet",
content: {
title: "Find it faster",
body: "Access all of your favorite search engines with a click. Search the whole Web or just one website from the search box."
},
targeting: "usesFirefoxSync && !addonsInfo.addons['activity-stream@mozilla.org']",
frequency: {
lifetime: 20,
custom: [{period: "daily", cap: 5}, {period: 3600000, cap: 1}]
}
}
```
### A Bundled Message example
The following 2 messages have a `bundled` property, indicating that they should be shown together, since they have the same template. The number `2` indicates that this message should be shown in a bundle of 2 messages of the same template. The order property defines that ONBOARDING_2 should be shown after ONBOARDING_3 in the bundle.
```javascript
{
id: "ONBOARDING_2",
template: "onboarding",
bundled: 2,
order: 2,
content: {
title: "Private Browsing",
body: "Browse by yourself. Private Browsing with Tracking Protection blocks online trackers that follow you around the web."
},
targeting: "",
trigger: "firstRun"
}
{
id: "ONBOARDING_3",
template: "onboarding",
bundled: 2,
order: 1,
content: {
title: "Find it faster",
body: "Access all of your favorite search engines with a click. Search the whole Web or just one website from the search box."
},
targeting: "",
trigger: "firstRun"
}
```
### HTML subset
The following tags are allowed in the content of the snippet: `i, b, u, strong, em, br`.
Links cannot be rendered using regular anchor tags because [Fluent does not allow for href attributes](https://github.com/projectfluent/fluent.js/blob/a03d3aa833660f8c620738b26c80e46b1a4edb05/fluent-dom/src/overlay.js#L13). They will be wrapped in custom tags, for example `link ` and the url will be provided as part of the payload:
```
{
"id": "7899",
"content": {
"text": "Use the CMD (CTRL) + T keyboard shortcut to open a new tab quickly! ",
"links": {
"cta": {
"url": "https://support.mozilla.org/en-US/kb/keyboard-shortcuts-perform-firefox-tasks-quickly"
}
}
}
}
```
If a tag that is not on the allowed is used, the text content will be extracted and displayed.
Grouping multiple allowed elements is not possible, only the first level will be used: `text ` will be interpreted as `text `.
### Trigger params
A set of hostnames that need to exactly match the location of the selected tab in order for the trigger to execute.
```
["github.com", "wwww.github.com"]
```
More examples in the [CFRMessageProvider](https://github.com/mozilla/activity-stream/blob/e76ce12fbaaac1182aa492b84fc038f78c3acc33/lib/CFRMessageProvider.jsm#L40-L47).
### Trigger patterns
A set of patterns that can match multiple hostnames. When the location of the selected tab matches one of the patterns it can execute a trigger.
```
["*://*.github.com"] // can match `github.com` but also match `https://gist.github.com/`
```
More [MatchPattern examples](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Match_patterns#Examples).
### Targeting attributes
(This section has moved to [targeting-attributes.md](../docs/targeting-attributes.md)).
================================================
FILE: content-src/asrouter/schemas/panel/cfr-fxa-bookmark.schema.json
================================================
{
"title": "CFRFxABookmark",
"description": "A message shown in the bookmark panel when user adds or edits a bookmark",
"version": "1.0.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": {
"description": "Shown at the top of the message in the largest font size.",
"oneOf": [
{
"allOf": [
{"$ref": "#/definitions/richText"},
{"description": "Message to be shown"}
]
},
{
"type": "object",
"properties": {
"string_id": {
"type": "string",
"description": "Fluent id of localized string"
}
},
"required": ["string_id"]
}
]
},
"text": {
"description": "Longest part of the message, below the title, provides explanation.",
"oneOf": [
{
"allOf": [
{"$ref": "#/definitions/richText"},
{"description": "Message to be shown"}
]
},
{
"type": "object",
"properties": {
"string_id": {
"type": "string",
"description": "Fluent id of localized string"
}
},
"required": ["string_id"]
}
]
},
"cta": {
"description": "Link shown at the bottom of the message, call to action",
"oneOf": [
{
"allOf": [
{"$ref": "#/definitions/richText"},
{"description": "Message to be shown"}
]
},
{
"type": "object",
"properties": {
"string_id": {
"type": "string",
"description": "Fluent id of localized string"
}
},
"required": ["string_id"]
}
]
},
"info_icon": {
"type": "object",
"description": "The small icon displayed in the top right corner of the panel. Not configurable, only the tooltip text." ,
"properties": {
"tooltiptext": {
"oneOf": [
{
"allOf": [
{"$ref": "#/definitions/plainText"},
{"description": "Message to be shown"}
]
},
{
"type": "object",
"properties": {
"string_id": {
"type": "string",
"description": "Fluent id of localized string"
}
},
"required": ["string_id"]
}
]
}
},
"required": ["tooltiptext"]
},
"close_button": {
"type": "object",
"description": "The small dissmiss icon displayed in the top right corner of the message. Not configurable, only the tooltip text." ,
"properties": {
"tooltiptext": {
"oneOf": [
{
"allOf": [
{"$ref": "#/definitions/plainText"},
{"description": "Message to be shown"}
]
},
{
"type": "object",
"properties": {
"string_id": {
"type": "string",
"description": "Fluent id of localized string"
}
},
"required": ["string_id"]
}
]
}
},
"required": ["tooltiptext"]
},
"color": {
"description": "Message text color",
"allOf": [
{"$ref": "#/definitions/plainText"},
{"description": "Valid CSS color"}
]
},
"background_color_1": {
"description": "Configurable background color through CSS gradient",
"allOf": [
{"$ref": "#/definitions/plainText"},
{"description": "Valid CSS color"}
]
},
"background_color_2": {
"description": "Configurable background color through CSS gradient",
"allOf": [
{"$ref": "#/definitions/plainText"},
{"description": "Valid CSS color"}
]
}
},
"additionalProperties": false,
"required": ["title", "text", "cta", "info_icon"]
}
================================================
FILE: content-src/asrouter/schemas/provider-response.schema.json
================================================
{
"title": "ProviderResponse",
"description": "A response object for remote providers of AS Router",
"type": "object",
"version": "6.1.0",
"properties": {
"messages": {
"type": "array",
"description": "An array of router messages",
"items": {
"title": "RouterMessage",
"description": "A definition of an individual message",
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "A unique identifier for the message that should not conflict with any other previous message"
},
"template": {
"type": "string",
"description": "An id matching an existing Activity Stream Router template",
"enum": ["simple_snippet"]
},
"bundled": {
"type": "integer",
"description": "The number of messages of the same template this one should be shown with (optional)"
},
"order": {
"type": "integer",
"minimum": 0,
"description": "If bundled with other messages of the same template, which order should this one be placed in? (optional - defaults to 0)"
},
"content": {
"type": "object",
"description": "An object containing all variables/props to be rendered in the template. See individual template schemas for details."
},
"targeting": {
"type": "string",
"description": "A JEXL expression representing targeting information"
},
"personalized": {
"type": "boolean",
"description": "Is a personalized score applied to the provider's messages?"
},
"personalizedModelVersion": {
"type": "string",
"description": "The version of the model use for personalization"
},
"trigger": {
"type": "object",
"description": "An action to trigger potentially showing the message",
"properties": {
"id": {
"type": "string",
"description": "A string identifying the trigger action",
"enum": ["firstRun", "openURL"]
},
"params": {
"type": "array",
"description": "An optional array of string parameters for the trigger action",
"items": {
"type": "string",
"description": "A parameter for the trigger action"
}
}
},
"required": ["id"]
},
"frequency": {
"type": "object",
"description": "An object containing frequency cap information for a message.",
"properties": {
"lifetime": {
"type": "integer",
"description": "The maximum lifetime impressions for a message.",
"minimum": 1,
"maximum": 100
},
"custom": {
"type": "array",
"description": "An array of custom frequency cap definitions.",
"items": {
"description": "A frequency cap definition containing time and max impression information",
"type": "object",
"properties": {
"period": {
"oneOf": [
{
"type": "integer",
"description": "Period of time in milliseconds (e.g. 86400000 for one day)"
},
{
"type": "string",
"description": "One of a preset list of short forms for period of time (e.g. 'daily' for one day)",
"enum": ["daily"]
}
]
},
"cap": {
"type": "integer",
"description": "The maximum impressions for the message within the defined period.",
"minimum": 1,
"maximum": 100
}
},
"required": ["period", "cap"]
}
}
}
}
},
"required": ["id", "template", "content"]
}
}
},
"required": ["messages"]
}
================================================
FILE: content-src/asrouter/template-utils.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/. */
export function safeURI(url) {
if (!url) {
return "";
}
const { protocol } = new URL(url);
const isAllowed = [
"http:",
"https:",
"data:",
"resource:",
"chrome:",
].includes(protocol);
if (!isAllowed) {
console.warn(`The protocol ${protocol} is not allowed for template URLs.`); // eslint-disable-line no-console
}
return isAllowed ? url : "";
}
================================================
FILE: content-src/asrouter/templates/CFR/templates/ExtensionDoorhanger.schema.json
================================================
{
"title": "ExtensionDoorhanger",
"description": "A template with a heading, addon icon, title and description. No markup allowed.",
"version": "1.0.0",
"type": "object",
"definitions": {
"plainText": {
"description": "Plain text (no HTML allowed)",
"type": "string"
},
"linkUrl": {
"description": "Target for links or buttons",
"type": "string",
"format": "uri"
}
},
"properties": {
"layout": {
"type": "string",
"description": "The layout style of the pop-over."
},
"category": {
"type": "string",
"description": "Attribute used for different groups of messages from the same provider"
},
"layout": {
"type": "string",
"description": "Attribute used for different groups of messages from the same provider",
"enum": ["short_message", "message_and_animation", "icon_and_message", "addon_recommendation"]
},
"anchor_id": {
"type": "string",
"description": "A DOM element ID that the pop-over will be anchored."
},
"bucket_id": {
"type": "string",
"description": "A bucket identifier for the addon. This is used in order to anonymize telemetry for history-sensitive targeting."
},
"skip_address_bar_notifier": {
"type": "boolean",
"description": "Skip the 'Recommend' notifier and show directly."
},
"notification_text": {
"description": "The text in the small blue chicklet that appears in the URL bar. This can be a reference to a localized string in Firefox or just a plain string.",
"oneOf": [
{
"type": "string",
"description": "Message shown in the location bar notification."
},
{
"type": "object",
"properties": {
"string_id": {
"type": "string",
"description": "Id of localized string for the location bar notification."
}
},
"required": ["string_id"]
}
]
},
"info_icon": {
"type": "object",
"description": "The small icon displayed in the top right corner of the pop-over. Should be 19x19px, svg or png. Defaults to a small question mark." ,
"properties": {
"label": {
"oneOf": [
{
"type": "object",
"properties": {
"attributes": {
"type": "object",
"properties": {
"tooltiptext": {
"type": "string",
"description": "Text for button tooltip used to provide information about the doorhanger."
}
},
"required": ["tooltiptext"]
}
},
"required": ["attributes"]
},
{
"type": "object",
"properties": {
"string_id": {
"type": "string",
"description": "Id of localized string used to provide information about the doorhanger."
}
},
"required": ["string_id"]
}
]
},
"sumo_path": {
"type": "string",
"description": "Last part of the path in the URL to the support page with the information about the doorhanger.",
"examples": ["extensionpromotions", "extensionrecommendations"]
}
}
},
"learn_more": {
"type": "string",
"description": "Last part of the path in the SUMO URL to the support page with the information about the doorhanger.",
"examples": ["extensionpromotions", "extensionrecommendations"]
},
"heading_text": {
"description": "The larger heading text displayed in the pop-over. This can be a reference to a localized string in Firefox or just a plain string.",
"oneOf": [
{
"type": "string",
"description": "The message displayed in the title of the extension doorhanger"
},
{
"type": "object",
"properties": {
"string_id": {
"type": "string"
}
},
"required": ["string_id"],
"description": "Id of localized string for extension doorhanger title"
}
]
},
"icon": {
"description": "The icon displayed in the pop-over. Should be 32x32px or 64x64px and png/svg.",
"allOf": [
{"$ref": "#/definitions/linkUrl"},
{"description": "Icon associated with the message"}
]
},
"icon_dark_theme": {
"type": "string",
"description": "Pop-over icon, dark theme variant. Should be 32x32px or 64x64px and png/svg."
},
"icon_class": {
"type": "string",
"description": "CSS class of the pop-over icon."
},
"addon": {
"description": "Addon information including AMO URL.",
"type": "object",
"properties": {
"id": {
"allOf": [
{"$ref": "#/definitions/plainText"},
{"description": "Unique addon ID"}
]
},
"title": {
"allOf": [
{"$ref": "#/definitions/plainText"},
{"description": "Addon name"}
]
},
"author": {
"allOf": [
{"$ref": "#/definitions/plainText"},
{"description": "Addon author"}
]
},
"icon": {
"description": "The icon displayed in the pop-over. Should be 64x64px and png/svg.",
"allOf": [
{"$ref": "#/definitions/linkUrl"},
{"description": "Addon icon"}
]
},
"rating": {
"type": "number",
"minimum": 0,
"maximum": 5,
"description": "Star rating"
},
"users": {
"type": "integer",
"minimum": 0,
"description": "Installed users"
},
"amo_url": {
"allOf": [
{"$ref": "#/definitions/linkUrl"},
{"description": "Link that offers more information related to the addon."}
]
}
},
"required": ["title", "author", "icon", "amo_url"]
},
"text": {
"description": "The body text displayed in the pop-over. This can be a reference to a localized string in Firefox or just a plain string.",
"oneOf": [
{
"type": "string",
"description": "Description message of the addon."
},
{
"type": "object",
"properties": {
"string_id": {
"type": "string",
"description": "Id of string to localized addon description"
}
},
"required": ["string_id"]
}
]
},
"descriptionDetails": {
"description": "Additional information and steps on how to use",
"type": "object",
"properties": {
"steps": {
"description": "Array of messages or string_ids",
"type": "array",
"items": {
"type": "object",
"properties": {
"string_id": {
"type": "string",
"description": "Id of string to localized addon description"
}
},
"required": ["string_id"]
}
}
},
"required": ["steps"]
},
"buttons": {
"description": "The label and functionality for the buttons in the pop-over.",
"type": "object",
"properties": {
"primary": {
"type": "object",
"properties": {
"label": {
"type": "object",
"oneOf": [
{
"properties": {
"value": {
"allOf": [
{"$ref": "#/definitions/plainText"},
{"description": "Button label override used when a localized version is not available."}
]
},
"attributes": {
"type": "object",
"properties": {
"accesskey": {
"type": "string",
"description": "A single character to be used as a shortcut key for the secondary button. This should be one of the characters that appears in the button label."
}
},
"required": ["accesskey"],
"description": "Button attributes."
}
},
"required": ["value", "attributes"]
},
{
"properties": {
"string_id": {
"allOf": [
{"$ref": "#/definitions/plainText"},
{"description": "Id of localized string for button"}
]
}
},
"required": ["string_id"]
}
],
"description": "Id of localized string or message override."
},
"action": {
"type": "object",
"properties": {
"type": {
"type": "string",
"description": "Action dispatched by the button."
},
"data": {
"properties": {
"url": {
"type": "null",
"$comment": "This is dynamically generated from the addon.id. See CFRPageActions.jsm",
"description": "URL used in combination with the primary action dispatched."
}
}
}
}
}
},
"secondary": {
"type": "object",
"properties": {
"label": {
"type": "object",
"oneOf": [
{
"properties": {
"value": {
"allOf": [
{"$ref": "#/definitions/plainText"},
{"description": "Button label override used when a localized version is not available."}
]
},
"attributes": {
"type": "object",
"properties": {
"accesskey": {
"type": "string",
"description": "A single character to be used as a shortcut key for the secondary button. This should be one of the characters that appears in the button label."
}
},
"required": ["accesskey"],
"description": "Button attributes."
}
},
"required": ["value", "attributes"]
},
{
"properties": {
"string_id": {
"allOf": [
{"$ref": "#/definitions/plainText"},
{"description": "Id of localized string for button"}
]
}
},
"required": ["string_id"]
}
],
"description": "Id of localized string or message override."
},
"action": {
"type": "object",
"properties": {
"type": {
"type": "string",
"description": "Action dispatched by the button."
},
"data": {
"properties": {
"url": {
"allOf": [
{"$ref": "#/definitions/linkUrl"},
{"description": "URL used in combination with the primary action dispatched."}
]
}
}
}
}
}
}
}
}
}
}
},
"additionalProperties": false,
"required": ["layout", "category", "bucket_id", "notification_text", "heading_text", "text", "buttons"]
}
================================================
FILE: content-src/asrouter/templates/EOYSnippet/EOYSnippet.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 "./EOYSnippet.schema.json";
import { SimpleSnippet } from "../SimpleSnippet/SimpleSnippet";
class EOYSnippetBase extends React.PureComponent {
constructor(props) {
super(props);
this.handleSubmit = this.handleSubmit.bind(this);
}
/**
* setFrequencyValue - `frequency` form parameter value should be `monthly`
* if `monthly-checkbox` is selected or `single` otherwise
*/
setFrequencyValue() {
const frequencyCheckbox = this.refs.form.querySelector("#monthly-checkbox");
if (frequencyCheckbox.checked) {
this.refs.form.querySelector("[name='frequency']").value = "monthly";
}
}
handleSubmit(event) {
event.preventDefault();
this.setFrequencyValue();
this.refs.form.submit();
if (!this.props.content.do_not_autoblock) {
this.props.onBlock();
}
}
renderDonations() {
const fieldNames = ["first", "second", "third", "fourth"];
const numberFormat = new Intl.NumberFormat(
this.props.content.locale || navigator.language,
{
style: "currency",
currency: this.props.content.currency_code,
minimumFractionDigits: 0,
}
);
// Default to `second` button
const { selected_button } = this.props.content;
const btnStyle = {
color: this.props.content.button_color,
backgroundColor: this.props.content.button_background_color,
};
const donationURLParams = [];
const paramsStartIndex = this.props.content.donation_form_url.indexOf("?");
for (const entry of new URLSearchParams(
this.props.content.donation_form_url.slice(paramsStartIndex)
).entries()) {
donationURLParams.push(entry);
}
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 = (
);
return (
);
}
render() {
const { content } = this.props;
return (
{content.header}
{content.title}
{this.renderText()}
{" "}
{" "}
{content.primary_button.label}{" "}
{" "}
{content.secondary_button.label}{" "}
);
}
}
================================================
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 (
{props.content.button_label}
);
}
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 (
{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 (
{props.content.button_label}
);
}
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}
{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 : (
{retryButtonText}
)}
);
}
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 ? (
) : null}
{content.scene2_title && (
{content.scene2_title}
)}{" "}
{content.scene2_text && (
)}
);
}
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 => (
))}
);
}
}
Trailhead.defaultProps = {
flowParams: { deviceId: "", flowId: "", flowBeginTime: "" },
};
================================================
FILE: content-src/asrouter/templates/Trailhead/_Trailhead.scss
================================================
.trailhead {
$benefit-icon-size: 62px;
$benefit-icon-spacing: $benefit-icon-size + 12px;
$benefit-icon-size-small: 40px;
$benefit-icon-spacing-small: $benefit-icon-size-small + 12px;
$responsive-breakpoint: 850px;
$logo-size: 100px;
background: url('#{$image-path}trailhead/accounts-form-bg.jpg') bottom / cover;
color: $white;
height: auto;
a {
color: $white;
text-decoration: underline;
}
input,
button {
border-radius: 4px;
padding: 10px;
}
.trailheadInner {
$content-spacing: 40px;
display: grid;
grid-column-gap: $content-spacing;
grid-template-columns: 5fr 3fr;
padding: $content-spacing 60px;
}
.trailheadContent {
h1 {
font-size: 36px;
font-weight: 200;
line-height: 46px;
margin: 0;
}
.trailheadLearn {
display: block;
margin-top: 30px;
@media (min-width: $responsive-breakpoint) {
margin-inline-start: $benefit-icon-spacing;
}
}
}
.trailhead-join-form {
background: url('#{$image-path}trailhead/firefox-logo.png') top center / $logo-size no-repeat;
color: $white;
min-width: 260px;
padding-top: $logo-size;
}
&.syncCohort {
left: calc(50% - 430px);
width: 860px;
@media (max-width: 860px) {
left: 0;
width: 100%;
}
.trailheadInner {
grid-template-columns: 4fr 3fr;
}
.trailheadContent {
.trailheadBenefits {
background: url('#{$image-path}sync-devices-trailhead.svg');
background-position: center center;
background-repeat: no-repeat;
background-size: contain;
height: 200px;
margin-inline-end: 60px;
}
.trailheadLearn {
margin-inline-start: 0;
}
}
}
.trailheadBenefits {
padding: 0;
li {
background-position: left 6px;
background-repeat: no-repeat;
background-size: $benefit-icon-size-small;
-moz-context-properties: fill;
fill: $blue-50;
list-style: none;
padding-top: 8px;
@media (min-width: $responsive-breakpoint) {
background-position-y: 4px;
background-size: $benefit-icon-size;
margin-inline-end: 60px;
padding-inline-start: $benefit-icon-spacing;
}
&:dir(rtl) {
background-position-x: right;
}
&.knowledge,
&.monitor {
background-image: url('#{$image-path}trailhead/benefit-knowledge.png');
}
&.lockwise,
&.privacy {
background-image: url('#{$image-path}trailhead/benefit-privacy.png');
}
&.products {
background-image: url('#{$image-path}trailhead/benefit-products.png');
}
&.sync {
background-image: url('#{$image-path}trailhead/benefit-sync.png');
}
}
h2 {
text-align: start;
line-height: inherit;
color: $violet-20;
font-size: 22px;
font-weight: 400;
margin: 0 0 4px;
padding-inline-start: $benefit-icon-spacing-small;
@media (min-width: $responsive-breakpoint) {
padding-inline-start: 0;
}
}
p {
color: $white;
font-size: 15px;
line-height: 22px;
margin: 4px 0 15px;
}
}
.trailheadStart {
border: 1px solid $white-50;
cursor: pointer;
display: block;
font-size: 15px;
font-weight: 400;
margin: 0 auto 40px;
min-width: 300px;
padding: 14px;
&:hover,
&:focus {
background-color: $trailhead-blue-60;
border-color: transparent;
}
&:focus {
outline: dotted 1px;
}
&:active {
background-color: $trailhead-blue-70;
}
}
.trailheadInner,
.trailheadStart {
animation: fadeIn 0.4s;
}
}
.trailheadCards {
background: var(--trailhead-cards-background-color);
overflow: hidden;
text-align: center;
// Note: should match TRANSITION_LENGTH in FirstRun.jsx
transition: max-height 0.5s $photon-easing;
// This is needed for the transition to work, but will cut off content at the smallest breakpoint
@media (min-width: $break-point-medium) {
max-height: 1000px;
}
&.collapsed {
max-height: 0;
}
h1 {
font-size: 36px;
font-weight: 200;
margin: 0 0 40px;
color: var(--trailhead-header-text-color);
}
}
.trailheadCardsInner {
margin: auto;
padding: 40px $section-horizontal-padding;
@media (min-width: $break-point-medium) {
width: $wrapper-max-width-medium;
}
@media (min-width: $break-point-large) {
width: $wrapper-max-width-large;
}
@media (min-width: $break-point-widest) {
width: $wrapper-max-width-widest;
}
.icon-dismiss {
border: 0;
cursor: pointer;
inset-inline-end: 15px;
padding: 15px;
opacity: 0.75;
position: absolute;
top: 15px;
&:hover,
&:focus {
background-color: var(--newtab-element-hover-color);
}
}
}
.trailheadCardGrid {
display: grid;
grid-gap: $base-gutter;
margin: 0;
opacity: 0;
transition: opacity 0.4s;
transition-delay: 0.1s;
grid-auto-rows: 1fr;
&.show {
opacity: 1;
}
@media (min-width: $break-point-medium) {
grid-template-columns: repeat(auto-fit, $card-width);
}
@media (min-width: $break-point-widest) {
grid-template-columns: repeat(auto-fit, $card-width-large);
}
}
.trailheadCard {
position: relative;
background: var(--newtab-card-background-color);
border-radius: 4px;
box-shadow: var(--newtab-card-shadow);
font-size: 13px;
padding: 20px 40px 60px;
@media (max-width: 865px) {
padding: 20px 40px;
}
@media (min-width: $break-point-widest) {
font-size: 15px;
}
.onboardingTitle {
font-weight: normal;
color: var(--newtab-text-primary-color);
margin: 10px 0 4px;
font-size: 15px;
@media (min-width: $break-point-widest) {
font-size: 18px;
}
}
.onboardingText {
margin: 0 0 60px;
color: var(--newtab-text-conditional-color);
line-height: 1.5;
font-weight: 200;
}
.onboardingButton {
color: var(--newtab-text-conditional-color);
background: var(--trailhead-card-button-background-color);
border: 0;
margin: 14px;
min-width: 70%;
padding: 6px 14px;
white-space: pre-wrap;
&:focus,
&:hover {
box-shadow: none;
background: var(--trailhead-card-button-background-hover-color);
}
&:focus {
outline: dotted 1px;
}
&:active {
background: var(--trailhead-card-button-background-active-color);
}
}
.onboardingButtonContainer {
position: absolute;
bottom: 16px;
left: 0;
width: 100%;
text-align: center;
}
}
.activity-stream.welcome {
overflow: hidden;
}
.inline-onboarding {
&.activity-stream.welcome {
overflow-y: hidden;
}
.trailhead.modalOverlayInner {
position: absolute;
}
.outer-wrapper {
position: relative;
display: block;
.prefs-button {
button {
position: absolute;
}
}
}
.asrouter-toggle {
position: absolute;
}
}
.error {
display: none;
}
.error.active {
display: block;
padding: 5px 12px;
animation: fade-down 450ms;
font-size: 12px;
font-weight: 500;
color: $white;
background-color: $red-60;
position: absolute;
inset-inline-start: 50px;
top: -28px;
border-radius: 2px;
&::before {
inset-inline-start: 12px;
background: $red-60;
bottom: -8px;
content: '.';
height: 16px;
position: absolute;
text-indent: -999px;
transform: rotate(45deg);
white-space: nowrap;
width: 16px;
z-index: -1;
}
}
@keyframes fade-down {
0% {
opacity: 0;
transform: translateY(-15px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
================================================
FILE: content-src/asrouter/templates/template-manifest.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 { EOYSnippet } from "./EOYSnippet/EOYSnippet";
import { FXASignupSnippet } from "./FXASignupSnippet/FXASignupSnippet";
import { NewsletterSnippet } from "./NewsletterSnippet/NewsletterSnippet";
import { SendToDeviceSnippet } from "./SendToDeviceSnippet/SendToDeviceSnippet";
import { SimpleBelowSearchSnippet } from "./SimpleBelowSearchSnippet/SimpleBelowSearchSnippet";
import { SimpleSnippet } from "./SimpleSnippet/SimpleSnippet";
// Key names matching schema name of templates
export const SnippetsTemplates = {
simple_snippet: SimpleSnippet,
newsletter_snippet: NewsletterSnippet,
fxa_signup_snippet: FXASignupSnippet,
send_to_device_snippet: SendToDeviceSnippet,
eoy_snippet: EOYSnippet,
simple_below_search_snippet: SimpleBelowSearchSnippet,
};
================================================
FILE: content-src/components/A11yLinkButton/A11yLinkButton.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 function A11yLinkButton(props) {
// function for merging classes, if necessary
let className = "a11y-link-button";
if (props.className) {
className += ` ${props.className}`;
}
return (
{props.children}
);
}
================================================
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 collapse/open ;
}
}
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 (
Type
{component.type}
Width
{width}
{component.feed && this.renderFeed(component.feed)}
);
}
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 (
Restore Pref Defaults
{" "}
Expire Cache
{prefToggles.map(pref => (
))}
Endpoint variant
You can also change this manually by changing this pref:{" "}
browser.newtabpage.activity-stream.discoverystream.config
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 ? "Unblock" : "Block"}
{isBlocked ? null : (
Show
)}
({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 */}
all providers
{this.state.providers.map(provider => (
{provider.id}
))}
);
}
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 (
);
}
renderPasteModal() {
if (!this.state.pasteFromClipboard) {
return null;
}
const errors =
this.refs.targetingParamsEval &&
this.refs.targetingParamsEval.innerText.length;
return (
);
}
renderTargetingParameters() {
// There was no error and the result is truthy
const success =
this.state.evaluationStatus.success &&
!!this.state.evaluationStatus.result;
const result =
JSON.stringify(this.state.evaluationStatus.result, null, 2) ||
"(Empty result)";
return (
);
}
onChangeAttributionParameters(event) {
const { name, value } = event.target;
this.setState(({ attributionParameters }) => {
const updatedParameters = { ...attributionParameters };
updatedParameters[name] = value;
return { attributionParameters: updatedParameters };
});
}
setAttribution(e) {
ASRouterUtils.sendMessage({
type: "FORCE_ATTRIBUTION",
data: this.state.attributionParameters,
});
}
renderPocketStory(story) {
return (
{story.guid}
{JSON.stringify(story, null, 2)}
);
}
renderPocketStories() {
const { rows } =
this.props.Sections.find(Section => Section.id === "topstories") || {};
return (
{rows && rows.map(story => this.renderPocketStory(story))}
);
}
renderDiscoveryStream() {
const { config } = this.props.DiscoveryStream;
return (
Enabled
{config.enabled ? "yes" : "no"}
Endpoint
{config.endpoint || "(empty)"}
);
}
renderAttributionParamers() {
return (
Attribution Parameters
{" "}
This forces the browser to set some attribution parameters, useful for
testing the Return To AMO feature. Clicking on 'Force Attribution',
with the default values in each field, will demo the Return To AMO
flow with the addon called 'Iridium for Youtube'. If you wish to try
different attribution parameters, enter them in the text boxes. If you
wish to try a different addon with the Return To AMO flow, make sure
the 'content' text box has the addon GUID, then click 'Force
Attribution'.
);
}
renderErrorMessage({ id, errors }) {
const providerId = {id} ;
// .reverse() so that the last error (most recent) is first
return errors
.map(({ error, timestamp }, cellKey) => (
{cellKey === errors.length - 1 ? providerId : null}
{error.message}
{relativeTime(timestamp)}
))
.reverse();
}
renderErrors() {
const providersWithErrors =
this.state.providers &&
this.state.providers.filter(p => p.errors && p.errors.length);
if (providersWithErrors && providersWithErrors.length) {
return (
Provider ID
Message
Timestamp
{providersWithErrors.map(this.renderErrorMessage)}
);
}
return No errors
;
}
renderTrailheadInfo() {
const { trailheadInterrupt, trailheadTriplet } = this.state.trailhead;
return (
Interrupt branch
{trailheadInterrupt}
Triplet branch
{trailheadTriplet}
);
}
getSection() {
const [section] = this.props.location.routes;
switch (section) {
case "targeting":
return (
Targeting Utilities
Expire Cache
{" "}
(This expires the cache in ASR Targeting for bookmarks and top
sites)
{this.renderTargetingParameters()}
{this.renderAttributionParamers()}
);
case "pocket":
return (
Pocket
{this.renderPocketStories()}
);
case "ds":
return (
Discovery Stream
);
case "errors":
return (
ASRouter Errors
{this.renderErrors()}
);
default:
return (
Message Providers{" "}
Restore default prefs
{this.state.providers ? this.renderProviders() : null}
Trailhead
{this.renderTrailheadInfo()}
Messages
{this.renderMessageFilter()}
{this.renderMessages()}
{this.renderPasteModal()}
);
}
}
render() {
return (
AS Router Admin
{" "}
Need help using these tools? Check out our{" "}
documentation
{this.getSection()}
);
}
}
export class CollapseToggle extends React.PureComponent {
constructor(props) {
super(props);
this.onCollapseToggle = this.onCollapseToggle.bind(this);
this.state = { collapsed: false };
}
get renderAdmin() {
const { props } = this;
return (
props.location.hash &&
(props.location.hash.startsWith("#asrouter") ||
props.location.hash.startsWith("#devtools"))
);
}
onCollapseToggle(e) {
e.preventDefault();
this.setState(state => ({ collapsed: !state.collapsed }));
}
setBodyClass() {
if (this.renderAdmin && !this.state.collapsed) {
global.document.body.classList.add("no-scroll");
} else {
global.document.body.classList.remove("no-scroll");
}
}
componentDidMount() {
this.setBodyClass();
}
componentDidUpdate() {
this.setBodyClass();
}
componentWillUnmount() {
global.document.body.classList.remove("no-scroll");
}
render() {
const { props } = this;
const { renderAdmin } = this;
const isCollapsed = this.state.collapsed || !renderAdmin;
const label = `${isCollapsed ? "Expand" : "Collapse"} devtools`;
return (
{renderAdmin ? (
) : null}
);
}
}
const _ASRouterAdmin = props => (
);
export const ASRouterAdmin = connect(state => ({
Sections: state.Sections,
DiscoveryStream: state.DiscoveryStream,
Prefs: state.Prefs,
}))(_ASRouterAdmin);
================================================
FILE: content-src/components/ASRouterAdmin/ASRouterAdmin.scss
================================================
.asrouter-toggle {
position: fixed;
top: 15px;
right: 48px;
border: 0;
background: none;
z-index: 1;
border-radius: 2px;
.icon-devtools {
background-image: url('chrome://browser/skin/developer.svg');
padding: 15px;
}
&:hover {
background: var(--newtab-element-hover-color);
}
&.expanded {
background: $black-20;
}
}
.asrouter-admin {
position: fixed;
top: 0;
left: 0;
width: 100%;
background: var(--newtab-background-color);
height: 100%;
overflow-y: scroll;
$border-color: var(--newtab-border-secondary-color);
$monospace: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Mono', 'Droid Sans Mono', 'Source Code Pro', monospace;
$sidebar-width: 240px;
margin: 0 auto;
font-size: 14px;
padding-left: $sidebar-width;
color: var(--newtab-text-primary-color);
&.collapsed {
display: none;
}
.sidebar {
inset-inline-start: 0;
position: fixed;
width: $sidebar-width;
padding: 30px 20px;
ul {
margin: 0;
padding: 0;
list-style: none;
}
li a {
padding: 10px 34px;
display: block;
color: var(--lwt-sidebar-text-color);
&:hover {
background: var(--newtab-textbox-background-color);
}
}
}
h1 {
font-weight: 200;
font-size: 32px;
}
h2 .button {
font-size: 14px;
padding: 6px 12px;
margin-inline-start: 5px;
margin-bottom: 0;
}
table {
border-collapse: collapse;
width: 100%;
&.minimal-table {
border-collapse: collapse;
td {
padding: 8px;
border: 1px solid $border-color;
}
td:first-child {
width: 1%;
white-space: nowrap;
}
td:not(:first-child) {
font-family: $monospace;
}
}
&.errorReporting {
tr {
border: 1px solid var(--newtab-textbox-background-color);
}
td {
padding: 4px;
&[rowspan] {
border: 1px solid var(--newtab-textbox-background-color);
}
}
}
}
.sourceLabel {
background: var(--newtab-textbox-background-color);
padding: 2px 5px;
border-radius: 3px;
&.isDisabled {
background: $email-input-invalid;
color: $red-60;
}
}
.message-item {
&:first-child td {
border-top: 1px solid $border-color;
}
td {
vertical-align: top;
border-bottom: 1px solid $border-color;
padding: 8px;
&.min {
width: 1%;
white-space: nowrap;
}
&:first-child {
border-left: 1px solid $border-color;
}
&:last-child {
border-right: 1px solid $border-color;
}
}
&.blocked {
.message-id,
.message-summary {
opacity: 0.5;
}
.message-id {
opacity: 0.5;
}
}
.message-id {
font-family: $monospace;
font-size: 12px;
}
}
.providerUrl {
font-size: 12px;
}
pre {
background: var(--newtab-textbox-background-color);
margin: 0;
padding: 8px;
font-size: 12px;
max-width: 750px;
overflow: auto;
font-family: $monospace;
}
.errorState {
border: 1px solid $red-60;
}
.helpLink {
padding: 10px;
display: flex;
background: $black-10;
border-radius: 3px;
a {
text-decoration: underline;
}
}
.ds-component {
margin-bottom: 20px;
}
.modalOverlayInner {
height: 80%;
}
}
================================================
FILE: content-src/components/ASRouterAdmin/SimpleHashRouter.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 SimpleHashRouter extends React.PureComponent {
constructor(props) {
super(props);
this.onHashChange = this.onHashChange.bind(this);
this.state = { hash: global.location.hash };
}
onHashChange() {
this.setState({ hash: global.location.hash });
}
componentWillMount() {
global.addEventListener("hashchange", this.onHashChange);
}
componentWillUnmount() {
global.removeEventListener("hashchange", this.onHashChange);
}
render() {
const [, ...routes] = this.state.hash.split("-");
return React.cloneElement(this.props.children, {
location: {
hash: this.state.hash,
routes,
},
});
}
}
================================================
FILE: content-src/components/Base/Base.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 { ASRouterAdmin } from "content-src/components/ASRouterAdmin/ASRouterAdmin";
import { ASRouterUISurface } from "../../asrouter/asrouter-content";
import { ConfirmDialog } from "content-src/components/ConfirmDialog/ConfirmDialog";
import { connect } from "react-redux";
import { DiscoveryStreamBase } from "content-src/components/DiscoveryStreamBase/DiscoveryStreamBase";
import { ErrorBoundary } from "content-src/components/ErrorBoundary/ErrorBoundary";
import React from "react";
import { Search } from "content-src/components/Search/Search";
import { Sections } from "content-src/components/Sections/Sections";
const PrefsButton = props => (
);
// Returns a function will not be continuously triggered when called. The
// function will be triggered if called again after `wait` milliseconds.
function debounce(func, wait) {
let timer;
return (...args) => {
if (timer) {
return;
}
let wakeUp = () => {
timer = null;
};
timer = setTimeout(wakeUp, wait);
func.apply(this, args);
};
}
export class _Base extends React.PureComponent {
componentWillMount() {
if (this.props.isFirstrun) {
global.document.body.classList.add("welcome", "hide-main");
}
}
componentWillUnmount() {
this.updateTheme();
}
componentWillUpdate() {
this.updateTheme();
}
updateTheme() {
const bodyClassName = [
"activity-stream",
// If we skipped the about:welcome overlay and removed the CSS classes
// we don't want to add them back to the Activity Stream view
document.body.classList.contains("welcome") ? "welcome" : "",
document.body.classList.contains("hide-main") ? "hide-main" : "",
document.body.classList.contains("inline-onboarding")
? "inline-onboarding"
: "",
]
.filter(v => v)
.join(" ");
global.document.body.className = bodyClassName;
}
render() {
const { props } = this;
const { App } = props;
const isDevtoolsEnabled = props.Prefs.values["asrouter.devtoolsEnabled"];
if (!App.initialized) {
return null;
}
return (
{isDevtoolsEnabled ? : null}
);
}
}
export class BaseContent extends React.PureComponent {
constructor(props) {
super(props);
this.openPreferences = this.openPreferences.bind(this);
this.onWindowScroll = debounce(this.onWindowScroll.bind(this), 5);
this.state = { fixedSearch: false };
}
componentDidMount() {
global.addEventListener("scroll", this.onWindowScroll);
}
componentWillUnmount() {
global.removeEventListener("scroll", this.onWindowScroll);
}
onWindowScroll() {
const SCROLL_THRESHOLD = 34;
if (global.scrollY > SCROLL_THRESHOLD && !this.state.fixedSearch) {
this.setState({ fixedSearch: true });
} else if (global.scrollY <= SCROLL_THRESHOLD && this.state.fixedSearch) {
this.setState({ fixedSearch: false });
}
}
openPreferences() {
this.props.dispatch(ac.OnlyToMain({ type: at.SETTINGS_OPEN }));
this.props.dispatch(ac.UserEvent({ event: "OPEN_NEWTAB_PREFS" }));
}
render() {
const { props } = this;
const { App } = props;
const { initialized } = App;
const prefs = props.Prefs.values;
const isDiscoveryStream =
props.DiscoveryStream.config && props.DiscoveryStream.config.enabled;
let filteredSections = props.Sections;
// Filter out highlights for DS
if (isDiscoveryStream) {
filteredSections = filteredSections.filter(
section => section.id !== "highlights"
);
}
const noSectionsEnabled =
!prefs["feeds.topsites"] &&
filteredSections.filter(section => section.enabled).length === 0;
const searchHandoffEnabled = prefs["improvesearch.handoffToAwesomebar"];
const outerClassName = [
"outer-wrapper",
isDiscoveryStream && "ds-outer-wrapper-search-alignment",
isDiscoveryStream && "ds-outer-wrapper-breakpoint-override",
prefs.showSearch &&
this.state.fixedSearch &&
!noSectionsEnabled &&
"fixed-search",
prefs.showSearch && noSectionsEnabled && "only-search",
]
.filter(v => v)
.join(" ");
return (
{prefs.showSearch && (
)}
{isDiscoveryStream ? (
) : (
)}
);
}
}
export const Base = connect(state => ({
App: state.App,
Prefs: state.Prefs,
Sections: state.Sections,
DiscoveryStream: state.DiscoveryStream,
Search: state.Search,
}))(_Base);
================================================
FILE: content-src/components/Base/_Base.scss
================================================
.outer-wrapper {
color: var(--newtab-text-primary-color);
display: flex;
flex-grow: 1;
min-height: 100vh;
padding: ($section-spacing + $section-vertical-padding) $base-gutter $base-gutter;
&.only-search {
display: block;
padding-top: 134px;
}
a {
color: var(--newtab-link-primary-color);
}
}
main {
margin: auto;
width: $wrapper-default-width;
// Offset the snippets container so things at the bottom of the page are still
// visible when snippets are visible. Adjust for other spacing.
padding-bottom: $snippets-container-height - $section-spacing - $base-gutter;
section {
margin-bottom: $section-spacing;
position: relative;
}
.hide-main & {
visibility: hidden;
}
@media (min-width: $break-point-medium) {
width: $wrapper-max-width-medium;
}
@media (min-width: $break-point-large) {
width: $wrapper-max-width-large;
}
@media (min-width: $break-point-widest) {
width: $wrapper-max-width-widest;
}
}
.below-search-snippet.withButton {
margin: auto;
width: 100%;
}
.ds-outer-wrapper-search-alignment {
main {
// This override is to ensure while Discovery Stream loads,
// the search bar does not jump around. (it sticks to the top)
margin: 0 auto;
}
}
.ds-outer-wrapper-breakpoint-override {
main {
// Override Activity Stream breakpoints for Discovery Stream.
// Right now Discovery Stream doesn't have any breakpoints,
// and Activity Stream breakpoints do some wonky things.
width: 1042px;
}
&:not(.fixed-search) {
.search-wrapper .search-inner-wrapper {
width: $searchbar-width-large;
}
}
}
.base-content-fallback {
// Make the error message be centered against the viewport
height: 100vh;
}
.body-wrapper {
// Hide certain elements so the page structure is fixed, e.g., placeholders,
// while avoiding flashes of changing content, e.g., icons and text
$selectors-to-hide: '
.section-title,
.sections-list .section:last-of-type,
.topics
';
#{$selectors-to-hide} {
opacity: 0;
}
&.on {
#{$selectors-to-hide} {
opacity: 1;
}
}
}
.non-collapsible-section {
padding: 0 $section-horizontal-padding;
}
.prefs-button {
button {
background-color: transparent;
border: 0;
cursor: pointer;
fill: var(--newtab-icon-primary-color);
inset-inline-end: 15px;
padding: 15px;
position: fixed;
top: 15px;
z-index: 1000;
&:hover,
&:focus {
background-color: var(--newtab-element-hover-color);
}
&:active {
background-color: var(--newtab-element-active-color);
}
}
}
================================================
FILE: content-src/components/Card/Card.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 { cardContextTypes } from "./types";
import { connect } from "react-redux";
import { ContextMenuButton } from "content-src/components/ContextMenu/ContextMenuButton";
import { LinkMenu } from "content-src/components/LinkMenu/LinkMenu";
import React from "react";
import { ScreenshotUtils } from "content-src/lib/screenshot-utils";
// Keep track of pending image loads to only request once
const gImageLoading = new Map();
/**
* Card component.
* Cards are found within a Section component and contain information about a link such
* as preview image, page title, page description, and some context about if the page
* was visited, bookmarked, trending etc...
* Each Section can make an unordered list of Cards which will create one instane of
* this class. Each card will then get a context menu which reflects the actions that
* can be done on this Card.
*/
export class _Card extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
activeCard: null,
imageLoaded: false,
cardImage: null,
};
this.onMenuButtonUpdate = this.onMenuButtonUpdate.bind(this);
this.onLinkClick = this.onLinkClick.bind(this);
}
/**
* Helper to conditionally load an image and update state when it loads.
*/
async maybeLoadImage() {
// No need to load if it's already loaded or no image
const { cardImage } = this.state;
if (!cardImage) {
return;
}
const imageUrl = cardImage.url;
if (!this.state.imageLoaded) {
// Initialize a promise to share a load across multiple card updates
if (!gImageLoading.has(imageUrl)) {
const loaderPromise = new Promise((resolve, reject) => {
const loader = new Image();
loader.addEventListener("load", resolve);
loader.addEventListener("error", reject);
loader.src = imageUrl;
});
// Save and remove the promise only while it's pending
gImageLoading.set(imageUrl, loaderPromise);
loaderPromise
.catch(ex => ex)
.then(() => gImageLoading.delete(imageUrl))
.catch();
}
// Wait for the image whether just started loading or reused promise
await gImageLoading.get(imageUrl);
// Only update state if we're still waiting to load the original image
if (
ScreenshotUtils.isRemoteImageLocal(
this.state.cardImage,
this.props.link.image
) &&
!this.state.imageLoaded
) {
this.setState({ imageLoaded: true });
}
}
}
/**
* Helper to obtain the next state based on nextProps and prevState.
*
* NOTE: Rename this method to getDerivedStateFromProps when we update React
* to >= 16.3. We will need to update tests as well. We cannot rename this
* method to getDerivedStateFromProps now because there is a mismatch in
* the React version that we are using for both testing and production.
* (i.e. react-test-render => "16.3.2", react => "16.2.0").
*
* See https://github.com/airbnb/enzyme/blob/master/packages/enzyme-adapter-react-16/package.json#L43.
*/
static getNextStateFromProps(nextProps, prevState) {
const { image } = nextProps.link;
const imageInState = ScreenshotUtils.isRemoteImageLocal(
prevState.cardImage,
image
);
let nextState = null;
// Image is updating.
if (!imageInState && nextProps.link) {
nextState = { imageLoaded: false };
}
if (imageInState) {
return nextState;
}
// Since image was updated, attempt to revoke old image blob URL, if it exists.
ScreenshotUtils.maybeRevokeBlobObjectURL(prevState.cardImage);
nextState = nextState || {};
nextState.cardImage = ScreenshotUtils.createLocalImageObject(image);
return nextState;
}
onMenuButtonUpdate(isOpen) {
if (isOpen) {
this.setState({ activeCard: this.props.index });
} else {
this.setState({ activeCard: null });
}
}
/**
* Report to telemetry additional information about the item.
*/
_getTelemetryInfo() {
// Filter out "history" type for being the default
if (this.props.link.type !== "history") {
return { value: { card_type: this.props.link.type } };
}
return null;
}
onLinkClick(event) {
event.preventDefault();
if (this.props.link.type === "download") {
this.props.dispatch(
ac.OnlyToMain({
type: at.SHOW_DOWNLOAD_FILE,
data: this.props.link,
})
);
} else {
const { altKey, button, ctrlKey, metaKey, shiftKey } = event;
this.props.dispatch(
ac.OnlyToMain({
type: at.OPEN_LINK,
data: Object.assign(this.props.link, {
event: { altKey, button, ctrlKey, metaKey, shiftKey },
}),
})
);
}
if (this.props.isWebExtension) {
this.props.dispatch(
ac.WebExtEvent(at.WEBEXT_CLICK, {
source: this.props.eventSource,
url: this.props.link.url,
action_position: this.props.index,
})
);
} else {
this.props.dispatch(
ac.UserEvent(
Object.assign(
{
event: "CLICK",
source: this.props.eventSource,
action_position: this.props.index,
},
this._getTelemetryInfo()
)
)
);
if (this.props.shouldSendImpressionStats) {
this.props.dispatch(
ac.ImpressionStats({
source: this.props.eventSource,
click: 0,
tiles: [{ id: this.props.link.guid, pos: this.props.index }],
})
);
}
}
}
componentDidMount() {
this.maybeLoadImage();
}
componentDidUpdate() {
this.maybeLoadImage();
}
// NOTE: Remove this function when we update React to >= 16.3 since React will
// call getDerivedStateFromProps automatically. We will also need to
// rename getNextStateFromProps to getDerivedStateFromProps.
componentWillMount() {
const nextState = _Card.getNextStateFromProps(this.props, this.state);
if (nextState) {
this.setState(nextState);
}
}
// NOTE: Remove this function when we update React to >= 16.3 since React will
// call getDerivedStateFromProps automatically. We will also need to
// rename getNextStateFromProps to getDerivedStateFromProps.
componentWillReceiveProps(nextProps) {
const nextState = _Card.getNextStateFromProps(nextProps, this.state);
if (nextState) {
this.setState(nextState);
}
}
componentWillUnmount() {
ScreenshotUtils.maybeRevokeBlobObjectURL(this.state.cardImage);
}
render() {
const {
index,
className,
link,
dispatch,
contextMenuOptions,
eventSource,
shouldSendImpressionStats,
} = this.props;
const { props } = this;
const title = link.title || link.hostname;
const isContextMenuOpen = this.state.activeCard === index;
// Display "now" as "trending" until we have new strings #3402
const { icon, fluentID } =
cardContextTypes[link.type === "now" ? "trending" : link.type] || {};
const hasImage = this.state.cardImage || link.hasImage;
const imageStyle = {
backgroundImage: this.state.cardImage
? `url(${this.state.cardImage.url})`
: "none",
};
const outerClassName = [
"card-outer",
className,
isContextMenuOpen && "active",
props.placeholder && "placeholder",
]
.filter(v => v)
.join(" ");
return (
{link.type === "download" && (
)}
{link.hostname && (
{link.hostname.slice(0, 100)}
{link.type === "download" && ` \u2014 ${link.description}`}
)}
{link.title}
{link.description}
{icon && !link.context && (
)}
{link.icon && link.context && (
)}
{fluentID && !link.context && (
)}
{link.context && (
{link.context}
)}
{!props.placeholder && (
)}
);
}
}
_Card.defaultProps = { link: {} };
export const Card = connect(state => ({
platform: state.Prefs.values.platform,
}))(_Card);
export const PlaceholderCard = props => (
);
================================================
FILE: content-src/components/Card/_Card.scss
================================================
.card-outer {
@include context-menu-button;
background: var(--newtab-card-background-color);
border-radius: $border-radius;
display: inline-block;
height: $card-height;
margin-inline-end: $base-gutter;
position: relative;
width: 100%;
&.placeholder {
background: transparent;
.card {
box-shadow: inset $inner-box-shadow;
}
.card-preview-image-outer,
.card-context {
display: none;
}
}
.card {
border-radius: $border-radius;
box-shadow: var(--newtab-card-shadow);
height: 100%;
}
> a {
color: inherit;
display: block;
height: 100%;
outline: none;
position: absolute;
width: 100%;
&:-moz-any(.active, :focus) {
.card {
@include fade-in-card;
}
.card-title {
color: var(--newtab-link-primary-color);
}
}
}
&:-moz-any(:hover, :focus, .active):not(.placeholder) {
@include fade-in-card;
@include context-menu-button-hover;
outline: none;
.card-title {
color: var(--newtab-link-primary-color);
}
.alternate ~ .card-host-name {
display: none;
}
.card-host-name.alternate {
display: block;
}
}
.card-preview-image-outer {
background-color: $grey-30;
border-radius: $border-radius $border-radius 0 0;
height: $card-preview-image-height;
overflow: hidden;
position: relative;
[lwt-newtab-brighttext] & {
background-color: $grey-60;
}
&::after {
border-bottom: 1px solid var(--newtab-card-hairline-color);
bottom: 0;
content: '';
position: absolute;
width: 100%;
}
.card-preview-image {
background-position: center;
background-repeat: no-repeat;
background-size: cover;
height: 100%;
opacity: 0;
transition: opacity 1s $photon-easing;
width: 100%;
&.loaded {
opacity: 1;
}
}
}
.card-details {
padding: 15px 16px 12px;
}
.card-text {
max-height: 4 * $card-text-line-height + $card-title-margin;
overflow: hidden;
&.no-host-name,
&.no-context {
max-height: 5 * $card-text-line-height + $card-title-margin;
}
&.no-host-name.no-context {
max-height: 6 * $card-text-line-height + $card-title-margin;
}
&:not(.no-description) .card-title {
max-height: 3 * $card-text-line-height;
overflow: hidden;
}
}
.card-host-name {
color: var(--newtab-text-secondary-color);
font-size: 10px;
overflow: hidden;
padding-bottom: 4px;
text-overflow: ellipsis;
text-transform: uppercase; // sass-lint:disable-line no-disallowed-properties
white-space: nowrap;
}
.card-host-name.alternate { display: none; }
.card-title {
font-size: 14px;
font-weight: 600;
line-height: $card-text-line-height;
margin: 0 0 $card-title-margin;
word-wrap: break-word;
}
.card-description {
font-size: 12px;
line-height: $card-text-line-height;
margin: 0;
overflow: hidden;
word-wrap: break-word;
}
.card-context {
bottom: 0;
color: var(--newtab-text-secondary-color);
display: flex;
font-size: 11px;
inset-inline-start: 0;
padding: 9px 16px 9px 14px;
position: absolute;
}
.card-context-icon {
fill: var(--newtab-text-secondary-color);
height: 22px;
margin-inline-end: 6px;
}
.card-context-label {
flex-grow: 1;
line-height: 22px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.normal-cards {
.card-outer {
// Wide layout styles
@media (min-width: $break-point-widest) {
$line-height: 23px;
height: $card-height-large;
.card-preview-image-outer {
height: $card-preview-image-height-large;
}
.card-details {
padding: 13px 16px 12px;
}
.card-text {
max-height: 6 * $line-height + $card-title-margin;
}
.card-host-name {
font-size: 12px;
padding-bottom: 5px;
}
.card-title {
font-size: 17px;
line-height: $line-height;
margin-bottom: 0;
}
.card-text:not(.no-description) {
.card-title {
max-height: 3 * $line-height;
}
}
.card-description {
font-size: 15px;
line-height: $line-height;
}
.card-context {
bottom: 4px;
font-size: 14px;
}
}
}
}
.compact-cards {
$card-detail-vertical-spacing: 12px;
$card-title-font-size: 12px;
.card-outer {
height: $card-height-compact;
.card-preview-image-outer {
height: $card-preview-image-height-compact;
}
.card-details {
padding: $card-detail-vertical-spacing 16px;
}
.card-host-name {
line-height: 10px;
}
.card-text {
.card-title,
&:not(.no-description) .card-title {
font-size: $card-title-font-size;
line-height: $card-title-font-size + 1;
max-height: $card-title-font-size + 5;
overflow: hidden;
padding: 0 0 4px;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.card-description {
display: none;
}
.card-context {
$icon-size: 16px;
$container-size: 32px;
background-color: var(--newtab-card-background-color);
border-radius: $container-size / 2;
clip-path: inset(-1px -1px $container-size - ($card-height-compact - $card-preview-image-height-compact - 2 * $card-detail-vertical-spacing));
height: $container-size;
width: $container-size;
padding: ($container-size - $icon-size) / 2;
top: $card-preview-image-height-compact - $icon-size;
inset-inline-end: 12px;
inset-inline-start: auto;
&::after {
border: 1px solid var(--newtab-card-hairline-color);
border-bottom: 0;
border-radius: ($container-size / 2) + 1 ($container-size / 2) + 1 0 0;
content: '';
position: absolute;
height: ($container-size + 2) / 2;
width: $container-size + 2;
top: -1px;
left: -1px;
}
.card-context-icon {
margin-inline-end: 0;
height: $icon-size;
width: $icon-size;
&.icon-bookmark-added {
fill: $bookmark-icon-fill;
}
&.icon-download {
fill: $download-icon-fill;
}
&.icon-pocket {
fill: $pocket-icon-fill;
}
}
.card-context-label {
display: none;
}
}
}
@media not all and (min-width: $break-point-widest) {
.hide-for-narrow {
display: none;
}
}
}
================================================
FILE: content-src/components/Card/types.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/. */
export const cardContextTypes = {
history: {
fluentID: "newtab-label-visited",
icon: "history-item",
},
removedBookmark: {
fluentID: "newtab-label-removed-bookmark",
icon: "bookmark-removed",
},
bookmark: {
fluentID: "newtab-label-bookmarked",
icon: "bookmark-added",
},
trending: {
fluentID: "newtab-label-recommended",
icon: "trending",
},
pocket: {
fluentID: "newtab-label-saved",
icon: "pocket",
},
download: {
fluentID: "newtab-label-download",
icon: "download",
},
};
================================================
FILE: content-src/components/CollapsibleSection/CollapsibleSection.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 { ErrorBoundary } from "content-src/components/ErrorBoundary/ErrorBoundary";
import { FluentOrText } from "content-src/components/FluentOrText/FluentOrText";
import React from "react";
import { SectionMenu } from "content-src/components/SectionMenu/SectionMenu";
import { SectionMenuOptions } from "content-src/lib/section-menu-options";
import { ContextMenuButton } from "content-src/components/ContextMenu/ContextMenuButton";
const VISIBLE = "visible";
const VISIBILITY_CHANGE_EVENT = "visibilitychange";
export class CollapsibleSection extends React.PureComponent {
constructor(props) {
super(props);
this.onBodyMount = this.onBodyMount.bind(this);
this.onHeaderClick = this.onHeaderClick.bind(this);
this.onKeyPress = this.onKeyPress.bind(this);
this.onTransitionEnd = this.onTransitionEnd.bind(this);
this.enableOrDisableAnimation = this.enableOrDisableAnimation.bind(this);
this.onMenuButtonMouseEnter = this.onMenuButtonMouseEnter.bind(this);
this.onMenuButtonMouseLeave = this.onMenuButtonMouseLeave.bind(this);
this.onMenuUpdate = this.onMenuUpdate.bind(this);
this.state = {
enableAnimation: true,
isAnimating: false,
menuButtonHover: false,
showContextMenu: false,
};
this.setContextMenuButtonRef = this.setContextMenuButtonRef.bind(this);
}
componentWillMount() {
this.props.document.addEventListener(
VISIBILITY_CHANGE_EVENT,
this.enableOrDisableAnimation
);
}
componentWillUpdate(nextProps) {
// Check if we're about to go from expanded to collapsed
if (!this.props.collapsed && nextProps.collapsed) {
// This next line forces a layout flush of the section body, which has a
// max-height style set, so that the upcoming collapse animation can
// animate from that height to the collapsed height. Without this, the
// update is coalesced and there's no animation from no-max-height to 0.
this.sectionBody.scrollHeight; // eslint-disable-line no-unused-expressions
}
}
setContextMenuButtonRef(element) {
this.contextMenuButtonRef = element;
}
componentDidMount() {
this.contextMenuButtonRef.addEventListener(
"mouseenter",
this.onMenuButtonMouseEnter
);
this.contextMenuButtonRef.addEventListener(
"mouseleave",
this.onMenuButtonMouseLeave
);
}
componentWillUnmount() {
this.props.document.removeEventListener(
VISIBILITY_CHANGE_EVENT,
this.enableOrDisableAnimation
);
this.contextMenuButtonRef.removeEventListener(
"mouseenter",
this.onMenuButtonMouseEnter
);
this.contextMenuButtonRef.removeEventListener(
"mouseleave",
this.onMenuButtonMouseLeave
);
}
enableOrDisableAnimation() {
// Only animate the collapse/expand for visible tabs.
const visible = this.props.document.visibilityState === VISIBLE;
if (this.state.enableAnimation !== visible) {
this.setState({ enableAnimation: visible });
}
}
onBodyMount(node) {
this.sectionBody = node;
}
onHeaderClick() {
// If this.sectionBody is unset, it means that we're in some sort of error
// state, probably displaying the error fallback, so we won't be able to
// compute the height, and we don't want to persist the preference.
// If props.collapsed is undefined handler shouldn't do anything.
if (!this.sectionBody || this.props.collapsed === undefined) {
return;
}
// Get the current height of the body so max-height transitions can work
this.setState({
isAnimating: true,
maxHeight: `${this._getSectionBodyHeight()}px`,
});
const { action, userEvent } = SectionMenuOptions.CheckCollapsed(this.props);
this.props.dispatch(action);
this.props.dispatch(
ac.UserEvent({
event: userEvent,
source: this.props.source,
})
);
}
onKeyPress(event) {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
this.onHeaderClick();
}
}
_getSectionBodyHeight() {
const div = this.sectionBody;
if (div.style.display === "none") {
// If the div isn't displayed, we can't get it's height. So we display it
// to get the height (it doesn't show up because max-height is set to 0px
// in CSS). We don't undo this because we are about to expand the section.
div.style.display = "block";
}
return div.scrollHeight;
}
onTransitionEnd(event) {
// Only update the animating state for our own transition (not a child's)
if (event.target === event.currentTarget) {
this.setState({ isAnimating: false });
}
}
renderIcon() {
const { icon } = this.props;
if (icon && icon.startsWith("moz-extension://")) {
return (
);
}
return (
);
}
onMenuButtonMouseEnter() {
this.setState({ menuButtonHover: true });
}
onMenuButtonMouseLeave() {
this.setState({ menuButtonHover: false });
}
onMenuUpdate(showContextMenu) {
this.setState({ showContextMenu });
}
render() {
const isCollapsible = this.props.collapsed !== undefined;
const {
enableAnimation,
isAnimating,
maxHeight,
menuButtonHover,
showContextMenu,
} = this.state;
const {
id,
eventSource,
collapsed,
learnMore,
title,
extraMenuOptions,
showPrefName,
privacyNoticeURL,
dispatch,
isFixed,
isFirst,
isLast,
isWebExtension,
} = this.props;
const active = menuButtonHover || showContextMenu;
let bodyStyle;
if (isAnimating && !collapsed) {
bodyStyle = { maxHeight };
} else if (!isAnimating && collapsed) {
bodyStyle = { display: "none" };
}
return (
{/* Click-targets that toggle a collapsible section should have an aria-expanded attribute; see bug 1553234 */}
{this.renderIcon()}
{isCollapsible && (
)}
{learnMore && (
)}
{this.props.children}
);
}
}
CollapsibleSection.defaultProps = {
document: global.document || {
addEventListener: () => {},
removeEventListener: () => {},
visibilityState: "hidden",
},
Prefs: { values: {} },
};
================================================
FILE: content-src/components/CollapsibleSection/_CollapsibleSection.scss
================================================
.collapsible-section {
padding: $section-vertical-padding $section-horizontal-padding;
transition-delay: 100ms;
transition-duration: 100ms;
transition-property: background-color;
.section-title {
font-size: $section-title-font-size;
font-weight: bold;
margin: 0;
&.grey-title,
span {
color: var(--newtab-section-header-text-color);
display: inline-block;
fill: var(--newtab-section-header-text-color);
vertical-align: middle;
}
.click-target-container {
// Center "What's Pocket?" for "mobile" viewport
@media (max-width: $break-point-medium - 1) {
display: block;
.learn-more-link-wrapper {
display: block;
text-align: center;
.learn-more-link {
margin-inline-start: 0;
}
}
}
vertical-align: top;
.click-target {
cursor: pointer;
white-space: nowrap;
}
}
.collapsible-arrow {
margin-inline-start: 8px;
margin-top: -1px;
}
}
.section-top-bar {
min-height: 19px;
margin-bottom: 13px;
position: relative;
.context-menu-button {
background: url('chrome://global/skin/icons/more.svg') no-repeat right center;
border: 0;
cursor: pointer;
fill: var(--newtab-section-header-text-color);
height: 100%;
inset-inline-end: 0;
opacity: 0;
position: absolute;
top: 0;
transition-duration: 200ms;
transition-property: opacity;
width: $context-menu-button-size;
&:-moz-any(:active, :focus, :hover) {
fill: var(--newtab-section-header-text-color);
opacity: 1;
}
}
.context-menu {
top: 16px;
}
@media (max-width: $break-point-widest + $card-width * 1.5) {
@include context-menu-open-left;
}
}
&:hover,
&.active {
.section-top-bar {
.context-menu-button {
opacity: 1;
}
}
}
&.active {
background: var(--newtab-element-hover-color);
border-radius: 4px;
.section-top-bar {
.context-menu-button {
fill: var(--newtab-section-active-contextmenu-color);
}
}
}
.learn-more-link {
font-size: 11px;
margin-inline-start: 12px;
a {
color: var(--newtab-link-secondary-color);
}
}
.section-body-fallback {
height: $card-height;
}
.section-body {
// This is so the top sites favicon and card dropshadows don't get clipped during animation:
$horizontal-padding: 7px;
margin: 0 (-$horizontal-padding);
padding: 0 $horizontal-padding;
&.animating {
overflow: hidden;
pointer-events: none;
}
}
&.animation-enabled {
.section-title {
.collapsible-arrow {
transition: transform 0.5s $photon-easing;
}
}
.section-body {
transition: max-height 0.5s $photon-easing;
}
}
&.collapsed {
.section-body {
max-height: 0;
overflow: hidden;
}
}
// Hide first story card for the medium breakpoint to prevent orphaned third story
&[data-section-id='topstories'] .card-outer:first-child {
@media (min-width: $break-point-medium) and (max-width: $break-point-large - 1) {
display: none;
}
}
}
================================================
FILE: content-src/components/ComponentPerfTimer/ComponentPerfTimer.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 { perfService as perfSvc } from "common/PerfService.jsm";
import React from "react";
// Currently record only a fixed set of sections. This will prevent data
// from custom sections from showing up or from topstories.
const RECORDED_SECTIONS = ["highlights", "topsites"];
export class ComponentPerfTimer extends React.Component {
constructor(props) {
super(props);
// Just for test dependency injection:
this.perfSvc = this.props.perfSvc || perfSvc;
this._sendBadStateEvent = this._sendBadStateEvent.bind(this);
this._sendPaintedEvent = this._sendPaintedEvent.bind(this);
this._reportMissingData = false;
this._timestampHandled = false;
this._recordedFirstRender = false;
}
componentDidMount() {
if (!RECORDED_SECTIONS.includes(this.props.id)) {
return;
}
this._maybeSendPaintedEvent();
}
componentDidUpdate() {
if (!RECORDED_SECTIONS.includes(this.props.id)) {
return;
}
this._maybeSendPaintedEvent();
}
/**
* Call the given callback after the upcoming frame paints.
*
* @note Both setTimeout and requestAnimationFrame are throttled when the page
* is hidden, so this callback may get called up to a second or so after the
* requestAnimationFrame "paint" for hidden tabs.
*
* Newtabs hidden while loading will presumably be fairly rare (other than
* preloaded tabs, which we will be filtering out on the server side), so such
* cases should get lost in the noise.
*
* If we decide that it's important to find out when something that's hidden
* has "painted", however, another option is to post a message to this window.
* That should happen even faster than setTimeout, and, at least as of this
* writing, it's not throttled in hidden windows in Firefox.
*
* @param {Function} callback
*
* @returns void
*/
_afterFramePaint(callback) {
requestAnimationFrame(() => setTimeout(callback, 0));
}
_maybeSendBadStateEvent() {
// Follow up bugs:
// https://github.com/mozilla/activity-stream/issues/3691
if (!this.props.initialized) {
// Remember to report back when data is available.
this._reportMissingData = true;
} else if (this._reportMissingData) {
this._reportMissingData = false;
// Report how long it took for component to become initialized.
this._sendBadStateEvent();
}
}
_maybeSendPaintedEvent() {
// If we've already handled a timestamp, don't do it again.
if (this._timestampHandled || !this.props.initialized) {
return;
}
// And if we haven't, we're doing so now, so remember that. Even if
// something goes wrong in the callback, we can't try again, as we'd be
// sending back the wrong data, and we have to do it here, so that other
// calls to this method while waiting for the next frame won't also try to
// handle it.
this._timestampHandled = true;
this._afterFramePaint(this._sendPaintedEvent);
}
/**
* Triggered by call to render. Only first call goes through due to
* `_recordedFirstRender`.
*/
_ensureFirstRenderTsRecorded() {
// Used as t0 for recording how long component took to initialize.
if (!this._recordedFirstRender) {
this._recordedFirstRender = true;
// topsites_first_render_ts, highlights_first_render_ts.
const key = `${this.props.id}_first_render_ts`;
this.perfSvc.mark(key);
}
}
/**
* Creates `TELEMETRY_UNDESIRED_EVENT` with timestamp in ms
* of how much longer the data took to be ready for display than it would
* have been the ideal case.
* https://github.com/mozilla/ping-centre/issues/98
*/
_sendBadStateEvent() {
// highlights_data_ready_ts, topsites_data_ready_ts.
const dataReadyKey = `${this.props.id}_data_ready_ts`;
this.perfSvc.mark(dataReadyKey);
try {
const firstRenderKey = `${this.props.id}_first_render_ts`;
// value has to be Int32.
const value = parseInt(
this.perfSvc.getMostRecentAbsMarkStartByName(dataReadyKey) -
this.perfSvc.getMostRecentAbsMarkStartByName(firstRenderKey),
10
);
this.props.dispatch(
ac.OnlyToMain({
type: at.SAVE_SESSION_PERF_DATA,
// highlights_data_late_by_ms, topsites_data_late_by_ms.
data: { [`${this.props.id}_data_late_by_ms`]: value },
})
);
} catch (ex) {
// If this failed, it's likely because the `privacy.resistFingerprinting`
// pref is true.
}
}
_sendPaintedEvent() {
// Record first_painted event but only send if topsites.
if (this.props.id !== "topsites") {
return;
}
// topsites_first_painted_ts.
const key = `${this.props.id}_first_painted_ts`;
this.perfSvc.mark(key);
try {
const data = {};
data[key] = this.perfSvc.getMostRecentAbsMarkStartByName(key);
this.props.dispatch(
ac.OnlyToMain({
type: at.SAVE_SESSION_PERF_DATA,
data,
})
);
} catch (ex) {
// If this failed, it's likely because the `privacy.resistFingerprinting`
// pref is true. We should at least not blow up, and should continue
// to set this._timestampHandled to avoid going through this again.
}
}
render() {
if (RECORDED_SECTIONS.includes(this.props.id)) {
this._ensureFirstRenderTsRecorded();
this._maybeSendBadStateEvent();
}
return this.props.children;
}
}
================================================
FILE: content-src/components/ConfirmDialog/ConfirmDialog.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 } from "common/Actions.jsm";
import { connect } from "react-redux";
import React from "react";
/**
* ConfirmDialog component.
* One primary action button, one cancel button.
*
* Content displayed is controlled by `data` prop the component receives.
* Example:
* data: {
* // Any sort of data needed to be passed around by actions.
* payload: site.url,
* // Primary button AlsoToMain action.
* action: "DELETE_HISTORY_URL",
* // Primary button USerEvent action.
* userEvent: "DELETE",
* // Array of locale ids to display.
* message_body: ["confirm_history_delete_p1", "confirm_history_delete_notice_p2"],
* // Text for primary button.
* confirm_button_string_id: "menu_action_delete"
* },
*/
export class _ConfirmDialog extends React.PureComponent {
constructor(props) {
super(props);
this._handleCancelBtn = this._handleCancelBtn.bind(this);
this._handleConfirmBtn = this._handleConfirmBtn.bind(this);
}
_handleCancelBtn() {
this.props.dispatch({ type: actionTypes.DIALOG_CANCEL });
this.props.dispatch(
ac.UserEvent({
event: actionTypes.DIALOG_CANCEL,
source: this.props.data.eventSource,
})
);
}
_handleConfirmBtn() {
this.props.data.onConfirm.forEach(this.props.dispatch);
}
_renderModalMessage() {
const message_body = this.props.data.body_string_id;
if (!message_body) {
return null;
}
return (
{message_body.map(msg => (
))}
);
}
render() {
if (!this.props.visible) {
return null;
}
return (
{this.props.data.icon && (
)}
{this._renderModalMessage()}
);
}
}
export const ConfirmDialog = connect(state => state.Dialog)(_ConfirmDialog);
================================================
FILE: content-src/components/ConfirmDialog/_ConfirmDialog.scss
================================================
.confirmation-dialog {
.modal {
box-shadow: 0 2px 2px 0 $black-10;
left: 0;
margin: auto;
position: fixed;
right: 0;
top: 20%;
width: 400px;
}
section {
margin: 0;
}
.modal-message {
display: flex;
padding: 16px;
padding-bottom: 0;
p {
margin: 0;
margin-bottom: 16px;
}
}
.actions {
border: 0;
display: flex;
flex-wrap: nowrap;
padding: 0 16px;
button {
margin-inline-end: 16px;
padding-inline-end: 18px;
padding-inline-start: 18px;
white-space: normal;
width: 50%;
&.done {
margin-inline-end: 0;
margin-inline-start: 0;
}
}
}
.icon {
margin-inline-end: 16px;
}
}
.modal-overlay {
background: var(--newtab-overlay-color);
height: 100%;
left: 0;
position: fixed;
top: 0;
width: 100%;
z-index: 11001;
}
.modal {
background: var(--newtab-modal-color);
border: $border-secondary;
border-radius: 5px;
font-size: 15px;
z-index: 11002;
}
================================================
FILE: content-src/components/ContextMenu/ContextMenu.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 ContextMenu extends React.PureComponent {
constructor(props) {
super(props);
this.hideContext = this.hideContext.bind(this);
this.onShow = this.onShow.bind(this);
this.onClick = this.onClick.bind(this);
}
hideContext() {
this.props.onUpdate(false);
}
onShow() {
if (this.props.onShow) {
this.props.onShow();
}
}
componentDidMount() {
this.onShow();
setTimeout(() => {
global.addEventListener("click", this.hideContext);
}, 0);
}
componentWillUnmount() {
global.removeEventListener("click", this.hideContext);
}
onClick(event) {
// Eat all clicks on the context menu so they don't bubble up to window.
// This prevents the context menu from closing when clicking disabled items
// or the separators.
event.stopPropagation();
}
render() {
// Disabling focus on the menu span allows the first tab to focus on the first menu item instead of the wrapper.
return (
// eslint-disable-next-line jsx-a11y/interactive-supports-focus
{this.props.options.map((option, i) =>
option.type === "separator" ? (
) : (
option.type !== "empty" && (
)
)
)}
);
}
}
export class ContextMenuItem extends React.PureComponent {
constructor(props) {
super(props);
this.onClick = this.onClick.bind(this);
this.onKeyDown = this.onKeyDown.bind(this);
this.onKeyUp = this.onKeyUp.bind(this);
this.focusFirst = this.focusFirst.bind(this);
}
onClick() {
this.props.hideContext();
this.props.option.onClick();
}
// Focus the first menu item if the menu was accessed via the keyboard.
focusFirst(button) {
if (this.props.keyboardAccess && button) {
button.focus();
}
}
// This selects the correct node based on the key pressed
focusSibling(target, key) {
const parent = target.parentNode;
const closestSiblingSelector =
key === "ArrowUp" ? "previousSibling" : "nextSibling";
if (!parent[closestSiblingSelector]) {
return;
}
if (parent[closestSiblingSelector].firstElementChild) {
parent[closestSiblingSelector].firstElementChild.focus();
} else {
parent[closestSiblingSelector][
closestSiblingSelector
].firstElementChild.focus();
}
}
onKeyDown(event) {
const { option } = this.props;
switch (event.key) {
case "Tab":
// tab goes down in context menu, shift + tab goes up in context menu
// if we're on the last item, one more tab will close the context menu
// similarly, if we're on the first item, one more shift + tab will close it
if (
(event.shiftKey && option.first) ||
(!event.shiftKey && option.last)
) {
this.props.hideContext();
}
break;
case "ArrowUp":
case "ArrowDown":
event.preventDefault();
this.focusSibling(event.target, event.key);
break;
case "Enter":
case " ":
event.preventDefault();
this.props.hideContext();
option.onClick();
break;
case "Escape":
this.props.hideContext();
break;
}
}
// Prevents the default behavior of spacebar
// scrolling the page & auto-triggering buttons.
onKeyUp(event) {
if (event.key === " ") {
event.preventDefault();
}
}
render() {
const { option } = this.props;
return (
{option.icon && (
)}
);
}
}
================================================
FILE: content-src/components/ContextMenu/ContextMenuButton.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 ContextMenuButton extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
showContextMenu: false,
contextMenuKeyboard: false,
};
this.onClick = this.onClick.bind(this);
this.onKeyDown = this.onKeyDown.bind(this);
this.onUpdate = this.onUpdate.bind(this);
}
openContextMenu(isKeyBoard, event) {
if (this.props.onUpdate) {
this.props.onUpdate(true);
}
this.setState({
showContextMenu: true,
contextMenuKeyboard: isKeyBoard,
});
}
onClick(event) {
event.preventDefault();
this.openContextMenu(false, event);
}
onKeyDown(event) {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
this.openContextMenu(true, event);
}
}
onUpdate(showContextMenu) {
if (this.props.onUpdate) {
this.props.onUpdate(showContextMenu);
}
this.setState({ showContextMenu });
}
render() {
const { tooltipArgs, tooltip, children, refFunction } = this.props;
const { showContextMenu, contextMenuKeyboard } = this.state;
return (
{showContextMenu
? React.cloneElement(children, {
keyboardAccess: contextMenuKeyboard,
onUpdate: this.onUpdate,
})
: null}
);
}
}
================================================
FILE: content-src/components/ContextMenu/_ContextMenu.scss
================================================
.context-menu {
background: var(--newtab-contextmenu-background-color);
border-radius: $context-menu-border-radius;
box-shadow: $context-menu-shadow;
display: block;
font-size: $context-menu-font-size;
margin-inline-start: 5px;
inset-inline-start: 100%;
position: absolute;
top: ($context-menu-button-size / 4);
z-index: 8;
> ul {
list-style: none;
margin: 0;
padding: $context-menu-outer-padding 0;
> li {
margin: 0;
width: 100%;
&.separator {
border-bottom: $border-secondary;
margin: $context-menu-outer-padding 0;
}
> a,
> button {
align-items: center;
color: inherit;
cursor: pointer;
display: flex;
width: 100%;
line-height: 16px;
outline: none;
border: 0;
padding: $context-menu-item-padding;
white-space: nowrap;
&:-moz-any(:focus, :hover) {
background: var(--newtab-element-hover-color);
}
&:active {
background: var(--newtab-element-active-color);
}
&.disabled {
opacity: 0.4;
pointer-events: none;
}
}
}
}
}
================================================
FILE: content-src/components/DiscoveryStreamBase/DiscoveryStreamBase.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 { CardGrid } from "content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid";
import { CollapsibleSection } from "content-src/components/CollapsibleSection/CollapsibleSection";
import { connect } from "react-redux";
import { DSDismiss } from "content-src/components/DiscoveryStreamComponents/DSDismiss/DSDismiss";
import { DSMessage } from "content-src/components/DiscoveryStreamComponents/DSMessage/DSMessage";
import { DSPrivacyModal } from "content-src/components/DiscoveryStreamComponents/DSPrivacyModal/DSPrivacyModal";
import { DSTextPromo } from "content-src/components/DiscoveryStreamComponents/DSTextPromo/DSTextPromo";
import { Hero } from "content-src/components/DiscoveryStreamComponents/Hero/Hero";
import { Highlights } from "content-src/components/DiscoveryStreamComponents/Highlights/Highlights";
import { HorizontalRule } from "content-src/components/DiscoveryStreamComponents/HorizontalRule/HorizontalRule";
import { List } from "content-src/components/DiscoveryStreamComponents/List/List";
import { Navigation } from "content-src/components/DiscoveryStreamComponents/Navigation/Navigation";
import React from "react";
import { SectionTitle } from "content-src/components/DiscoveryStreamComponents/SectionTitle/SectionTitle";
import { selectLayoutRender } from "content-src/lib/selectLayoutRender";
import { TopSites } from "content-src/components/DiscoveryStreamComponents/TopSites/TopSites";
const ALLOWED_CSS_URL_PREFIXES = [
"chrome://",
"resource://",
"https://img-getpocket.cdn.mozilla.net/",
];
const DUMMY_CSS_SELECTOR = "DUMMY#CSS.SELECTOR";
let rollCache = []; // Cache of random probability values for a spoc position
/**
* Validate a CSS declaration. The values are assumed to be normalized by CSSOM.
*/
export function isAllowedCSS(property, value) {
// Bug 1454823: INTERNAL properties, e.g., -moz-context-properties, are
// exposed but their values aren't resulting in getting nothing. Fortunately,
// we don't care about validating the values of the current set of properties.
if (value === undefined) {
return true;
}
// Make sure all urls are of the allowed protocols/prefixes
const urls = value.match(/url\("[^"]+"\)/g);
return (
!urls ||
urls.every(url =>
ALLOWED_CSS_URL_PREFIXES.some(prefix => url.slice(5).startsWith(prefix))
)
);
}
export class _DiscoveryStreamBase extends React.PureComponent {
constructor(props) {
super(props);
this.onStyleMount = this.onStyleMount.bind(this);
}
onStyleMount(style) {
// Unmounting style gets rid of old styles, so nothing else to do
if (!style) {
return;
}
const { sheet } = style;
const styles = JSON.parse(style.dataset.styles);
styles.forEach((row, rowIndex) => {
row.forEach((component, componentIndex) => {
// Nothing to do without optional styles overrides
if (!component) {
return;
}
Object.entries(component).forEach(([selectors, declarations]) => {
// Start with a dummy rule to validate declarations and selectors
sheet.insertRule(`${DUMMY_CSS_SELECTOR} {}`);
const [rule] = sheet.cssRules;
// Validate declarations and remove any offenders. CSSOM silently
// discards invalid entries, so here we apply extra restrictions.
rule.style = declarations;
[...rule.style].forEach(property => {
const value = rule.style[property];
if (!isAllowedCSS(property, value)) {
console.error(`Bad CSS declaration ${property}: ${value}`); // eslint-disable-line no-console
rule.style.removeProperty(property);
}
});
// Set the actual desired selectors scoped to the component
const prefix = `.ds-layout > .ds-column:nth-child(${rowIndex +
1}) .ds-column-grid > :nth-child(${componentIndex + 1})`;
// NB: Splitting on "," doesn't work with strings with commas, but
// we're okay with not supporting those selectors
rule.selectorText = selectors
.split(",")
.map(
selector =>
prefix +
// Assume :pseudo-classes are for component instead of descendant
(selector[0] === ":" ? "" : " ") +
selector
)
.join(",");
// CSSOM silently ignores bad selectors, so we'll be noisy instead
if (rule.selectorText === DUMMY_CSS_SELECTOR) {
console.error(`Bad CSS selector ${selectors}`); // eslint-disable-line no-console
}
});
});
});
}
renderComponent(component, embedWidth) {
const ENGAGEMENT_LABEL_ENABLED = this.props.Prefs.values[
`discoverystream.engagementLabelEnabled`
];
switch (component.type) {
case "Highlights":
return ;
case "TopSites":
let promoAlignment;
if (
component.spocs &&
component.spocs.positions &&
component.spocs.positions.length
) {
promoAlignment =
component.spocs.positions[0].index === 0 ? "left" : "right";
}
return (
);
case "TextPromo":
if (
!component.data ||
!component.data.spocs ||
!component.data.spocs[0]
) {
return null;
}
// Grab the first item in the array as we only have 1 spoc position.
const [spoc] = component.data.spocs;
const {
image_src,
raw_image_src,
alt_text,
title,
url,
context,
cta,
flight_id,
id,
shim,
} = spoc;
return (
);
case "Message":
return (
);
case "SectionTitle":
return ;
case "Navigation":
return (
);
case "CardGrid":
return (
);
case "Hero":
return (
= 9 ? `cards` : `list`}
feed={component.feed}
title={component.header && component.header.title}
data={component.data}
border={component.properties.border}
type={component.type}
dispatch={this.props.dispatch}
items={component.properties.items}
/>
);
case "HorizontalRule":
return ;
case "List":
return (
);
default:
return {component.type}
;
}
}
renderStyles(styles) {
// Use json string as both the key and styles to render so React knows when
// to unmount and mount a new instance for new styles.
const json = JSON.stringify(styles);
return ;
}
componentWillReceiveProps(oldProps) {
if (this.props.DiscoveryStream.layout !== oldProps.DiscoveryStream.layout) {
rollCache = [];
}
}
render() {
// Select layout render data by adding spocs and position to recommendations
const { layoutRender, spocsFill } = selectLayoutRender({
state: this.props.DiscoveryStream,
prefs: this.props.Prefs.values,
rollCache,
lang: this.props.document.documentElement.lang,
});
const { config, spocs, feeds } = this.props.DiscoveryStream;
// Send SPOCS Fill if any. Note that it should not send it again if the same
// page gets re-rendered by state changes.
if (
spocs.loaded &&
feeds.loaded &&
spocsFill.length &&
!this._spocsFillSent
) {
this.props.dispatch(
ac.DiscoveryStreamSpocsFill({ spoc_fills: spocsFill })
);
this._spocsFillSent = true;
}
// Allow rendering without extracting special components
if (!config.collapsible) {
return this.renderLayout(layoutRender);
}
// Find the first component of a type and remove it from layout
const extractComponent = type => {
for (const [rowIndex, row] of Object.entries(layoutRender)) {
for (const [index, component] of Object.entries(row.components)) {
if (component.type === type) {
// Remove the row if it was the only component or the single item
if (row.components.length === 1) {
layoutRender.splice(rowIndex, 1);
} else {
row.components.splice(index, 1);
}
return component;
}
}
}
return null;
};
// Get "topstories" Section state for default values
const topStories = this.props.Sections.find(s => s.id === "topstories");
if (!topStories) {
return null;
}
// Extract TopSites to render before the rest and Message to use for header
const topSites = extractComponent("TopSites");
const message = extractComponent("Message") || {
header: {
link_text: topStories.learnMore.link.message,
link_url: topStories.learnMore.link.href,
title: topStories.title,
},
};
// Render a DS-style TopSites then the rest if any in a collapsible section
return (
{this.props.DiscoveryStream.isPrivacyInfoModalVisible && (
)}
{topSites &&
this.renderLayout([
{
width: 12,
components: [topSites],
},
])}
{!!layoutRender.length && (
{this.renderLayout(layoutRender)}
)}
{this.renderLayout([
{
width: 12,
components: [{ type: "Highlights" }],
},
])}
);
}
renderLayout(layoutRender) {
const styles = [];
return (
{layoutRender.map((row, rowIndex) => (
{row.components.map((component, componentIndex) => {
if (!component) {
return null;
}
styles[rowIndex] = [
...(styles[rowIndex] || []),
component.styles,
];
return (
{this.renderComponent(component, row.width)}
);
})}
))}
{this.renderStyles(styles)}
);
}
}
export const DiscoveryStreamBase = connect(state => ({
DiscoveryStream: state.DiscoveryStream,
Prefs: state.Prefs,
Sections: state.Sections,
document: global.document,
}))(_DiscoveryStreamBase);
================================================
FILE: content-src/components/DiscoveryStreamBase/_DiscoveryStreamBase.scss
================================================
$ds-width: 936px;
.discovery-stream.ds-layout {
$columns: 12;
--gridColumnGap: 48px;
--gridRowGap: 24px;
display: grid;
grid-template-columns: repeat($columns, 1fr);
grid-column-gap: var(--gridColumnGap);
grid-row-gap: var(--gridRowGap);
width: $ds-width;
margin: 0 auto;
@while $columns > 0 {
.ds-column-#{$columns} {
grid-column-start: auto;
grid-column-end: span $columns;
}
$columns: $columns - 1;
}
.ds-column-grid {
display: grid;
grid-row-gap: var(--gridRowGap);
}
}
.ds-header {
margin: 8px 0;
}
.ds-header,
.ds-layout .section-title span {
@include dark-theme-only {
color: $grey-30;
}
color: $grey-50;
font-size: 13px;
font-weight: 600;
line-height: 20px;
.icon {
fill: var(--newtab-text-secondary-color);
}
}
.collapsible-section.ds-layout {
margin: auto;
width: $ds-width + 2 * $section-horizontal-padding;
.section-top-bar {
.learn-more-link a {
color: var(--newtab-link-primary-color);
font-weight: normal;
&:-moz-any(:focus, :hover) {
text-decoration: underline;
}
}
}
}
================================================
FILE: content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid.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 { DSCard, PlaceholderDSCard } from "../DSCard/DSCard.jsx";
import { DSEmptyState } from "../DSEmptyState/DSEmptyState.jsx";
import React from "react";
export class CardGrid extends React.PureComponent {
renderCards() {
const recs = this.props.data.recommendations.slice(0, this.props.items);
const cards = [];
for (let index = 0; index < this.props.items; index++) {
const rec = recs[index];
cards.push(
!rec || rec.placeholder ? (
) : (
)
);
}
let divisibility = ``;
if (this.props.items % 4 === 0) {
divisibility = `divisible-by-4`;
} else if (this.props.items % 3 === 0) {
divisibility = `divisible-by-3`;
}
return (
{cards}
);
}
render() {
const { data } = this.props;
// Handle a render before feed has been fetched by displaying nothing
if (!data) {
return null;
}
// Handle the case where a user has dismissed all recommendations
const isEmpty = data.recommendations.length === 0;
return (
{this.props.title && (
{this.props.title}
)}
{isEmpty ? (
) : (
this.renderCards()
)}
);
}
}
CardGrid.defaultProps = {
border: `border`,
items: 4, // Number of stories to display
};
================================================
FILE: content-src/components/DiscoveryStreamComponents/CardGrid/_CardGrid.scss
================================================
$col4-header-line-height: 20;
$col4-header-font-size: 14;
.ds-card-grid {
display: grid;
grid-gap: 24px;
.ds-card {
@include dark-theme-only {
background: none;
}
background: $white;
border-radius: 4px;
}
.ds-card-link:focus {
@include ds-fade-in;
}
&.ds-card-grid-border {
.ds-card:not(.placeholder) {
@include dark-theme-only {
box-shadow: 0 1px 4px $shadow-10;
background: $grey-70;
}
box-shadow: 0 1px 4px 0 $grey-90-10;
.img-wrapper .img img {
border-radius: 4px 4px 0 0;
}
}
}
&.ds-card-grid-no-border {
.ds-card {
background: none;
.meta {
padding: 12px 0;
}
}
}
// "2/3 width layout"
.ds-column-5 &,
.ds-column-6 &,
.ds-column-7 &,
.ds-column-8 & {
grid-template-columns: repeat(2, 1fr);
}
// "Full width layout"
.ds-column-9 &,
.ds-column-10 &,
.ds-column-11 &,
.ds-column-12 & {
grid-template-columns: repeat(4, 1fr);
&.ds-card-grid-divisible-by-3 {
grid-template-columns: repeat(3, 1fr);
.title {
font-size: 17px;
line-height: 24px;
}
.excerpt {
@include limit-visible-lines(3, 24, 15);
}
}
&.ds-card-grid-divisible-by-4 .title {
@include limit-visible-lines(3, 20, 15);
}
}
&.empty {
grid-template-columns: auto;
}
}
================================================
FILE: content-src/components/DiscoveryStreamComponents/DSCard/DSCard.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 { DSImage } from "../DSImage/DSImage.jsx";
import { DSLinkMenu } from "../DSLinkMenu/DSLinkMenu";
import { ImpressionStats } from "../../DiscoveryStreamImpressionStats/ImpressionStats";
import React from "react";
import { SafeAnchor } from "../SafeAnchor/SafeAnchor";
import { DSContextFooter } from "../DSContextFooter/DSContextFooter.jsx";
// Default Meta that displays CTA as link if cta_variant in layout is set as "link"
export const DefaultMeta = ({
display_engagement_labels,
source,
title,
excerpt,
context,
context_type,
cta,
engagement,
cta_variant,
}) => (
{source}
{excerpt &&
{excerpt}
}
{cta_variant === "link" && cta && (
{cta}
)}
);
export const CTAButtonMeta = ({
display_engagement_labels,
source,
title,
excerpt,
context,
context_type,
cta,
engagement,
sponsor,
}) => (
{sponsor ? sponsor : source}
{context && ` · Sponsored`}
{excerpt &&
{excerpt}
}
{context && cta &&
{cta} }
{!context && (
)}
);
export class DSCard extends React.PureComponent {
constructor(props) {
super(props);
this.onLinkClick = this.onLinkClick.bind(this);
this.setPlaceholderRef = element => {
this.placeholderElement = element;
};
this.state = {
isSeen: false,
};
}
onLinkClick(event) {
if (this.props.dispatch) {
this.props.dispatch(
ac.UserEvent({
event: "CLICK",
source: this.props.type.toUpperCase(),
action_position: this.props.pos,
value: { card_type: this.props.flightId ? "spoc" : "organic" },
})
);
this.props.dispatch(
ac.ImpressionStats({
source: this.props.type.toUpperCase(),
click: 0,
tiles: [
{
id: this.props.id,
pos: this.props.pos,
...(this.props.shim && this.props.shim.click
? { shim: this.props.shim.click }
: {}),
},
],
})
);
}
}
onSeen(entries) {
if (this.state) {
const entry = entries.find(e => e.isIntersecting);
if (entry) {
if (this.placeholderElement) {
this.observer.unobserve(this.placeholderElement);
}
// Stop observing since element has been seen
this.setState({
isSeen: true,
});
}
}
}
onIdleCallback() {
if (!this.state.isSeen) {
if (this.observer && this.placeholderElement) {
this.observer.unobserve(this.placeholderElement);
}
this.setState({
isSeen: true,
});
}
}
componentDidMount() {
this.idleCallbackId = this.props.windowObj.requestIdleCallback(
this.onIdleCallback.bind(this)
);
if (this.placeholderElement) {
this.observer = new IntersectionObserver(this.onSeen.bind(this));
this.observer.observe(this.placeholderElement);
}
}
componentWillUnmount() {
// Remove observer on unmount
if (this.observer && this.placeholderElement) {
this.observer.unobserve(this.placeholderElement);
}
if (this.idleCallbackId) {
this.props.windowObj.cancelIdleCallback(this.idleCallbackId);
}
}
render() {
if (this.props.placeholder || !this.state.isSeen) {
return (
);
}
const isButtonCTA = this.props.cta_variant === "button";
return (
{isButtonCTA ? (
) : (
)}
);
}
}
DSCard.defaultProps = {
windowObj: window, // Added to support unit tests
};
export const PlaceholderDSCard = props => ;
================================================
FILE: content-src/components/DiscoveryStreamComponents/DSCard/_DSCard.scss
================================================
// Type sizes
$header-font-size: 17;
$header-line-height: 24;
$excerpt-font-size: 14;
$excerpt-line-height: 20;
.ds-card {
display: flex;
flex-direction: column;
position: relative;
&.placeholder {
background: transparent;
box-shadow: inset $inner-box-shadow;
border-radius: 4px;
min-height: 300px;
}
.img-wrapper {
width: 100%;
}
.img {
height: 0;
padding-top: 50%; // 2:1 aspect ratio
img {
border-radius: 4px;
box-shadow: inset 0 0 0 0.5px $black-15;
}
}
.ds-card-link {
height: 100%;
display: flex;
flex-direction: column;
&:hover {
@include ds-fade-in($grey-30);
@include dark-theme-only {
@include ds-fade-in($grey-60);
}
header {
@include dark-theme-only {
color: $blue-40;
}
color: $blue-60;
}
}
&:focus {
@include ds-fade-in;
@include dark-theme-only {
@include ds-fade-in($blue-40-40);
}
header {
@include dark-theme-only {
color: $blue-40;
}
color: $blue-60;
}
}
&:active {
@include ds-fade-in($grey-30);
@include dark-theme-only {
@include ds-fade-in($grey-60);
}
header {
@include dark-theme-only {
color: $blue-50;
}
color: $blue-70;
}
}
}
.meta {
display: flex;
flex-direction: column;
padding: 12px 16px;
flex-grow: 1;
.info-wrap {
flex-grow: 1;
}
.title {
// show only 3 lines of copy
@include limit-visible-lines(3, $header-line-height, $header-font-size);
font-weight: 600;
}
.excerpt {
// show only 3 lines of copy
@include limit-visible-lines(
3,
$excerpt-line-height,
$excerpt-font-size
);
}
.source {
@include dark-theme-only {
color: $grey-40;
}
-webkit-line-clamp: 1;
margin-bottom: 2px;
font-size: 13px;
color: $grey-50;
}
.cta-button {
@include dark-theme-only {
color: $grey-10;
background: $grey-90-70;
}
width: 100%;
margin: 12px 0 4px;
box-shadow: none;
border-radius: 4px;
height: 32px;
font-size: 14px;
font-weight: 600;
padding: 5px 8px 7px;
border: 0;
color: $grey-90;
background: $grey-90-10;
&:focus {
@include dark-theme-only {
background: $grey-90-70;
box-shadow: 0 0 0 2px $grey-80, 0 0 0 5px $blue-50-50;
}
background: $grey-90-10;
box-shadow: 0 0 0 2px $white, 0 0 0 5px $blue-50-50;
}
&:hover {
@include dark-theme-only {
background: $grey-90-50;
}
background: $grey-90-20;
}
&:active {
@include dark-theme-only {
background: $grey-90-70;
}
background: $grey-90-30;
}
}
.cta-link {
@include dark-theme-only {
color: $blue-40;
fill: $blue-40;
}
font-size: 15px;
font-weight: 600;
line-height: 24px;
height: 24px;
width: auto;
background-size: auto;
background-position: right 1.5px;
padding-right: 9px;
color: $blue-60;
fill: $blue-60;
&:focus {
@include dark-theme-only {
box-shadow: 0 0 0 1px $grey-80, 0 0 0 4px $blue-50-50;
}
box-shadow: 0 0 0 1px $white, 0 0 0 4px $blue-50-50;
border-radius: 4px;
outline: 0;
}
&:active {
@include dark-theme-only {
color: $blue-50;
fill: $blue-50;
box-shadow: none;
}
color: $blue-70;
fill: $blue-70;
box-shadow: none;
}
&:hover {
text-decoration: underline;
}
}
}
header {
@include dark-theme-only {
color: $grey-10;
}
line-height: $header-line-height * 1px;
font-size: $header-font-size * 1px;
color: $grey-90;
}
p {
@include dark-theme-only {
color: $grey-10;
}
font-size: $excerpt-font-size * 1px;
line-height: $excerpt-line-height * 1px;
color: $grey-90;
margin: 0;
}
}
================================================
FILE: content-src/components/DiscoveryStreamComponents/DSContextFooter/DSContextFooter.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 { cardContextTypes } from "../../Card/types.js";
import { CSSTransition, TransitionGroup } from "react-transition-group";
import React from "react";
// Animation time is mirrored in DSContextFooter.scss
const ANIMATION_DURATION = 3000;
export const StatusMessage = ({ icon, fluentID }) => (
);
export class DSContextFooter extends React.PureComponent {
render() {
// display_engagement_labels is based on pref `browser.newtabpage.activity-stream.discoverystream.engagementLabelEnabled`
const {
context,
context_type,
engagement,
display_engagement_labels,
} = this.props;
const { icon, fluentID } = cardContextTypes[context_type] || {};
return (
{context &&
{context}
}
{!context &&
(context_type || (display_engagement_labels && engagement)) && (
{engagement && !context_type ? (
{engagement}
) : (
)}
)}
);
}
}
================================================
FILE: content-src/components/DiscoveryStreamComponents/DSContextFooter/_DSContextFooter.scss
================================================
$status-green: #058B00;
$status-dark-green: #7C6;
.story-footer {
color: var(--newtab-text-secondary-color);
inset-inline-start: 0;
margin-top: 12px;
position: relative;
.story-sponsored-label,
.story-view-count,
.status-message {
@include dark-theme-only {
color: $grey-40;
}
-webkit-line-clamp: 1;
font-size: 13px;
line-height: 24px;
color: $grey-50;
}
.status-message {
display: flex;
align-items: center;
height: 24px;
.story-badge-icon {
@include dark-theme-only {
fill: $grey-40;
}
fill: $grey-50;
height: 16px;
margin-inline-end: 6px;
&.icon-bookmark-removed {
background-image: url('#{$image-path}icon-removed-bookmark.svg');
}
}
.story-context-label {
@include dark-theme-only {
color: $grey-40;
}
color: $grey-50;
flex-grow: 1;
font-size: 13px;
line-height: 24px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
.story-animate-enter {
opacity: 0;
}
.story-animate-enter-active {
opacity: 1;
transition: opacity 150ms ease-in 300ms;
.story-badge-icon,
.story-context-label {
@include dark-theme-only {
animation: dark-color 3s ease-out 0.3s;
}
animation: color 3s ease-out 0.3s;
@keyframes color {
0% {
color: $status-green;
fill: $status-green;
}
100% {
color: $grey-50;
fill: $grey-50;
}
}
@keyframes dark-color {
0% {
color: $status-dark-green;
fill: $status-dark-green;
}
100% {
color: $grey-40;
fill: $grey-40;
}
}
}
}
.story-animate-exit {
position: absolute;
top: 0;
opacity: 1;
}
.story-animate-exit-active {
opacity: 0;
transition: opacity 250ms ease-in;
}
================================================
FILE: content-src/components/DiscoveryStreamComponents/DSDismiss/DSDismiss.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 React from "react";
import { LinkMenuOptions } from "content-src/lib/link-menu-options";
export class DSDismiss extends React.PureComponent {
constructor(props) {
super(props);
this.onDismissClick = this.onDismissClick.bind(this);
this.onHover = this.onHover.bind(this);
this.offHover = this.offHover.bind(this);
this.state = {
hovering: false,
};
}
onDismissClick() {
const index = 0;
const source = "DISCOVERY_STREAM";
const blockUrlOption = LinkMenuOptions.BlockUrl(
this.props.data,
index,
source
);
const { action, impression, userEvent } = blockUrlOption;
this.props.dispatch(action);
const userEventData = Object.assign(
{
event: userEvent,
source,
action_position: index,
},
this.props.data
);
this.props.dispatch(ac.UserEvent(userEventData));
if (impression && this.props.shouldSendImpressionStats) {
this.props.dispatch(impression);
}
}
onHover() {
this.setState({
hovering: true,
});
}
offHover() {
this.setState({
hovering: false,
});
}
render() {
let className = `ds-dismiss
${this.state.hovering ? ` hovering` : ``}
${this.props.extraClasses ? ` ${this.props.extraClasses}` : ``}`;
return (
{this.props.children}
);
}
}
================================================
FILE: content-src/components/DiscoveryStreamComponents/DSDismiss/_DSDismiss.scss
================================================
.ds-dismiss {
position: relative;
overflow: hidden;
border-radius: 8px;
transition-delay: 100ms;
transition-duration: 200ms;
transition-property: background;
&.hovering {
@include dark-theme-only {
background: $grey-90-30;
}
background: $grey-90-10;
}
&:hover {
.ds-dismiss-button {
opacity: 1;
}
}
.ds-dismiss-button {
@include dark-theme-only {
background: $grey-90-30;
}
border: 0;
cursor: pointer;
height: 32px;
width: 32px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
position: absolute;
right: 0;
top: 0;
border-radius: 50%;
margin: 18px 18px 0 0;
background: $grey-90-10;
&:hover {
@include dark-theme-only {
background: $grey-90-50;
}
background: $grey-90-20;
}
&:active {
@include dark-theme-only {
background: $grey-90-70;
}
background: $grey-90-30;
}
&:focus {
@include dark-theme-only {
box-shadow: 0 0 0 2px $grey-80, 0 0 0 5px $blue-50-50;
}
box-shadow: 0 0 0 2px $white, 0 0 0 5px $blue-50-50;
}
}
}
================================================
FILE: content-src/components/DiscoveryStreamComponents/DSEmptyState/DSEmptyState.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 React from "react";
export class DSEmptyState extends React.PureComponent {
constructor(props) {
super(props);
this.onReset = this.onReset.bind(this);
this.state = {};
}
componentWillUnmount() {
if (this.timeout) {
clearTimeout(this.timeout);
}
}
onReset() {
if (this.props.dispatch && this.props.feed) {
const { feed } = this.props;
const { url } = feed;
this.props.dispatch({
type: at.DISCOVERY_STREAM_FEED_UPDATE,
data: {
feed: {
...feed,
data: {
...feed.data,
status: "waiting",
},
},
url,
},
});
this.setState({ waiting: true });
this.timeout = setTimeout(() => {
this.timeout = null;
this.setState({
waiting: false,
});
}, 300);
this.props.dispatch(
ac.OnlyToMain({ type: at.DISCOVERY_STREAM_RETRY_FEED, data: { feed } })
);
}
}
renderButton() {
if (this.props.status === "waiting" || this.state.waiting) {
return (
);
}
return (
);
}
renderState() {
if (this.props.status === "waiting" || this.props.status === "failed") {
return (
{this.renderButton()}
);
}
return (
);
}
render() {
return (
);
}
}
================================================
FILE: content-src/components/DiscoveryStreamComponents/DSEmptyState/_DSEmptyState.scss
================================================
.section-empty-state {
border: $border-secondary;
border-radius: 4px;
display: flex;
height: $card-height-compact;
width: 100%;
.empty-state-message {
color: var(--newtab-text-secondary-color);
font-size: 14px;
line-height: 20px;
text-align: center;
margin: auto;
max-width: 936px;
}
.try-again-button {
margin-top: 12px;
padding: 6px 32px;
border-radius: 2px;
border: 0;
background: var(--newtab-feed-button-background);
color: var(--newtab-feed-button-text);
cursor: pointer;
position: relative;
transition: background 0.2s ease, color 0.2s ease;
&:not(.waiting) {
&:focus {
@include ds-fade-in;
@include dark-theme-only {
@include ds-fade-in($blue-40-40);
}
}
&:hover {
@include ds-fade-in($grey-30);
@include dark-theme-only {
@include ds-fade-in($grey-60);
}
}
}
&::after {
content: '';
height: 20px;
width: 20px;
animation: spinner 1s linear infinite;
opacity: 0;
position: absolute;
top: 50%;
left: 50%;
margin: -10px 0 0 -10px;
mask-image: url('../data/content/assets/spinner.svg');
mask-size: 20px;
background: var(--newtab-feed-button-spinner);
}
&.waiting {
cursor: initial;
background: var(--newtab-feed-button-background-faded);
color: var(--newtab-feed-button-text-faded);
transition: background 0.2s ease;
&::after {
transition: opacity 0.2s ease;
opacity: 1;
}
}
}
h2 {
font-size: 15px;
font-weight: 600;
margin: 0;
}
p {
margin: 0;
}
}
@keyframes spinner {
to { transform: rotate(360deg); }
}
================================================
FILE: content-src/components/DiscoveryStreamComponents/DSImage/DSImage.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 ReactDOM from "react-dom";
export class DSImage extends React.PureComponent {
constructor(props) {
super(props);
this.onOptimizedImageError = this.onOptimizedImageError.bind(this);
this.onNonOptimizedImageError = this.onNonOptimizedImageError.bind(this);
this.state = {
isSeen: false,
optimizedImageFailed: false,
useTransition: false,
};
}
onSeen(entries) {
if (this.state) {
const entry = entries.find(e => e.isIntersecting);
if (entry) {
if (this.props.optimize) {
this.setState({
// Thumbor doesn't handle subpixels and just errors out, so rounding...
containerWidth: Math.round(entry.boundingClientRect.width),
containerHeight: Math.round(entry.boundingClientRect.height),
});
}
this.setState({
isSeen: true,
});
// Stop observing since element has been seen
this.observer.unobserve(ReactDOM.findDOMNode(this));
}
}
}
onIdleCallback() {
if (!this.state.isSeen) {
this.setState({
useTransition: true,
});
}
}
reformatImageURL(url, width, height) {
// Change the image URL to request a size tailored for the parent container width
// Also: force JPEG, quality 60, no upscaling, no EXIF data
// Uses Thumbor: https://thumbor.readthedocs.io/en/latest/usage.html
return `https://img-getpocket.cdn.mozilla.net/${width}x${height}/filters:format(jpeg):quality(60):no_upscale():strip_exif()/${encodeURIComponent(
url
)}`;
}
componentDidMount() {
this.idleCallbackId = this.props.windowObj.requestIdleCallback(
this.onIdleCallback.bind(this)
);
this.observer = new IntersectionObserver(this.onSeen.bind(this), {
// Assume an image will be eventually seen if it is within
// half the average Desktop vertical screen size:
// http://gs.statcounter.com/screen-resolution-stats/desktop/north-america
rootMargin: `540px`,
});
this.observer.observe(ReactDOM.findDOMNode(this));
}
componentWillUnmount() {
// Remove observer on unmount
if (this.observer) {
this.observer.unobserve(ReactDOM.findDOMNode(this));
}
if (this.idleCallbackId) {
this.props.windowObj.cancelIdleCallback(this.idleCallbackId);
}
}
render() {
let classNames = `ds-image
${this.props.extraClassNames ? ` ${this.props.extraClassNames}` : ``}
${this.state && this.state.useTransition ? ` use-transition` : ``}
${this.state && this.state.isSeen ? ` loaded` : ``}
`;
let img;
if (this.state && this.state.isSeen) {
if (
this.props.optimize &&
this.props.rawSource &&
!this.state.optimizedImageFailed
) {
let source;
let source2x;
if (this.state && this.state.containerWidth) {
let baseSource = this.props.rawSource;
source = this.reformatImageURL(
baseSource,
this.state.containerWidth,
this.state.containerHeight
);
source2x = this.reformatImageURL(
baseSource,
this.state.containerWidth * 2,
this.state.containerHeight * 2
);
img = (
);
}
} else if (!this.state.nonOptimizedImageFailed) {
img = (
);
} else {
// Remove the img element if both sources fail. Render a placeholder instead.
img =
;
}
}
return {img} ;
}
onOptimizedImageError() {
// This will trigger a re-render and the unoptimized 450px image will be used as a fallback
this.setState({
optimizedImageFailed: true,
});
}
onNonOptimizedImageError() {
this.setState({
nonOptimizedImageFailed: true,
});
}
}
DSImage.defaultProps = {
source: null, // The current source style from Pocket API (always 450px)
rawSource: null, // Unadulterated image URL to filter through Thumbor
extraClassNames: null, // Additional classnames to append to component
optimize: true, // Measure parent container to request exact sizes
alt_text: null,
windowObj: window, // Added to support unit tests
};
================================================
FILE: content-src/components/DiscoveryStreamComponents/DSImage/_DSImage.scss
================================================
.ds-image {
display: block;
position: relative;
opacity: 0;
&.use-transition {
transition: opacity 0.8s;
}
&.loaded {
opacity: 1;
}
img,
.broken-image {
background-color: var(--newtab-card-placeholder-color);
position: absolute;
top: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
}
================================================
FILE: content-src/components/DiscoveryStreamComponents/DSLinkMenu/DSLinkMenu.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 { LinkMenu } from "content-src/components/LinkMenu/LinkMenu";
import { ContextMenuButton } from "content-src/components/ContextMenu/ContextMenuButton";
import React from "react";
export class DSLinkMenu extends React.PureComponent {
constructor(props) {
super(props);
this.onMenuUpdate = this.onMenuUpdate.bind(this);
this.onMenuShow = this.onMenuShow.bind(this);
this.contextMenuButtonRef = React.createRef();
}
onMenuUpdate(showContextMenu) {
if (!showContextMenu) {
const dsLinkMenuHostDiv = this.contextMenuButtonRef.current.parentElement;
dsLinkMenuHostDiv.parentElement.classList.remove("active", "last-item");
}
}
nextAnimationFrame() {
return new Promise(resolve =>
this.props.windowObj.requestAnimationFrame(resolve)
);
}
async onMenuShow() {
const dsLinkMenuHostDiv = this.contextMenuButtonRef.current.parentElement;
// Wait for next frame before computing scrollMaxX to allow fluent menu strings to be visible
await this.nextAnimationFrame();
if (this.props.windowObj.scrollMaxX > 0) {
dsLinkMenuHostDiv.parentElement.classList.add("last-item");
}
dsLinkMenuHostDiv.parentElement.classList.add("active");
}
render() {
const { index, dispatch } = this.props;
const TOP_STORIES_CONTEXT_MENU_OPTIONS = [
"CheckBookmarkOrArchive",
"CheckSavedToPocket",
"Separator",
"OpenInNewWindow",
"OpenInPrivateWindow",
"Separator",
"BlockUrl",
...(this.props.flightId ? ["ShowPrivacyInfo"] : []),
];
const type = this.props.type || "DISCOVERY_STREAM";
const title = this.props.title || this.props.source;
return (
);
}
}
DSLinkMenu.defaultProps = {
windowObj: window, // Added to support unit tests
};
================================================
FILE: content-src/components/DiscoveryStreamComponents/DSLinkMenu/_DSLinkMenu.scss
================================================
.ds-hero-item,
.ds-list-item,
.ds-card {
@include context-menu-button;
.context-menu {
opacity: 0;
}
&.active {
.context-menu {
opacity: 1;
}
}
&.last-item {
@include context-menu-open-left;
.context-menu {
opacity: 1;
}
}
&:-moz-any(:hover, :focus, .active) {
@include context-menu-button-hover;
outline: none;
&.ds-card-grid-border {
@include fade-in-card;
}
}
}
================================================
FILE: content-src/components/DiscoveryStreamComponents/DSMessage/DSMessage.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 { SafeAnchor } from "../SafeAnchor/SafeAnchor";
import { FluentOrText } from "content-src/components/FluentOrText/FluentOrText";
export class DSMessage extends React.PureComponent {
render() {
return (
{this.props.icon && (
)}
{this.props.title && (
)}
{this.props.link_text && this.props.link_url && (
)}
);
}
}
================================================
FILE: content-src/components/DiscoveryStreamComponents/DSMessage/_DSMessage.scss
================================================
.ds-message {
margin: 8px 0 0;
.title {
display: flex;
align-items: center;
.glyph {
@include dark-theme-only {
fill: $grey-30;
}
width: 16px;
height: 16px;
margin: 0 6px 0 0;
-moz-context-properties: fill;
fill: $grey-50;
background-position: center center;
background-size: 16px;
background-repeat: no-repeat;
}
.title-text {
@include dark-theme-only {
color: $grey-30;
}
line-height: 20px;
font-size: 13px;
color: $grey-50;
font-weight: 600;
padding-right: 12px;
}
.link {
line-height: 20px;
font-size: 13px;
&:hover,
&:focus {
text-decoration: underline;
}
}
}
}
================================================
FILE: content-src/components/DiscoveryStreamComponents/DSPrivacyModal/DSPrivacyModal.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 { actionCreators as ac } from "common/Actions.jsm";
import { ModalOverlayWrapper } from "content-src/asrouter/components/ModalOverlay/ModalOverlay";
export class DSPrivacyModal extends React.PureComponent {
constructor(props) {
super(props);
this.closeModal = this.closeModal.bind(this);
this.onLinkClick = this.onLinkClick.bind(this);
}
onLinkClick(event) {
this.props.dispatch(
ac.UserEvent({
event: "CLICK_PRIVACY_INFO",
source: "DS_PRIVACY_MODAL",
})
);
}
closeModal() {
this.props.dispatch({
type: `HIDE_PRIVACY_INFO`,
data: {},
});
}
render() {
return (
);
}
}
================================================
FILE: content-src/components/DiscoveryStreamComponents/DSPrivacyModal/_DSPrivacyModal.scss
================================================
.ds-privacy-modal {
a:hover {
text-decoration: underline;
}
.privacy-notice {
width: 492px;
padding: 40px 0;
margin: auto;
}
}
================================================
FILE: content-src/components/DiscoveryStreamComponents/DSTextPromo/DSTextPromo.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 { DSImage } from "../DSImage/DSImage.jsx";
import { ImpressionStats } from "../../DiscoveryStreamImpressionStats/ImpressionStats";
import React from "react";
import { SafeAnchor } from "../SafeAnchor/SafeAnchor";
export class DSTextPromo extends React.PureComponent {
constructor(props) {
super(props);
this.onLinkClick = this.onLinkClick.bind(this);
}
onLinkClick() {
if (this.props.dispatch) {
this.props.dispatch(
ac.UserEvent({
event: "CLICK",
source: this.props.type.toUpperCase(),
action_position: this.props.pos,
})
);
this.props.dispatch(
ac.ImpressionStats({
source: this.props.type.toUpperCase(),
click: 0,
tiles: [
{
id: this.props.id,
pos: this.props.pos,
...(this.props.shim && this.props.shim.click
? { shim: this.props.shim.click }
: {}),
},
],
})
);
}
}
render() {
return (
{`${this.props.header}\u2003`}
{this.props.cta_text}
{this.props.subtitle}
);
}
}
================================================
FILE: content-src/components/DiscoveryStreamComponents/DSTextPromo/_DSTextPromo.scss
================================================
.ds-dismiss-ds-text-promo {
width: 744px;
margin: auto;
}
.ds-text-promo {
display: flex;
max-width: 640px;
margin: 18px 24px;
.ds-image {
width: 40px;
height: 40px;
margin: 4px 12px 0 0;
flex-shrink: 0;
img {
border-radius: 4px;
}
}
.text {
line-height: 24px;
}
h3 {
@include dark-theme-only {
color: $grey-10;
}
margin: 0;
font-weight: 600;
font-size: 15px;
}
.subtitle {
@include dark-theme-only {
color: $grey-40;
}
font-size: 13px;
margin: 0;
color: $grey-50;
}
}
.ds-chevron-link {
color: $blue-60;
display: inline-block;
outline: 0;
&:hover {
text-decoration: underline;
}
&:active {
@include dark-theme-only {
color: $blue-50;
}
color: $blue-70;
&::after {
@include dark-theme-only {
background-color: $blue-50;
}
background-color: $blue-70;
}
}
&:focus {
@include dark-theme-only {
box-shadow: 0 0 0 2px $grey-80, 0 0 0 5px $blue-50-50;
}
box-shadow: 0 0 0 2px $white, 0 0 0 5px $blue-50-50;
border-radius: 2px;
}
&::after {
@include dark-theme-only {
background-color: $blue-40;
}
content: ' ';
mask: url('#{$image-path}glyph-caret-right.svg') 0 -8px no-repeat;
background-color: $blue-60;
margin: 0 0 0 4px;
width: 5px;
height: 8px;
text-decoration: none;
display: inline-block;
}
}
================================================
FILE: content-src/components/DiscoveryStreamComponents/Hero/Hero.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 { DSCard, PlaceholderDSCard } from "../DSCard/DSCard.jsx";
import { actionCreators as ac } from "common/Actions.jsm";
import { DSEmptyState } from "../DSEmptyState/DSEmptyState.jsx";
import { DSImage } from "../DSImage/DSImage.jsx";
import { DSLinkMenu } from "../DSLinkMenu/DSLinkMenu";
import { ImpressionStats } from "../../DiscoveryStreamImpressionStats/ImpressionStats";
import { List } from "../List/List.jsx";
import React from "react";
import { SafeAnchor } from "../SafeAnchor/SafeAnchor";
import { DSContextFooter } from "../DSContextFooter/DSContextFooter.jsx";
export class Hero extends React.PureComponent {
constructor(props) {
super(props);
this.onLinkClick = this.onLinkClick.bind(this);
}
onLinkClick(event) {
if (this.props.dispatch) {
this.props.dispatch(
ac.UserEvent({
event: "CLICK",
source: this.props.type.toUpperCase(),
action_position: this.heroRec.pos,
})
);
this.props.dispatch(
ac.ImpressionStats({
source: this.props.type.toUpperCase(),
click: 0,
tiles: [
{
id: this.heroRec.id,
pos: this.heroRec.pos,
...(this.heroRec.shim && this.heroRec.shim.click
? { shim: this.heroRec.shim.click }
: {}),
},
],
})
);
}
}
renderHero() {
let [heroRec, ...otherRecs] = this.props.data.recommendations.slice(
0,
this.props.items
);
this.heroRec = heroRec;
const cards = [];
for (let index = 0; index < this.props.items - 1; index++) {
const rec = otherRecs[index];
cards.push(
!rec || rec.placeholder ? (
) : (
)
);
}
let heroCard = null;
if (!heroRec || heroRec.placeholder) {
heroCard = ;
} else {
heroCard = (
{heroRec.domain}
{heroRec.excerpt}
);
}
let list = (
);
return (
{heroCard}
{this.props.subComponentType === `cards` ? cards : list}
);
}
render() {
const { data } = this.props;
// Handle a render before feed has been fetched by displaying nothing
if (!data || !data.recommendations) {
return
;
}
// Handle the case where a user has dismissed all recommendations
const isEmpty = data.recommendations.length === 0;
return (
{this.props.title}
{isEmpty ? (
) : (
this.renderHero()
)}
);
}
}
Hero.defaultProps = {
data: {},
border: `border`,
items: 1, // Number of stories to display
};
================================================
FILE: content-src/components/DiscoveryStreamComponents/Hero/_Hero.scss
================================================
$card-header-in-hero-font-size: 14;
$card-header-in-hero-line-height: 20;
.ds-hero {
position: relative;
header {
font-weight: 600;
}
p {
line-height: 1.538;
margin: 8px 0;
}
.excerpt {
@include limit-visible-lines(3, 24, 15);
@include dark-theme-only {
color: $grey-10;
}
color: $grey-90;
margin: 0 0 10px;
}
.ds-card:not(.placeholder) {
border: 0;
padding-bottom: 20px;
&:hover {
border: 0;
box-shadow: none;
border-radius: 0;
}
.meta {
padding: 0;
}
.img-wrapper {
margin: 0 0 12px;
}
}
.ds-card.placeholder {
margin-bottom: 20px;
padding-bottom: 20px;
min-height: 180px;
}
.img-wrapper {
margin: 0 0 12px;
}
.ds-hero-item {
position: relative;
}
// "1/3 width layout" (aka "Mobile First")
.wrapper {
@include ds-border-top;
@include dark-theme-only {
color: $grey-30;
}
color: $grey-50;
display: block;
margin: 12px 0 16px;
padding-top: 16px;
height: 100%;
&:focus {
@include ds-fade-in;
}
@at-root .ds-hero-no-border .ds-hero-item .wrapper {
border-top: 0;
border-bottom: 0;
padding: 0 0 8px;
}
&:hover .meta header {
@include dark-theme-only {
color: $blue-40;
}
color: $blue-60;
}
&:active .meta header {
@include dark-theme-only {
color: $blue-40;
}
color: $blue-70;
}
.img-wrapper {
width: 100%;
}
.img {
height: 0;
padding-top: 50%; // 2:1 aspect ratio
img {
border-radius: 4px;
box-shadow: inset 0 0 0 0.5px $black-15;
}
}
.meta {
display: block;
flex-direction: column;
justify-content: space-between;
.header-and-excerpt {
flex: 1;
}
header {
@include dark-theme-only {
color: $white;
}
@include limit-visible-lines(4, 28, 22);
color: $grey-90;
margin-bottom: 0;
}
.context,
.source {
margin: 0 0 4px;
}
.context {
@include dark-theme-only {
color: $teal-10;
}
color: $teal-70;
}
.source {
@include dark-theme-only {
color: $grey-40;
}
font-size: 13px;
color: $grey-50;
-webkit-line-clamp: 1;
margin-bottom: 0;
}
}
}
// "2/3 width layout"
.ds-column-5 &,
.ds-column-6 &,
.ds-column-7 &,
.ds-column-8 & {
.wrapper {
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-column-gap: 24px;
.img-wrapper {
margin: 0;
grid-column: 2;
grid-row: 1;
}
.meta {
grid-column: 1;
grid-row: 1;
display: flex;
}
.img {
height: 0;
padding-top: 100%; // 1:1 aspect ratio
}
}
.cards {
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-column-gap: 24px;
grid-auto-rows: min-content;
}
}
// "Full width layout"
.ds-column-9 &,
.ds-column-10 &,
.ds-column-11 &,
.ds-column-12 & {
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-column-gap: 24px;
&.ds-hero-border {
@include ds-border-top;
padding: 20px 0;
.ds-card:not(.placeholder):nth-child(-n+2) {
@include ds-border-bottom;
margin-bottom: 20px;
}
}
.wrapper {
border-top: 0;
border-bottom: 0;
margin: 0;
padding: 0 0 20px;
display: flex;
flex-direction: column;
.img-wrapper {
margin: 0;
}
.img {
margin-bottom: 12px;
height: 0;
padding-top: 50%; // 2:1 aspect ratio
}
.meta {
flex-grow: 1;
display: flex;
padding: 0 24px 0 0;
header {
@include limit-visible-lines(3, 28, 22);
}
.source {
margin-bottom: 0;
}
}
}
.cards {
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-column-gap: 24px;
grid-auto-rows: min-content;
.ds-card {
&:hover {
@include dark-theme-only {
background: none;
.title {
color: $blue-40;
}
}
}
&:active .title {
@include dark-theme-only {
color: $blue-50;
}
}
.title {
@include dark-theme-only {
color: $white;
}
@include limit-visible-lines(3, 20, 14);
}
}
}
}
&.empty {
grid-template-columns: auto;
}
}
================================================
FILE: content-src/components/DiscoveryStreamComponents/Highlights/Highlights.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 { connect } from "react-redux";
import React from "react";
import { SectionIntl } from "content-src/components/Sections/Sections";
export class _Highlights extends React.PureComponent {
render() {
const section = this.props.Sections.find(s => s.id === "highlights");
if (!section || !section.enabled) {
return null;
}
return (
);
}
}
export const Highlights = connect(state => ({ Sections: state.Sections }))(
_Highlights
);
================================================
FILE: content-src/components/DiscoveryStreamComponents/Highlights/_Highlights.scss
================================================
.ds-highlights {
.section {
margin: 0 (-$section-horizontal-padding);
.section-list {
grid-gap: var(--gridRowGap);
grid-template-columns: repeat(4, 1fr);
.card-outer {
$line-height: 20px;
height: 175px;
.card-host-name {
font-size: 13px;
line-height: $line-height;
margin-bottom: 2px;
padding-bottom: 0;
text-transform: unset; // sass-lint:disable-line no-disallowed-properties
}
.card-title {
font-size: 14px;
font-weight: 600;
line-height: $line-height;
max-height: $line-height;
}
}
}
}
.hide-for-narrow {
display: block;
}
}
================================================
FILE: content-src/components/DiscoveryStreamComponents/HorizontalRule/HorizontalRule.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 HorizontalRule extends React.PureComponent {
render() {
return ;
}
}
================================================
FILE: content-src/components/DiscoveryStreamComponents/HorizontalRule/_HorizontalRule.scss
================================================
.ds-hr {
@include ds-border-top {
border: 0;
};
height: 0;
}
================================================
FILE: content-src/components/DiscoveryStreamComponents/List/List.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 { connect } from "react-redux";
import { DSEmptyState } from "../DSEmptyState/DSEmptyState.jsx";
import { DSImage } from "../DSImage/DSImage.jsx";
import { DSLinkMenu } from "../DSLinkMenu/DSLinkMenu";
import { ImpressionStats } from "../../DiscoveryStreamImpressionStats/ImpressionStats";
import React from "react";
import { SafeAnchor } from "../SafeAnchor/SafeAnchor";
import { DSContextFooter } from "../DSContextFooter/DSContextFooter.jsx";
/**
* @note exported for testing only
*/
export class ListItem extends React.PureComponent {
// TODO performance: get feeds to send appropriately sized images rather
// than waiting longer and scaling down on client?
constructor(props) {
super(props);
this.onLinkClick = this.onLinkClick.bind(this);
}
onLinkClick(event) {
if (this.props.dispatch) {
this.props.dispatch(
ac.UserEvent({
event: "CLICK",
source: this.props.type.toUpperCase(),
action_position: this.props.pos,
value: { card_type: this.props.flightId ? "spoc" : "organic" },
})
);
this.props.dispatch(
ac.ImpressionStats({
source: this.props.type.toUpperCase(),
click: 0,
tiles: [
{
id: this.props.id,
pos: this.props.pos,
...(this.props.shim && this.props.shim.click
? { shim: this.props.shim.click }
: {}),
},
],
})
);
}
}
render() {
return (
{this.props.domain}
{this.props.title}
{this.props.excerpt && (
{this.props.excerpt}
)}
{!this.props.placeholder && (
)}
);
}
}
export const PlaceholderListItem = props => ;
/**
* @note exported for testing only
*/
export function _List(props) {
const renderList = () => {
const recs = props.data.recommendations.slice(
props.recStartingPoint,
props.recStartingPoint + props.items
);
const recMarkup = [];
for (let index = 0; index < props.items; index++) {
const rec = recs[index];
recMarkup.push(
!rec || rec.placeholder ? (
) : (
)
);
}
const listStyles = [
"ds-list",
props.fullWidth ? "ds-list-full-width" : "",
props.hasBorders ? "ds-list-borders" : "",
props.hasImages ? "ds-list-images" : "",
props.hasNumbers ? "ds-list-numbers" : "",
];
return ;
};
const { data } = props;
if (!data || !data.recommendations) {
return null;
}
// Handle the case where a user has dismissed all recommendations
const isEmpty = data.recommendations.length === 0;
return (
{props.header && props.header.title ? (
{props.header.title}
) : null}
{isEmpty ? (
) : (
renderList()
)}
);
}
_List.defaultProps = {
recStartingPoint: 0, // Index of recommendations to start displaying from
fullWidth: false, // Display items taking up the whole column
hasBorders: false, // Display lines separating each item
hasImages: false, // Display images for each item
hasNumbers: false, // Display numbers for each item
items: 6, // Number of stories to display. TODO: get from endpoint
};
export const List = connect(state => ({
DiscoveryStream: state.DiscoveryStream,
}))(_List);
================================================
FILE: content-src/components/DiscoveryStreamComponents/List/_List.scss
================================================
// Type sizes
$bordered-spacing: 16px;
$item-font-size: 14;
$item-image-size: 80px;
$item-line-height: 20;
// XXX this is gross, and attaches the bottom-border to the item above.
// Ideally, we'd attach the top-border to the item that needs it.
// Unfortunately the border needs to go _above_ the row gap as currently
// set up, which means that some refactoring will be required to do this.
@mixin bottom-border-except-last-grid-row($columns) {
.ds-list-item:not(.placeholder):not(:nth-last-child(-n+#{$columns})) {
@include ds-border-bottom;
margin-bottom: -1px; // cancel out the pixel we used for the border
padding-bottom: $bordered-spacing;
}
}
@mixin set-item-sizes($font-size, $line-height, $image-size) {
.ds-list-item {
// XXX see if we really want absolute units, maybe hoist somewhere central?
font-size: $font-size * 1px;
line-height: $line-height * 1px;
position: relative;
}
.ds-list-item-title {
@include limit-visible-lines(3, $line-height, $font-size);
}
.ds-list-image {
min-width: $image-size;
width: $image-size;
}
}
.ds-list {
display: grid;
grid-row-gap: 24px;
grid-column-gap: 24px;
// reset some stuff from . Should maybe be hoisted when we have better
// regression detection?
padding-inline-start: 0;
&:not(.ds-list-full-width) {
@include set-item-sizes($item-font-size, $item-line-height, $item-image-size);
// "2/3 width layout"
.ds-column-5 &,
.ds-column-6 &,
.ds-column-7 &,
.ds-column-8 & {
grid-template-columns: repeat(2, 1fr);
}
// "Full width layout"
.ds-column-9 &,
.ds-column-10 &,
.ds-column-11 &,
.ds-column-12 & {
grid-template-columns: repeat(3, 1fr);
}
&.empty {
grid-template-columns: auto;
}
.ds-list-item-excerpt {
display: none;
}
}
&:not(.ds-list-images) {
.ds-list-image {
display: none;
}
}
a {
@include dark-theme-only {
color: $grey-10;
}
color: $grey-90;
}
}
.ds-list-item-link:focus {
@include ds-fade-in;
}
.ds-list-numbers {
$counter-whitespace: ($item-line-height - $item-font-size) * 1px;
$counter-size: 32px;
$counter-padded-size: $counter-size + $counter-whitespace * 1.5;
.ds-list-item {
counter-increment: list;
}
.ds-list-item:not(.placeholder) > .ds-list-item-link {
padding-inline-start: $counter-padded-size;
&::before {
@include dark-theme-only {
background-color: $teal-70;
}
background-color: $pocket-teal;
border-radius: $counter-size;
color: $white;
content: counter(list);
font-size: 17px;
height: $counter-size;
line-height: $counter-size;
margin-inline-start: -$counter-padded-size;
margin-top: $counter-whitespace / 2;
position: absolute;
text-align: center;
width: $counter-size;
}
&:hover::before {
@include dark-theme-only {
background-color: $blue-40;
}
background-color: $blue-40;
}
&:active::before {
@include dark-theme-only {
background-color: $blue-60;
}
background-color: $blue-70;
}
}
}
.ds-list-borders {
@include ds-border-top;
grid-row-gap: $bordered-spacing;
padding-top: $bordered-spacing;
&.ds-list-full-width,
.ds-column-1 &,
.ds-column-2 &,
.ds-column-3 &,
.ds-column-4 & {
@include bottom-border-except-last-grid-row(1);
}
&:not(.ds-list-full-width) {
// "2/3 width layout"
.ds-column-5 &,
.ds-column-6 &,
.ds-column-7 &,
.ds-column-8 & {
@include bottom-border-except-last-grid-row(2);
}
// "Full width layout"
.ds-column-9 &,
.ds-column-10 &,
.ds-column-11 &,
.ds-column-12 & {
@include bottom-border-except-last-grid-row(3);
}
}
}
.ds-list-full-width {
@include set-item-sizes(17, 24, $item-image-size * 2);
}
.ds-list-item {
// reset some stuff from . Should maybe be hoisted when we have better
// regression detection?
display: block;
text-align: start;
&.placeholder {
background: transparent;
min-height: $item-image-size;
box-shadow: inset $inner-box-shadow;
border-radius: 4px;
.ds-list-item-link {
cursor: default;
}
.ds-list-image {
opacity: 0;
}
}
.ds-list-item-link {
mix-blend-mode: normal;
display: flex;
justify-content: space-between;
height: 100%;
}
.ds-list-item-excerpt {
@include limit-visible-lines(2, $item-line-height, $item-font-size);
@include dark-theme-only {
color: $grey-10-80;
}
color: $grey-50;
margin: 4px 0 8px;
}
p {
font-size: $item-font-size * 1px;
line-height: $item-line-height * 1px;
margin: 0;
}
.ds-list-item-info {
@include limit-visible-lines(1, $item-line-height, $item-font-size);
@include dark-theme-only {
color: $grey-40;
}
color: $grey-50;
font-size: 13px;
}
.ds-list-item-title {
font-weight: 600;
margin-bottom: 4px;
}
.ds-list-item-body {
flex: 1;
}
.ds-list-item-text {
display: flex;
flex-direction: column;
justify-content: space-between;
}
.ds-list-image {
height: $item-image-size;
margin-inline-start: $item-font-size * 1px;
min-height: $item-image-size;
img {
border-radius: 4px;
box-shadow: inset 0 0 0 0.5px $black-15;
}
}
&:hover {
.ds-list-item-title {
color: $blue-40;
}
}
&:active {
.ds-list-item-title {
color: $blue-70;
}
}
}
================================================
FILE: content-src/components/DiscoveryStreamComponents/Navigation/Navigation.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 { SafeAnchor } from "../SafeAnchor/SafeAnchor";
import { FluentOrText } from "content-src/components/FluentOrText/FluentOrText";
export class Topic extends React.PureComponent {
render() {
const { url, name } = this.props;
return (
{name}
);
}
}
export class Navigation extends React.PureComponent {
render() {
const { links } = this.props || [];
const { alignment } = this.props || "centered";
const header = this.props.header || {};
return (
{header.title ? (
) : null}
{links &&
links.map(t => )}
);
}
}
================================================
FILE: content-src/components/DiscoveryStreamComponents/Navigation/_Navigation.scss
================================================
.ds-navigation {
line-height: 32px;
padding: 4px 0;
font-size: 14px;
font-weight: 600;
&.ds-navigation-centered {
text-align: center;
}
&.ds-navigation-right-aligned {
text-align: end;
}
ul {
margin: 0;
padding: 0;
}
ul li {
display: inline-block;
&::after {
content: '·';
padding: 8px;
color: $grey-50;
}
&:last-child::after {
content: none;
}
a {
&:hover {
// text-decoration: underline; didn't quite match comps.
border-bottom: 1px solid var(--newtab-link-primary-color);
&:active {
border-bottom: 1px solid $blue-70;
}
}
&:active {
color: $blue-70;
}
}
}
.ds-header {
margin-bottom: 8px;
}
}
================================================
FILE: content-src/components/DiscoveryStreamComponents/SafeAnchor/SafeAnchor.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 React from "react";
export class SafeAnchor extends React.PureComponent {
constructor(props) {
super(props);
this.onClick = this.onClick.bind(this);
}
onClick(event) {
// Use dispatch instead of normal link click behavior to include referrer
if (this.props.dispatch) {
event.preventDefault();
const { altKey, button, ctrlKey, metaKey, shiftKey } = event;
this.props.dispatch(
ac.OnlyToMain({
type: at.OPEN_LINK,
data: {
event: { altKey, button, ctrlKey, metaKey, shiftKey },
referrer: "https://getpocket.com/recommendations",
// Use the anchor's url, which could have been cleaned up
url: event.currentTarget.href,
},
})
);
}
// Propagate event if there's a handler
if (this.props.onLinkClick) {
this.props.onLinkClick(event);
}
}
safeURI(url) {
let protocol = null;
try {
protocol = new URL(url).protocol;
} catch (e) {
return "";
}
const isAllowed = ["http:", "https:"].includes(protocol);
if (!isAllowed) {
console.warn(`${url} is not allowed for anchor targets.`); // eslint-disable-line no-console
return "";
}
return url;
}
render() {
const { url, className } = this.props;
return (
{this.props.children}
);
}
}
================================================
FILE: content-src/components/DiscoveryStreamComponents/SectionTitle/SectionTitle.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 SectionTitle extends React.PureComponent {
render() {
const {
header: { title, subtitle },
} = this.props;
return (
{title}
{subtitle ?
{subtitle}
: null}
);
}
}
================================================
FILE: content-src/components/DiscoveryStreamComponents/SectionTitle/_SectionTitle.scss
================================================
.ds-section-title {
text-align: center;
margin-top: 24px;
.title {
@include dark-theme-only {
color: $white;
}
line-height: 48px;
font-size: 36px;
font-weight: 300;
color: $grey-90;
}
.subtitle {
@include dark-theme-only {
color: $grey-30;
}
line-height: 24px;
font-size: 14px;
color: $grey-50;
margin-top: 4px;
}
}
================================================
FILE: content-src/components/DiscoveryStreamComponents/TopSites/TopSites.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 { connect } from "react-redux";
import { TopSites as OldTopSites } from "content-src/components/TopSites/TopSites";
import { TOP_SITES_MAX_SITES_PER_ROW } from "common/Reducers.jsm";
import React from "react";
export class _TopSites extends React.PureComponent {
// Find a SPOC that doesn't already exist in User's TopSites
getFirstAvailableSpoc(topSites, data) {
const { spocs } = data;
if (!spocs || spocs.length === 0) {
return null;
}
const userTopSites = new Set(
topSites.map(topSite => topSite && topSite.url)
);
// We "clean urls" with http in TopSiteForm.jsx
// Spoc domains are in the format 'sponsorname.com'
return spocs.find(
spoc =>
!userTopSites.has(spoc.url) &&
!userTopSites.has(`http://${spoc.domain}`) &&
!userTopSites.has(`https://${spoc.domain}`) &&
!userTopSites.has(`http://www.${spoc.domain}`) &&
!userTopSites.has(`https://www.${spoc.domain}`)
);
}
// Find the first empty or unpinned index we can place the SPOC in.
// Return -1 if no available index and we should push it at the end.
getFirstAvailableIndex(topSites, promoAlignment) {
if (promoAlignment === "left") {
return topSites.findIndex(topSite => !topSite || !topSite.isPinned);
}
// The row isn't full so we can push it to the end of the row.
if (topSites.length < TOP_SITES_MAX_SITES_PER_ROW) {
return -1;
}
// If the row is full, we can check the row first for unpinned topsites to replace.
// Else we can check after the row. This behavior is how unpinned topsites move while drag and drop.
let endOfRow = TOP_SITES_MAX_SITES_PER_ROW - 1;
for (let i = endOfRow; i >= 0; i--) {
if (!topSites[i] || !topSites[i].isPinned) {
return i;
}
}
for (let i = endOfRow + 1; i < topSites.length; i++) {
if (!topSites[i] || !topSites[i].isPinned) {
return i;
}
}
return -1;
}
insertSpocContent(TopSites, data, promoAlignment) {
if (
!TopSites.rows ||
TopSites.rows.length === 0 ||
!data.spocs ||
data.spocs.length === 0
) {
return null;
}
let topSites = [...TopSites.rows];
const topSiteSpoc = this.getFirstAvailableSpoc(topSites, data);
if (!topSiteSpoc) {
return null;
}
const link = {
customScreenshotURL: topSiteSpoc.image_src,
type: "SPOC",
label: topSiteSpoc.sponsor,
title: topSiteSpoc.sponsor,
url: topSiteSpoc.url,
flightId: topSiteSpoc.flight_id,
id: topSiteSpoc.id,
guid: topSiteSpoc.id,
shim: topSiteSpoc.shim,
// For now we are assuming position based on intended position.
// Actual position can shift based on other content.
// We also hard code left and right to be 0 and 7.
// We send the intended postion in the ping.
pos: promoAlignment === "left" ? 0 : 7,
};
const firstAvailableIndex = this.getFirstAvailableIndex(
topSites,
promoAlignment
);
if (firstAvailableIndex === -1) {
topSites.push(link);
} else {
// Normal insertion will not work since pinned topsites are in their correct index already
// Similar logic is done to handle drag and drop with pinned topsites in TopSite.jsx
let shiftedTopSite = topSites[firstAvailableIndex];
let index = firstAvailableIndex + 1;
// Shift unpinned topsites to the right by finding the next unpinned topsite to replace
while (shiftedTopSite) {
if (index === topSites.length) {
topSites.push(shiftedTopSite);
shiftedTopSite = null;
} else if (topSites[index] && topSites[index].isPinned) {
index += 1;
} else {
const nextTopSite = topSites[index];
topSites[index] = shiftedTopSite;
shiftedTopSite = nextTopSite;
index += 1;
}
}
topSites[firstAvailableIndex] = link;
}
return { ...TopSites, rows: topSites };
}
render() {
const { header = {}, data, promoAlignment, TopSites } = this.props;
const TopSitesWithSpoc =
TopSites && data && promoAlignment
? this.insertSpocContent(TopSites, data, promoAlignment)
: null;
return (
);
}
}
export const TopSites = connect(state => ({ TopSites: state.TopSites }))(
_TopSites
);
================================================
FILE: content-src/components/DiscoveryStreamComponents/TopSites/_TopSites.scss
================================================
$top-sites-vertical-space-with-spoc: 20px;
// ds topsites wraps the original topsites, with a few css changes.
.ds-top-sites {
// This is the override layer.
.top-sites {
// Slightly different alignment with the other DS components than AS has.
margin: 0 (-$section-horizontal-padding);
.top-site-outer {
padding: 0 12px;
.top-site-inner > a:-moz-any(.active, :focus) .tile {
@include ds-fade-in;
@include dark-theme-only {
@include ds-fade-in($blue-40-40);
}
}
.top-site-inner > a:-moz-any(:hover) .tile {
@include ds-fade-in($grey-30);
@include dark-theme-only {
@include ds-fade-in($grey-60);
}
}
}
.top-sites-list {
margin: 0 -12px;
}
}
// Only show 6 cards for 2/3 and 1/3
// XXX hide-for-narrow is wrapping a previous functionality, can do better.
.hide-for-narrow {
display: none;
}
}
// Only show 8 cards for the full row.
// XXX hide-for-narrow is wrapping a previous functionality, can do better.
.ds-column-9,
.ds-column-10,
.ds-column-11,
.ds-column-12 {
.ds-top-sites {
.hide-for-narrow {
display: inline-block;
}
}
}
// Size overrides for topsites in the 2/3 view.
.ds-column-5,
.ds-column-6,
.ds-column-7,
.ds-column-8 {
.ds-top-sites {
.top-site-outer {
padding: 0 10px;
}
.top-sites-list {
margin: 0 -10px;
}
.top-site-inner {
--leftPanelIconWidth: 84.67px;
.tile {
width: var(--leftPanelIconWidth);
height: var(--leftPanelIconWidth);
}
.title {
width: var(--leftPanelIconWidth);
}
}
}
}
// Size overrides for topsites in the 1/3 view.
.ds-column-1,
.ds-column-2,
.ds-column-3,
.ds-column-4 {
.ds-top-sites {
.top-site-outer {
padding: 0 8px;
}
.top-sites-list {
margin: 0 -8px;
}
.top-site-inner {
--rightPanelIconWidth: 82.67px;
.tile {
width: var(--rightPanelIconWidth);
height: var(--rightPanelIconWidth);
}
.title {
width: var(--rightPanelIconWidth);
}
}
}
}
.top-sites-spoc {
.top-sites-list {
display: flex;
flex-wrap: wrap;
.top-site-outer {
margin: 0 0 $top-sites-vertical-space-with-spoc;
.top-site-spoc-label {
@include dark-theme-only {
color: $grey-40;
}
color: $grey-50;
font-size: 11px;
display: flex;
justify-content: center;
margin-top: -4px;
}
&.dragged {
.top-site-spoc-label {
visibility: hidden;
}
}
}
}
}
================================================
FILE: content-src/components/DiscoveryStreamImpressionStats/ImpressionStats.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 React from "react";
const VISIBLE = "visible";
const VISIBILITY_CHANGE_EVENT = "visibilitychange";
// Per analytical requirement, we set the minimal intersection ratio to
// 0.5, and an impression is identified when the wrapped item has at least
// 50% visibility.
//
// This constant is exported for unit test
export const INTERSECTION_RATIO = 0.5;
/**
* Impression wrapper for Discovery Stream related React components.
*
* It makses use of the Intersection Observer API to detect the visibility,
* and relies on page visibility to ensure the impression is reported
* only when the component is visible on the page.
*
* Note:
* * This wrapper used to be used either at the individual card level,
* or by the card container components.
* It is now only used for individual card level.
* * Each impression will be sent only once as soon as the desired
* visibility is detected
* * Batching is not yet implemented, hence it might send multiple
* impression pings separately
*/
export class ImpressionStats extends React.PureComponent {
// This checks if the given cards are the same as those in the last impression ping.
// If so, it should not send the same impression ping again.
_needsImpressionStats(cards) {
if (
!this.impressionCardGuids ||
this.impressionCardGuids.length !== cards.length
) {
return true;
}
for (let i = 0; i < cards.length; i++) {
if (cards[i].id !== this.impressionCardGuids[i]) {
return true;
}
}
return false;
}
_dispatchImpressionStats() {
const { props } = this;
const cards = props.rows;
if (this.props.flightId) {
this.props.dispatch(
ac.OnlyToMain({
type: at.DISCOVERY_STREAM_SPOC_IMPRESSION,
data: { flightId: this.props.flightId },
})
);
}
if (this._needsImpressionStats(cards)) {
props.dispatch(
ac.DiscoveryStreamImpressionStats({
source: props.source.toUpperCase(),
tiles: cards.map(link => ({
id: link.id,
pos: link.pos,
...(link.shim ? { shim: link.shim } : {}),
})),
})
);
this.impressionCardGuids = cards.map(link => link.id);
}
}
// This checks if the given cards are the same as those in the last loaded content ping.
// If so, it should not send the same loaded content ping again.
_needsLoadedContent(cards) {
if (
!this.loadedContentGuids ||
this.loadedContentGuids.length !== cards.length
) {
return true;
}
for (let i = 0; i < cards.length; i++) {
if (cards[i].id !== this.loadedContentGuids[i]) {
return true;
}
}
return false;
}
_dispatchLoadedContent() {
const { props } = this;
const cards = props.rows;
if (this._needsLoadedContent(cards)) {
props.dispatch(
ac.DiscoveryStreamLoadedContent({
source: props.source.toUpperCase(),
tiles: cards.map(link => ({ id: link.id, pos: link.pos })),
})
);
this.loadedContentGuids = cards.map(link => link.id);
}
}
setImpressionObserverOrAddListener() {
const { props } = this;
if (!props.dispatch) {
return;
}
if (props.document.visibilityState === VISIBLE) {
// Send the loaded content ping once the page is visible.
this._dispatchLoadedContent();
this.setImpressionObserver();
} else {
// We should only ever send the latest impression stats ping, so remove any
// older listeners.
if (this._onVisibilityChange) {
props.document.removeEventListener(
VISIBILITY_CHANGE_EVENT,
this._onVisibilityChange
);
}
this._onVisibilityChange = () => {
if (props.document.visibilityState === VISIBLE) {
// Send the loaded content ping once the page is visible.
this._dispatchLoadedContent();
this.setImpressionObserver();
props.document.removeEventListener(
VISIBILITY_CHANGE_EVENT,
this._onVisibilityChange
);
}
};
props.document.addEventListener(
VISIBILITY_CHANGE_EVENT,
this._onVisibilityChange
);
}
}
/**
* Set an impression observer for the wrapped component. It makes use of
* the Intersection Observer API to detect if the wrapped component is
* visible with a desired ratio, and only sends impression if that's the case.
*
* See more details about Intersection Observer API at:
* https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API
*/
setImpressionObserver() {
const { props } = this;
if (!props.rows.length) {
return;
}
this._handleIntersect = entries => {
if (
entries.some(
entry =>
entry.isIntersecting &&
entry.intersectionRatio >= INTERSECTION_RATIO
)
) {
this._dispatchImpressionStats();
this.impressionObserver.unobserve(this.refs.impression);
}
};
const options = { threshold: INTERSECTION_RATIO };
this.impressionObserver = new props.IntersectionObserver(
this._handleIntersect,
options
);
this.impressionObserver.observe(this.refs.impression);
}
componentDidMount() {
if (this.props.rows.length) {
this.setImpressionObserverOrAddListener();
}
}
componentWillUnmount() {
if (this._handleIntersect && this.impressionObserver) {
this.impressionObserver.unobserve(this.refs.impression);
}
if (this._onVisibilityChange) {
this.props.document.removeEventListener(
VISIBILITY_CHANGE_EVENT,
this._onVisibilityChange
);
}
}
render() {
return (
{this.props.children}
);
}
}
ImpressionStats.defaultProps = {
IntersectionObserver: global.IntersectionObserver,
document: global.document,
rows: [],
source: "",
};
================================================
FILE: content-src/components/DiscoveryStreamImpressionStats/_ImpressionStats.scss
================================================
.impression-observer {
position: absolute;
top: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
================================================
FILE: content-src/components/ErrorBoundary/ErrorBoundary.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 { A11yLinkButton } from "content-src/components/A11yLinkButton/A11yLinkButton";
import React from "react";
export class ErrorBoundaryFallback extends React.PureComponent {
constructor(props) {
super(props);
this.windowObj = this.props.windowObj || window;
this.onClick = this.onClick.bind(this);
}
/**
* Since we only get here if part of the page has crashed, do a
* forced reload to give us the best chance at recovering.
*/
onClick() {
this.windowObj.location.reload(true);
}
render() {
const defaultClass = "as-error-fallback";
let className;
if ("className" in this.props) {
className = `${this.props.className} ${defaultClass}`;
} else {
className = defaultClass;
}
// "A11yLinkButton" to force normal link styling stuff (eg cursor on hover)
return (
);
}
}
ErrorBoundaryFallback.defaultProps = { className: "as-error-fallback" };
export class ErrorBoundary extends React.PureComponent {
constructor(props) {
super(props);
this.state = { hasError: false };
}
componentDidCatch(error, info) {
this.setState({ hasError: true });
}
render() {
if (!this.state.hasError) {
return this.props.children;
}
return ;
}
}
ErrorBoundary.defaultProps = { FallbackComponent: ErrorBoundaryFallback };
================================================
FILE: content-src/components/ErrorBoundary/_ErrorBoundary.scss
================================================
.as-error-fallback {
align-items: center;
border-radius: $border-radius;
box-shadow: inset $inner-box-shadow;
color: var(--newtab-text-conditional-color);
display: flex;
flex-direction: column;
font-size: $error-fallback-font-size;
justify-content: center;
justify-items: center;
line-height: $error-fallback-line-height;
&.borderless-error {
box-shadow: none;
}
a {
color: var(--newtab-text-conditional-color);
text-decoration: underline;
}
}
================================================
FILE: content-src/components/FluentOrText/FluentOrText.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";
/**
* Set text on a child element/component depending on if the message is already
* translated plain text or a fluent id with optional args.
*/
export class FluentOrText extends React.PureComponent {
render() {
// Ensure we have a single child to attach attributes
const { children, message } = this.props;
const child = children ? React.Children.only(children) : ;
// For a string message, just use it as the child's text
let grandChildren = message;
let extraProps;
// Convert a message object to set desired fluent-dom attributes
if (typeof message === "object") {
const args = message.args || message.values;
extraProps = {
"data-l10n-args": args && JSON.stringify(args),
"data-l10n-id": message.id || message.string_id,
};
// Use original children potentially with data-l10n-name attributes
grandChildren = child.props.children;
}
// Add the message to the child via fluent attributes or text node
return React.cloneElement(child, extraProps, grandChildren);
}
}
================================================
FILE: content-src/components/LinkMenu/LinkMenu.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 { connect } from "react-redux";
import { ContextMenu } from "content-src/components/ContextMenu/ContextMenu";
import { LinkMenuOptions } from "content-src/lib/link-menu-options";
import React from "react";
const DEFAULT_SITE_MENU_OPTIONS = [
"CheckPinTopSite",
"EditTopSite",
"Separator",
"OpenInNewWindow",
"OpenInPrivateWindow",
"Separator",
"BlockUrl",
];
export class _LinkMenu extends React.PureComponent {
getOptions() {
const { props } = this;
const {
site,
index,
source,
isPrivateBrowsingEnabled,
siteInfo,
platform,
} = props;
// Handle special case of default site
const propOptions =
!site.isDefault || site.searchTopSite
? props.options
: DEFAULT_SITE_MENU_OPTIONS;
const options = propOptions
.map(o =>
LinkMenuOptions[o](
site,
index,
source,
isPrivateBrowsingEnabled,
siteInfo,
platform
)
)
.map(option => {
const { action, impression, id, type, userEvent } = option;
if (!type && id) {
option.onClick = () => {
props.dispatch(action);
if (userEvent) {
const userEventData = Object.assign(
{
event: userEvent,
source,
action_position: index,
},
siteInfo
);
props.dispatch(ac.UserEvent(userEventData));
}
if (impression && props.shouldSendImpressionStats) {
props.dispatch(impression);
}
};
}
return option;
});
// This is for accessibility to support making each item tabbable.
// We want to know which item is the first and which item
// is the last, so we can close the context menu accordingly.
options[0].first = true;
options[options.length - 1].last = true;
return options;
}
render() {
return (
);
}
}
const getState = state => ({
isPrivateBrowsingEnabled: state.Prefs.values.isPrivateBrowsingEnabled,
platform: state.Prefs.values.platform,
});
export const LinkMenu = connect(getState)(_LinkMenu);
================================================
FILE: content-src/components/MoreRecommendations/MoreRecommendations.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 MoreRecommendations extends React.PureComponent {
render() {
const { read_more_endpoint } = this.props;
if (read_more_endpoint) {
return (
);
}
return null;
}
}
================================================
FILE: content-src/components/MoreRecommendations/_MoreRecommendations.scss
================================================
.more-recommendations {
display: flex;
align-items: center;
white-space: nowrap;
line-height: 1.230769231; // (16 / 13) -> 16px computed
&::after {
background: url('#{$image-path}topic-show-more-12.svg') no-repeat center center;
content: '';
-moz-context-properties: fill;
display: inline-block;
fill: var(--newtab-link-secondary-color);
height: 16px;
margin-inline-start: 5px;
vertical-align: top;
width: 12px;
}
&:dir(rtl)::after {
transform: scaleX(-1);
}
}
================================================
FILE: content-src/components/PocketLoggedInCta/PocketLoggedInCta.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 { connect } from "react-redux";
import React from "react";
export class _PocketLoggedInCta extends React.PureComponent {
render() {
const { pocketCta } = this.props.Pocket;
return (
{pocketCta.ctaButton ? (
pocketCta.ctaButton
) : (
)}
{pocketCta.ctaText ? (
pocketCta.ctaText
) : (
)}
);
}
}
export const PocketLoggedInCta = connect(state => ({ Pocket: state.Pocket }))(
_PocketLoggedInCta
);
================================================
FILE: content-src/components/PocketLoggedInCta/_PocketLoggedInCta.scss
================================================
.pocket-logged-in-cta {
$max-button-width: 130px;
$min-button-height: 18px;
font-size: 13px;
margin-inline-end: 20px;
display: flex;
align-items: flex-start;
.pocket-cta-button {
white-space: nowrap;
background: $blue-60;
letter-spacing: -0.34px;
color: $white;
border-radius: 4px;
cursor: pointer;
max-width: $max-button-width;
// The button height is 2px taller than the rest of the cta text.
// So I move it up by 1px to align with the rest of the cta text.
margin-top: -1px;
min-height: $min-button-height;
padding: 0 8px;
display: inline-flex;
justify-content: center;
align-items: center;
font-size: 11px;
margin-inline-end: 10px;
}
.cta-text {
font-weight: normal;
font-size: 13px;
line-height: 1.230769231; // (16 / 13) –> 16px computed
}
.pocket-cta-button,
.cta-text {
vertical-align: top;
}
}
================================================
FILE: content-src/components/Search/Search.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/. */
/* globals ContentSearchUIController */
"use strict";
import { actionCreators as ac, actionTypes as at } from "common/Actions.jsm";
import { connect } from "react-redux";
import { IS_NEWTAB } from "content-src/lib/constants";
import React from "react";
export class _Search extends React.PureComponent {
constructor(props) {
super(props);
this.onSearchClick = this.onSearchClick.bind(this);
this.onSearchHandoffClick = this.onSearchHandoffClick.bind(this);
this.onSearchHandoffPaste = this.onSearchHandoffPaste.bind(this);
this.onSearchHandoffDrop = this.onSearchHandoffDrop.bind(this);
this.onInputMount = this.onInputMount.bind(this);
this.onSearchHandoffButtonMount = this.onSearchHandoffButtonMount.bind(
this
);
}
handleEvent(event) {
// Also track search events with our own telemetry
if (event.detail.type === "Search") {
this.props.dispatch(ac.UserEvent({ event: "SEARCH" }));
}
}
onSearchClick(event) {
window.gContentSearchController.search(event);
}
doSearchHandoff(text) {
this.props.dispatch(
ac.OnlyToMain({ type: at.HANDOFF_SEARCH_TO_AWESOMEBAR, data: { text } })
);
this.props.dispatch({ type: at.FAKE_FOCUS_SEARCH });
this.props.dispatch(ac.UserEvent({ event: "SEARCH_HANDOFF" }));
if (text) {
this.props.dispatch({ type: at.HIDE_SEARCH });
}
}
onSearchHandoffClick(event) {
// 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").
event.preventDefault();
this.doSearchHandoff();
}
onSearchHandoffPaste(event) {
event.preventDefault();
this.doSearchHandoff(event.clipboardData.getData("Text"));
}
onSearchHandoffDrop(event) {
event.preventDefault();
let text = event.dataTransfer.getData("text");
if (text) {
this.doSearchHandoff(text);
}
}
componentWillUnmount() {
delete window.gContentSearchController;
}
onInputMount(input) {
if (input) {
// The "healthReportKey" and needs to be "newtab" or "abouthome" so that
// BrowserUsageTelemetry.jsm knows to handle events with this name, and
// can add the appropriate telemetry probes for search. Without the correct
// name, certain tests like browser_UsageTelemetry_content.js will fail
// (See github ticket #2348 for more details)
const healthReportKey = IS_NEWTAB ? "newtab" : "abouthome";
// The "searchSource" needs to be "newtab" or "homepage" and is sent with
// the search data and acts as context for the search request (See
// nsISearchEngine.getSubmission). It is necessary so that search engine
// plugins can correctly atribute referrals. (See github ticket #3321 for
// more details)
const searchSource = IS_NEWTAB ? "newtab" : "homepage";
// gContentSearchController needs to exist as a global so that tests for
// the existing about:home can find it; and so it allows these tests to pass.
// In the future, when activity stream is default about:home, this can be renamed
window.gContentSearchController = new ContentSearchUIController(
input,
input.parentNode,
healthReportKey,
searchSource
);
addEventListener("ContentSearchClient", this);
} else {
window.gContentSearchController = null;
removeEventListener("ContentSearchClient", this);
}
}
onSearchHandoffButtonMount(button) {
// Keep a reference to the button for use during "paste" event handling.
this._searchHandoffButton = button;
}
/*
* Do not change the ID on the input field, as legacy newtab code
* specifically looks for the id 'newtab-search-text' on input fields
* in order to execute searches in various tests
*/
render() {
const wrapperClassName = [
"search-wrapper",
this.props.hide && "search-hidden",
this.props.fakeFocus && "fake-focus",
]
.filter(v => v)
.join(" ");
return (
{this.props.showLogo && (
)}
{!this.props.handoffEnabled && (
)}
{this.props.handoffEnabled && (
)}
);
}
}
export const Search = connect()(_Search);
================================================
FILE: content-src/components/Search/_Search.scss
================================================
$search-height: 48px;
$search-icon-size: 24px;
$search-icon-padding: 12px;
$search-icon-width: 2 * $search-icon-padding + $search-icon-size -2;
$search-button-width: 48px;
$glyph-forward: url('chrome://browser/skin/forward.svg');
.search-wrapper {
padding: 34px 0 64px;
.only-search & {
padding: 0 0 64px;
}
.logo-and-wordmark {
$logo-size: 96px;
$wordmark-size: 172px;
align-items: center;
display: flex;
justify-content: center;
margin-bottom: 49px;
.logo {
background: url('chrome://branding/content/icon128.png') no-repeat center center;
background-size: $logo-size;
display: inline-block;
height: $logo-size;
width: $logo-size;
}
.wordmark {
background: url('#{$image-path}firefox-wordmark.svg') no-repeat center center;
background-size: $wordmark-size;
-moz-context-properties: fill;
display: inline-block;
fill: var(--newtab-search-wordmark-color);
height: $logo-size;
margin-inline-start: 15px;
width: $wordmark-size;
}
@media (max-width: $break-point-medium - 1) {
$logo-size-small: 64px;
$wordmark-small-size: 100px;
.logo {
background-size: $logo-size-small;
height: $logo-size-small;
width: $logo-size-small;
}
.wordmark {
background-size: $wordmark-small-size;
height: $logo-size-small;
width: $wordmark-small-size;
}
}
}
.search-inner-wrapper {
cursor: default;
display: flex;
height: $search-height;
margin: 0 auto;
position: relative;
width: $searchbar-width-small;
@media (min-width: $break-point-medium) {
width: $searchbar-width-medium;
}
@media (min-width: $break-point-large) {
width: $searchbar-width-large;
}
}
input {
background: var(--newtab-textbox-background-color) var(--newtab-search-icon) $search-icon-padding center no-repeat;
background-size: $search-icon-size;
border: solid 1px var(--newtab-search-border-color);
box-shadow: $shadow-secondary, 0 0 0 1px $black-15;
font-size: 15px;
-moz-context-properties: fill;
fill: var(--newtab-search-icon-color);
padding: 0;
padding-inline-end: $search-button-width;
padding-inline-start: $search-icon-width;
width: 100%;
&:dir(rtl) {
background-position-x: right $search-icon-padding;
}
}
&:hover input {
box-shadow: $shadow-secondary, 0 0 0 1px $black-25;
}
.search-inner-wrapper:active input,
input:focus {
border: $input-border-active;
box-shadow: var(--newtab-textbox-focus-boxshadow);
}
.search-button {
background: $glyph-forward no-repeat center center;
background-size: 16px 16px;
border: 0;
border-radius: 0 $border-radius $border-radius 0;
-moz-context-properties: fill;
fill: var(--newtab-search-icon-color);
height: 100%;
inset-inline-end: 0;
position: absolute;
width: $search-button-width;
&:focus,
&:hover {
background-color: $grey-90-10;
cursor: pointer;
}
&:active {
background-color: $grey-90-20;
}
&:dir(rtl) {
transform: scaleX(-1);
}
}
}
.non-collapsible-section + .below-search-snippet-wrapper {
// If search is enabled, we need to invade its large bottom padding.
margin-top: -48px;
}
@media (max-height: 700px) {
.search-wrapper {
padding: 0 0 30px;
}
.non-collapsible-section + .below-search-snippet-wrapper {
// In shorter windows, search doesn't have such a large padding.
margin-top: -14px;
}
.below-search-snippet-wrapper {
min-height: 0;
}
}
.search-handoff-button {
background: var(--newtab-textbox-background-color) var(--newtab-search-icon) $search-icon-padding center no-repeat;
background-size: $search-icon-size;
border: solid 1px var(--newtab-search-border-color);
border-radius: 3px;
box-shadow: $shadow-secondary, 0 0 0 1px $black-15;
cursor: text;
font-size: 15px;
padding: 0;
padding-inline-end: 48px;
padding-inline-start: 46px;
opacity: 1;
transition: opacity 500ms;
width: 100%;
&:dir(rtl) {
background-position-x: right $search-icon-padding;
}
&:hover {
box-shadow: $shadow-secondary, 0 0 0 1px $black-25;
}
.fake-focus & {
border: $input-border-active;
box-shadow: var(--newtab-textbox-focus-boxshadow);
.fake-caret {
display: block;
}
}
.search-hidden & {
opacity: 0;
visibility: hidden;
}
.fake-editable:focus {
outline: none;
caret-color: transparent;
}
.fake-editable {
color: transparent;
height: 100%;
opacity: 0;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
.fake-textbox {
opacity: 0.54;
text-align: start;
}
.fake-caret {
animation: caret-animation 1.3s steps(5, start) infinite;
background: var(--newtab-text-primary-color);
display: none;
inset-inline-start: 47px;
height: 17px;
position: absolute;
top: 16px;
width: 1px;
@keyframes caret-animation {
to {
visibility: hidden;
}
}
}
}
@media (min-height: 701px) {
body:not(.inline-onboarding) .fixed-search {
main {
padding-top: 146px;
}
.search-wrapper {
$search-header-bar-height: 95px;
$search-height: 35px;
$search-icon-size: 16px;
$search-icon-padding: 16px;
background-color: var(--newtab-search-header-background-color);
border-bottom: solid 1px var(--newtab-border-secondary-color);
height: $search-header-bar-height;
left: 0;
padding: 30px 0;
position: fixed;
top: 0;
width: 100%;
z-index: 9;
.search-inner-wrapper {
height: $search-height;
}
input {
background-position-x: $search-icon-padding;
background-size: $search-icon-size;
&:dir(rtl) {
background-position-x: right $search-icon-padding;
}
}
}
.search-handoff-button {
background-position-x: $search-icon-padding;
background-size: $search-icon-size;
&:dir(rtl) {
background-position-x: right $search-icon-padding;
}
.fake-caret {
top: 10px;
}
}
}
}
@at-root {
// Adjust the style of the contentSearchUI-generated table
.contentSearchSuggestionTable {
background-color: var(--newtab-search-dropdown-color);
border: 0;
box-shadow: $context-menu-shadow;
transform: translateY($textbox-shadow-size);
.contentSearchHeader {
background-color: var(--newtab-search-dropdown-header-color);
color: var(--newtab-text-secondary-color);
}
.contentSearchHeader,
.contentSearchSettingsButton {
border-color: var(--newtab-border-secondary-color);
}
.contentSearchSuggestionsList {
border: 0;
}
.contentSearchOneOffsTable {
background-color: var(--newtab-search-dropdown-header-color);
border-top: solid 1px var(--newtab-border-secondary-color);
}
.contentSearchSearchWithHeaderSearchText {
color: var(--newtab-text-primary-color);
}
.contentSearchSuggestionsContainer {
background-color: var(--newtab-search-dropdown-color);
}
.contentSearchSuggestionRow {
&.selected {
background: var(--newtab-element-hover-color);
color: var(--newtab-text-primary-color);
&:active {
background: var(--newtab-element-active-color);
}
.historyIcon {
fill: var(--newtab-icon-secondary-color);
}
}
}
.contentSearchOneOffsTable {
.contentSearchSuggestionsContainer {
background-color: var(--newtab-search-dropdown-header-color);
}
}
.contentSearchOneOffItem {
// Make the border slightly shorter by offsetting from the top and bottom
$border-offset: 18%;
background-image: none;
border-image: linear-gradient(transparent $border-offset, var(--newtab-border-secondary-color) $border-offset, var(--newtab-border-secondary-color) 100% - $border-offset, transparent 100% - $border-offset) 1;
border-inline-end: 1px solid;
position: relative;
&.selected {
background: var(--newtab-element-hover-color);
}
&:active {
background: var(--newtab-element-active-color);
}
}
.contentSearchSettingsButton {
&:hover {
background: var(--newtab-element-hover-color);
color: var(--newtab-text-primary-color);
}
}
}
.contentSearchHeaderRow > td > img,
.contentSearchSuggestionRow > td > .historyIcon {
margin-inline-start: 7px;
margin-inline-end: 15px;
}
}
================================================
FILE: content-src/components/SectionMenu/SectionMenu.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 { ContextMenu } from "content-src/components/ContextMenu/ContextMenu";
import React from "react";
import { SectionMenuOptions } from "content-src/lib/section-menu-options";
const DEFAULT_SECTION_MENU_OPTIONS = [
"MoveUp",
"MoveDown",
"Separator",
"RemoveSection",
"CheckCollapsed",
"Separator",
"ManageSection",
];
const WEBEXT_SECTION_MENU_OPTIONS = [
"MoveUp",
"MoveDown",
"Separator",
"CheckCollapsed",
"Separator",
"ManageWebExtension",
];
export class _SectionMenu extends React.PureComponent {
handleAddWhileCollapsed() {
const { action, userEvent } = SectionMenuOptions.ExpandSection(this.props);
this.props.dispatch(action);
if (userEvent) {
this.props.dispatch(
ac.UserEvent({
event: userEvent,
source: this.props.source,
})
);
}
}
getOptions() {
const { props } = this;
const propOptions = props.isWebExtension
? [...WEBEXT_SECTION_MENU_OPTIONS]
: [...DEFAULT_SECTION_MENU_OPTIONS];
// Remove the move related options if the section is fixed
if (props.isFixed) {
propOptions.splice(propOptions.indexOf("MoveUp"), 3);
}
// Prepend custom options and a separator
if (props.extraOptions) {
propOptions.splice(0, 0, ...props.extraOptions, "Separator");
}
// Insert privacy notice before the last option ("ManageSection")
if (props.privacyNoticeURL) {
propOptions.splice(-1, 0, "PrivacyNotice");
}
const options = propOptions
.map(o => SectionMenuOptions[o](props))
.map(option => {
const { action, id, type, userEvent } = option;
if (!type && id) {
option.onClick = () => {
const hasAddEvent =
userEvent === "MENU_ADD_TOPSITE" ||
userEvent === "MENU_ADD_SEARCH";
if (props.collapsed && hasAddEvent) {
this.handleAddWhileCollapsed();
}
props.dispatch(action);
if (userEvent) {
props.dispatch(
ac.UserEvent({
event: userEvent,
source: props.source,
})
);
}
};
}
return option;
});
// This is for accessibility to support making each item tabbable.
// We want to know which item is the first and which item
// is the last, so we can close the context menu accordingly.
options[0].first = true;
options[options.length - 1].last = true;
return options;
}
render() {
return (
);
}
}
export const SectionMenu = _SectionMenu;
================================================
FILE: content-src/components/Sections/Sections.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 { Card, PlaceholderCard } from "content-src/components/Card/Card";
import { CollapsibleSection } from "content-src/components/CollapsibleSection/CollapsibleSection";
import { ComponentPerfTimer } from "content-src/components/ComponentPerfTimer/ComponentPerfTimer";
import { FluentOrText } from "content-src/components/FluentOrText/FluentOrText";
import { connect } from "react-redux";
import { MoreRecommendations } from "content-src/components/MoreRecommendations/MoreRecommendations";
import { PocketLoggedInCta } from "content-src/components/PocketLoggedInCta/PocketLoggedInCta";
import React from "react";
import { Topics } from "content-src/components/Topics/Topics";
import { TopSites } from "content-src/components/TopSites/TopSites";
const VISIBLE = "visible";
const VISIBILITY_CHANGE_EVENT = "visibilitychange";
const CARDS_PER_ROW_DEFAULT = 3;
const CARDS_PER_ROW_COMPACT_WIDE = 4;
export class Section extends React.PureComponent {
get numRows() {
const { rowsPref, maxRows, Prefs } = this.props;
return rowsPref ? Prefs.values[rowsPref] : maxRows;
}
_dispatchImpressionStats() {
const { props } = this;
let cardsPerRow = CARDS_PER_ROW_DEFAULT;
if (
props.compactCards &&
global.matchMedia(`(min-width: 1072px)`).matches
) {
// If the section has compact cards and the viewport is wide enough, we show
// 4 columns instead of 3.
// $break-point-widest = 1072px (from _variables.scss)
cardsPerRow = CARDS_PER_ROW_COMPACT_WIDE;
}
const maxCards = cardsPerRow * this.numRows;
const cards = props.rows.slice(0, maxCards);
if (this.needsImpressionStats(cards)) {
props.dispatch(
ac.ImpressionStats({
source: props.eventSource,
tiles: cards.map(link => ({ id: link.guid })),
})
);
this.impressionCardGuids = cards.map(link => link.guid);
}
}
// 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.
sendImpressionStatsOrAddListener() {
const { props } = this;
if (!props.shouldSendImpressionStats || !props.dispatch) {
return;
}
if (props.document.visibilityState === VISIBLE) {
this._dispatchImpressionStats();
} else {
// We should only ever send the latest impression stats ping, so remove any
// older listeners.
if (this._onVisibilityChange) {
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 (props.document.visibilityState === VISIBLE) {
if (!this.props.pref.collapsed) {
this._dispatchImpressionStats();
}
props.document.removeEventListener(
VISIBILITY_CHANGE_EVENT,
this._onVisibilityChange
);
}
};
props.document.addEventListener(
VISIBILITY_CHANGE_EVENT,
this._onVisibilityChange
);
}
}
componentWillMount() {
this.sendNewTabRehydrated(this.props.initialized);
}
componentDidMount() {
if (this.props.rows.length && !this.props.pref.collapsed) {
this.sendImpressionStatsOrAddListener();
}
}
componentDidUpdate(prevProps) {
const { props } = this;
const isCollapsed = props.pref.collapsed;
const wasCollapsed = prevProps.pref.collapsed;
if (
// Don't send impression stats for the empty state
props.rows.length &&
// We only want to send impression stats if the content of the cards has changed
// and the section is not collapsed...
((props.rows !== prevProps.rows && !isCollapsed) ||
// or if we are expanding a section that was collapsed.
(wasCollapsed && !isCollapsed))
) {
this.sendImpressionStatsOrAddListener();
}
}
componentWillUpdate(nextProps) {
this.sendNewTabRehydrated(nextProps.initialized);
}
componentWillUnmount() {
if (this._onVisibilityChange) {
this.props.document.removeEventListener(
VISIBILITY_CHANGE_EVENT,
this._onVisibilityChange
);
}
}
needsImpressionStats(cards) {
if (
!this.impressionCardGuids ||
this.impressionCardGuids.length !== cards.length
) {
return true;
}
for (let i = 0; i < cards.length; i++) {
if (cards[i].guid !== this.impressionCardGuids[i]) {
return true;
}
}
return false;
}
// The NEW_TAB_REHYDRATED event is used to inform feeds that their
// data has been consumed e.g. for counting the number of tabs that
// have rendered that data.
sendNewTabRehydrated(initialized) {
if (initialized && !this.renderNotified) {
this.props.dispatch(
ac.AlsoToMain({ type: at.NEW_TAB_REHYDRATED, data: {} })
);
this.renderNotified = true;
}
}
render() {
const {
id,
eventSource,
title,
icon,
rows,
Pocket,
topics,
emptyState,
dispatch,
compactCards,
read_more_endpoint,
contextMenuOptions,
initialized,
learnMore,
pref,
privacyNoticeURL,
isFirst,
isLast,
} = this.props;
const waitingForSpoc =
id === "topstories" && this.props.Pocket.waitingForSpoc;
const maxCardsPerRow = compactCards
? CARDS_PER_ROW_COMPACT_WIDE
: CARDS_PER_ROW_DEFAULT;
const { numRows } = this;
const maxCards = maxCardsPerRow * numRows;
const maxCardsOnNarrow = CARDS_PER_ROW_DEFAULT * numRows;
const { pocketCta, isUserLoggedIn } = Pocket || {};
const { useCta } = pocketCta || {};
// Don't display anything until we have a definitve result from Pocket,
// to avoid a flash of logged out state while we render.
const isPocketLoggedInDefined =
isUserLoggedIn === true || isUserLoggedIn === false;
const hasTopics = topics && !!topics.length;
const shouldShowPocketCta =
id === "topstories" && useCta && isUserLoggedIn === false;
// Show topics only for top stories and if it has loaded with topics.
// The classs .top-stories-bottom-container ensures content doesn't shift as things load.
const shouldShowTopics =
id === "topstories" &&
hasTopics &&
((useCta && isUserLoggedIn === true) ||
(!useCta && isPocketLoggedInDefined));
// We use topics to determine language support for read more.
const shouldShowReadMore = read_more_endpoint && hasTopics;
const realRows = rows.slice(0, maxCards);
// The empty state should only be shown after we have initialized and there is no content.
// Otherwise, we should show placeholders.
const shouldShowEmptyState = initialized && !rows.length;
const cards = [];
if (!shouldShowEmptyState) {
for (let i = 0; i < maxCards; i++) {
const link = realRows[i];
// On narrow viewports, we only show 3 cards per row. We'll mark the rest as
// .hide-for-narrow to hide in CSS via @media query.
const className = i >= maxCardsOnNarrow ? "hide-for-narrow" : "";
let usePlaceholder = !link;
// If we are in the third card and waiting for spoc,
// use the placeholder.
if (!usePlaceholder && i === 2 && waitingForSpoc) {
usePlaceholder = true;
}
cards.push(
!usePlaceholder ? (
) : (
)
);
}
}
const sectionClassName = [
"section",
compactCards ? "compact-cards" : "normal-cards",
].join(" ");
// <-- React component
// <-- HTML5 element
return (
{!shouldShowEmptyState && (
)}
{shouldShowEmptyState && (
{emptyState.icon &&
emptyState.icon.startsWith("moz-extension://") ? (
) : (
)}
)}
{id === "topstories" && (
{shouldShowTopics && (
)}
{shouldShowPocketCta && (
)}
{shouldShowReadMore && (
)}
)}
);
}
}
Section.defaultProps = {
document: global.document,
rows: [],
emptyState: {},
pref: {},
title: "",
};
export const SectionIntl = connect(state => ({
Prefs: state.Prefs,
Pocket: state.Pocket,
}))(Section);
export class _Sections extends React.PureComponent {
renderSections() {
const sections = [];
const enabledSections = this.props.Sections.filter(
section => section.enabled
);
const {
sectionOrder,
"feeds.topsites": showTopSites,
} = this.props.Prefs.values;
// Enabled sections doesn't include Top Sites, so we add it if enabled.
const expectedCount = enabledSections.length + ~~showTopSites;
for (const sectionId of sectionOrder.split(",")) {
const commonProps = {
key: sectionId,
isFirst: sections.length === 0,
isLast: sections.length === expectedCount - 1,
};
if (sectionId === "topsites" && showTopSites) {
sections.push( );
} else {
const section = enabledSections.find(s => s.id === sectionId);
if (section) {
sections.push( );
}
}
}
return sections;
}
render() {
return {this.renderSections()}
;
}
}
export const Sections = connect(state => ({
Sections: state.Sections,
Prefs: state.Prefs,
}))(_Sections);
================================================
FILE: content-src/components/Sections/_Sections.scss
================================================
.sections-list {
.section-list {
display: grid;
grid-gap: $base-gutter;
grid-template-columns: repeat(auto-fit, $card-width);
margin: 0;
@media (max-width: $break-point-medium) {
@include context-menu-open-left;
}
@media (min-width: $break-point-medium) and (max-width: $break-point-large) {
:nth-child(2n) {
@include context-menu-open-left;
}
}
@media (min-width: $break-point-large) and (max-width: $break-point-large + 2 * $card-width) {
:nth-child(3n) {
@include context-menu-open-left;
}
}
@media (min-width: $break-point-widest) and (max-width: $break-point-widest + 2 * $card-width) {
// 3n for normal cards, 4n for compact cards
:nth-child(3n),
:nth-child(4n) {
@include context-menu-open-left;
}
}
}
.section-empty-state {
border: $border-secondary;
border-radius: $border-radius;
display: flex;
height: $card-height;
width: 100%;
.empty-state {
margin: auto;
max-width: 350px;
.empty-state-icon {
background-position: center;
background-repeat: no-repeat;
background-size: 50px 50px;
-moz-context-properties: fill;
display: block;
fill: var(--newtab-icon-secondary-color);
height: 50px;
margin: 0 auto;
width: 50px;
}
.empty-state-message {
color: var(--newtab-text-primary-color);
font-size: 13px;
margin-bottom: 0;
text-align: center;
}
}
@media (min-width: $break-point-widest) {
height: $card-height-large;
}
}
}
.top-stories-bottom-container {
color: var(--newtab-section-navigation-text-color);
font-size: 12px;
line-height: 1.6;
margin-top: $topic-margin-top;
display: flex;
justify-content: space-between;
a {
color: var(--newtab-link-secondary-color);
font-weight: bold;
&.more-recommendations {
font-weight: normal;
font-size: 13px;
}
}
.wrapper-topics,
.wrapper-cta + .wrapper-more-recommendations {
@media (max-width: $break-point-large - 1) {
display: none;
}
}
@media (max-width: $break-point-medium - 1) {
.wrapper-cta {
text-align: center;
.pocket-logged-in-cta {
display: block;
margin-inline-end: 0;
.pocket-cta-button {
max-width: none;
display: block;
margin-inline-end: 0;
margin: 5px 0 10px;
}
}
}
.wrapper-more-recommendations {
width: 100%;
.more-recommendations {
justify-content: center;
&::after {
display: none;
}
}
}
}
}
@media (min-width: $break-point-widest) {
.sections-list {
// Compact cards stay the same size but normal cards get bigger.
.normal-cards {
.section-list {
grid-template-columns: repeat(auto-fit, $card-width-large);
}
}
}
}
================================================
FILE: content-src/components/TopSites/SearchShortcutsForm.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 React from "react";
import { TOP_SITES_SOURCE } from "./TopSitesConstants";
export class SelectableSearchShortcut extends React.PureComponent {
render() {
const { shortcut, selected } = this.props;
const imageStyle = { backgroundImage: `url("${shortcut.tippyTopIcon}")` };
return (
);
}
}
export class SearchShortcutsForm extends React.PureComponent {
constructor(props) {
super(props);
this.handleChange = this.handleChange.bind(this);
this.onCancelButtonClick = this.onCancelButtonClick.bind(this);
this.onSaveButtonClick = this.onSaveButtonClick.bind(this);
// clone the shortcuts and add them to the state so we can add isSelected property
const shortcuts = [];
const { rows, searchShortcuts } = props.TopSites;
searchShortcuts.forEach(shortcut => {
shortcuts.push({
...shortcut,
isSelected: !!rows.find(
row =>
row &&
row.isPinned &&
row.searchTopSite &&
row.label === shortcut.keyword
),
});
});
this.state = { shortcuts };
}
handleChange(event) {
const { target } = event;
const { name, checked } = target;
this.setState(prevState => {
const shortcuts = prevState.shortcuts.slice();
let shortcut = shortcuts.find(({ keyword }) => keyword === name);
shortcut.isSelected = checked;
return { shortcuts };
});
}
onCancelButtonClick(ev) {
ev.preventDefault();
this.props.onClose();
}
onSaveButtonClick(ev) {
ev.preventDefault();
// Check if there were any changes and act accordingly
const { rows } = this.props.TopSites;
const pinQueue = [];
const unpinQueue = [];
this.state.shortcuts.forEach(shortcut => {
const alreadyPinned = rows.find(
row =>
row &&
row.isPinned &&
row.searchTopSite &&
row.label === shortcut.keyword
);
if (shortcut.isSelected && !alreadyPinned) {
pinQueue.push(this._searchTopSite(shortcut));
} else if (!shortcut.isSelected && alreadyPinned) {
unpinQueue.push({
url: alreadyPinned.url,
searchVendor: shortcut.shortURL,
});
}
});
// Tell the feed to do the work.
this.props.dispatch(
ac.OnlyToMain({
type: at.UPDATE_PINNED_SEARCH_SHORTCUTS,
data: {
addedShortcuts: pinQueue,
deletedShortcuts: unpinQueue,
},
})
);
// Send the Telemetry pings.
pinQueue.forEach(shortcut => {
this.props.dispatch(
ac.UserEvent({
source: TOP_SITES_SOURCE,
event: "SEARCH_EDIT_ADD",
value: { search_vendor: shortcut.searchVendor },
})
);
});
unpinQueue.forEach(shortcut => {
this.props.dispatch(
ac.UserEvent({
source: TOP_SITES_SOURCE,
event: "SEARCH_EDIT_DELETE",
value: { search_vendor: shortcut.searchVendor },
})
);
});
this.props.onClose();
}
_searchTopSite(shortcut) {
return {
url: shortcut.url,
searchTopSite: true,
label: shortcut.keyword,
searchVendor: shortcut.shortURL,
};
}
render() {
return (
);
}
}
================================================
FILE: content-src/components/TopSites/TopSite.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 {
MIN_CORNER_FAVICON_SIZE,
MIN_RICH_FAVICON_SIZE,
TOP_SITES_CONTEXT_MENU_OPTIONS,
TOP_SITES_SPOC_CONTEXT_MENU_OPTIONS,
TOP_SITES_SEARCH_SHORTCUTS_CONTEXT_MENU_OPTIONS,
TOP_SITES_SOURCE,
} from "./TopSitesConstants";
import { LinkMenu } from "content-src/components/LinkMenu/LinkMenu";
import { ImpressionStats } from "../DiscoveryStreamImpressionStats/ImpressionStats";
import React from "react";
import { ScreenshotUtils } from "content-src/lib/screenshot-utils";
import { TOP_SITES_MAX_SITES_PER_ROW } from "common/Reducers.jsm";
import { ContextMenuButton } from "content-src/components/ContextMenu/ContextMenuButton";
const SPOC_TYPE = "SPOC";
export class TopSiteLink extends React.PureComponent {
constructor(props) {
super(props);
this.state = { screenshotImage: null };
this.onDragEvent = this.onDragEvent.bind(this);
this.onKeyPress = this.onKeyPress.bind(this);
}
/*
* Helper to determine whether the drop zone should allow a drop. We only allow
* dropping top sites for now.
*/
_allowDrop(e) {
return e.dataTransfer.types.includes("text/topsite-index");
}
onDragEvent(event) {
switch (event.type) {
case "click":
// Stop any link clicks if we started any dragging
if (this.dragged) {
event.preventDefault();
}
break;
case "dragstart":
this.dragged = true;
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/topsite-index", this.props.index);
event.target.blur();
this.props.onDragEvent(
event,
this.props.index,
this.props.link,
this.props.title
);
break;
case "dragend":
this.props.onDragEvent(event);
break;
case "dragenter":
case "dragover":
case "drop":
if (this._allowDrop(event)) {
event.preventDefault();
this.props.onDragEvent(event, this.props.index);
}
break;
case "mousedown":
// Block the scroll wheel from appearing for middle clicks on search top sites
if (event.button === 1 && this.props.link.searchTopSite) {
event.preventDefault();
}
// Reset at the first mouse event of a potential drag
this.dragged = false;
break;
}
}
/**
* Helper to obtain the next state based on nextProps and prevState.
*
* NOTE: Rename this method to getDerivedStateFromProps when we update React
* to >= 16.3. We will need to update tests as well. We cannot rename this
* method to getDerivedStateFromProps now because there is a mismatch in
* the React version that we are using for both testing and production.
* (i.e. react-test-render => "16.3.2", react => "16.2.0").
*
* See https://github.com/airbnb/enzyme/blob/master/packages/enzyme-adapter-react-16/package.json#L43.
*/
static getNextStateFromProps(nextProps, prevState) {
const { screenshot } = nextProps.link;
const imageInState = ScreenshotUtils.isRemoteImageLocal(
prevState.screenshotImage,
screenshot
);
if (imageInState) {
return null;
}
// Since image was updated, attempt to revoke old image blob URL, if it exists.
ScreenshotUtils.maybeRevokeBlobObjectURL(prevState.screenshotImage);
return {
screenshotImage: ScreenshotUtils.createLocalImageObject(screenshot),
};
}
// NOTE: Remove this function when we update React to >= 16.3 since React will
// call getDerivedStateFromProps automatically. We will also need to
// rename getNextStateFromProps to getDerivedStateFromProps.
componentWillMount() {
const nextState = TopSiteLink.getNextStateFromProps(this.props, this.state);
if (nextState) {
this.setState(nextState);
}
}
// NOTE: Remove this function when we update React to >= 16.3 since React will
// call getDerivedStateFromProps automatically. We will also need to
// rename getNextStateFromProps to getDerivedStateFromProps.
componentWillReceiveProps(nextProps) {
const nextState = TopSiteLink.getNextStateFromProps(nextProps, this.state);
if (nextState) {
this.setState(nextState);
}
}
componentWillUnmount() {
ScreenshotUtils.maybeRevokeBlobObjectURL(this.state.screenshotImage);
}
onKeyPress(event) {
// If we have tabbed to a search shortcut top site, and we click 'enter',
// we should execute the onClick function. This needs to be added because
// search top sites are anchor tags without an href. See bug 1483135
if (this.props.link.searchTopSite && event.key === "Enter") {
this.props.onClick(event);
}
}
render() {
const {
children,
className,
defaultStyle,
isDraggable,
link,
onClick,
title,
} = this.props;
const topSiteOuterClassName = `top-site-outer${
className ? ` ${className}` : ""
}${link.isDragged ? " dragged" : ""}${
link.searchTopSite ? " search-shortcut" : ""
}`;
const { tippyTopIcon, faviconSize } = link;
const [letterFallback] = title;
let imageClassName;
let imageStyle;
let showSmallFavicon = false;
let smallFaviconStyle;
let smallFaviconFallback;
let hasScreenshotImage =
this.state.screenshotImage && this.state.screenshotImage.url;
if (defaultStyle) {
// force no styles (letter fallback) even if the link has imagery
smallFaviconFallback = false;
} else if (link.searchTopSite) {
imageClassName = "top-site-icon rich-icon";
imageStyle = {
backgroundColor: link.backgroundColor,
backgroundImage: `url(${tippyTopIcon})`,
};
smallFaviconStyle = { backgroundImage: `url(${tippyTopIcon})` };
} else if (link.customScreenshotURL) {
// assume high quality custom screenshot and use rich icon styles and class names
// TopSite spoc experiment only
const spocImgURL =
link.type === SPOC_TYPE ? link.customScreenshotURL : "";
imageClassName = "top-site-icon rich-icon";
imageStyle = {
backgroundColor: link.backgroundColor,
backgroundImage: hasScreenshotImage
? `url(${this.state.screenshotImage.url})`
: `url(${spocImgURL})`,
};
} else if (tippyTopIcon || faviconSize >= MIN_RICH_FAVICON_SIZE) {
// styles and class names for top sites with rich icons
imageClassName = "top-site-icon rich-icon";
imageStyle = {
backgroundColor: link.backgroundColor,
backgroundImage: `url(${tippyTopIcon || link.favicon})`,
};
} else {
// styles and class names for top sites with screenshot + small icon in top left corner
imageClassName = `screenshot${hasScreenshotImage ? " active" : ""}`;
imageStyle = {
backgroundImage: hasScreenshotImage
? `url(${this.state.screenshotImage.url})`
: "none",
};
// only show a favicon in top left if it's greater than 16x16
if (faviconSize >= MIN_CORNER_FAVICON_SIZE) {
showSmallFavicon = true;
smallFaviconStyle = { backgroundImage: `url(${link.favicon})` };
} else if (hasScreenshotImage) {
// Don't show a small favicon if there is no screenshot, because that
// would result in two fallback icons
showSmallFavicon = true;
smallFaviconFallback = true;
}
}
let draggableProps = {};
if (isDraggable) {
draggableProps = {
onClick: this.onDragEvent,
onDragEnd: this.onDragEvent,
onDragStart: this.onDragEvent,
onMouseDown: this.onDragEvent,
};
}
return (
);
}
}
TopSiteLink.defaultProps = {
title: "",
link: {},
isDraggable: true,
};
export class TopSite extends React.PureComponent {
constructor(props) {
super(props);
this.state = { showContextMenu: false };
this.onLinkClick = this.onLinkClick.bind(this);
this.onMenuUpdate = this.onMenuUpdate.bind(this);
}
/**
* Report to telemetry additional information about the item.
*/
_getTelemetryInfo() {
const value = { icon_type: this.props.link.iconType };
// Filter out "not_pinned" type for being the default
if (this.props.link.isPinned) {
value.card_type = "pinned";
}
if (this.props.link.searchTopSite) {
// Set the card_type as "search" regardless of its pinning status
value.card_type = "search";
value.search_vendor = this.props.link.hostname;
}
if (this.props.link.type === SPOC_TYPE) {
value.card_type = "spoc";
}
return { value };
}
userEvent(event) {
this.props.dispatch(
ac.UserEvent(
Object.assign(
{
event,
source: TOP_SITES_SOURCE,
action_position: this.props.index,
},
this._getTelemetryInfo()
)
)
);
}
onLinkClick(event) {
this.userEvent("CLICK");
// Specially handle a top site link click for "typed" frecency bonus as
// specified as a property on the link.
event.preventDefault();
const { altKey, button, ctrlKey, metaKey, shiftKey } = event;
if (!this.props.link.searchTopSite) {
this.props.dispatch(
ac.OnlyToMain({
type: at.OPEN_LINK,
data: Object.assign(this.props.link, {
event: { altKey, button, ctrlKey, metaKey, shiftKey },
}),
})
);
// Fire off a spoc specific impression.
if (this.props.link.type === SPOC_TYPE) {
this.props.dispatch(
ac.ImpressionStats({
source: TOP_SITES_SOURCE,
click: 0,
tiles: [
{
id: this.props.link.id,
pos: this.props.link.pos,
shim: this.props.link.shim && this.props.link.shim.click,
},
],
})
);
}
} else {
this.props.dispatch(
ac.OnlyToMain({
type: at.FILL_SEARCH_TERM,
data: { label: this.props.link.label },
})
);
}
}
onMenuUpdate(isOpen) {
if (isOpen) {
this.props.onActivate(this.props.index);
} else {
this.props.onActivate();
}
}
render() {
const { props } = this;
const { link } = props;
const isContextMenuOpen = props.activeIndex === props.index;
const title = link.label || link.hostname;
const menuOptions =
link.type !== SPOC_TYPE
? TOP_SITES_CONTEXT_MENU_OPTIONS
: TOP_SITES_SPOC_CONTEXT_MENU_OPTIONS;
return (
);
}
}
TopSite.defaultProps = {
link: {},
onActivate() {},
};
export class TopSitePlaceholder extends React.PureComponent {
constructor(props) {
super(props);
this.onEditButtonClick = this.onEditButtonClick.bind(this);
}
onEditButtonClick() {
this.props.dispatch({
type: at.TOP_SITES_EDIT,
data: { index: this.props.index },
});
}
render() {
return (
);
}
}
export class TopSiteList extends React.PureComponent {
static get DEFAULT_STATE() {
return {
activeIndex: null,
draggedIndex: null,
draggedSite: null,
draggedTitle: null,
topSitesPreview: null,
};
}
constructor(props) {
super(props);
this.state = TopSiteList.DEFAULT_STATE;
this.onDragEvent = this.onDragEvent.bind(this);
this.onActivate = this.onActivate.bind(this);
}
componentWillReceiveProps(nextProps) {
if (this.state.draggedSite) {
const prevTopSites = this.props.TopSites && this.props.TopSites.rows;
const newTopSites = nextProps.TopSites && nextProps.TopSites.rows;
if (
prevTopSites &&
prevTopSites[this.state.draggedIndex] &&
prevTopSites[this.state.draggedIndex].url ===
this.state.draggedSite.url &&
(!newTopSites[this.state.draggedIndex] ||
newTopSites[this.state.draggedIndex].url !==
this.state.draggedSite.url)
) {
// We got the new order from the redux store via props. We can clear state now.
this.setState(TopSiteList.DEFAULT_STATE);
}
}
}
userEvent(event, index) {
this.props.dispatch(
ac.UserEvent({
event,
source: TOP_SITES_SOURCE,
action_position: index,
})
);
}
onDragEvent(event, index, link, title) {
switch (event.type) {
case "dragstart":
this.dropped = false;
this.setState({
draggedIndex: index,
draggedSite: link,
draggedTitle: title,
activeIndex: null,
});
this.userEvent("DRAG", index);
break;
case "dragend":
if (!this.dropped) {
// If there was no drop event, reset the state to the default.
this.setState(TopSiteList.DEFAULT_STATE);
}
break;
case "dragenter":
if (index === this.state.draggedIndex) {
this.setState({ topSitesPreview: null });
} else {
this.setState({ topSitesPreview: this._makeTopSitesPreview(index) });
}
break;
case "drop":
if (index !== this.state.draggedIndex) {
this.dropped = true;
this.props.dispatch(
ac.AlsoToMain({
type: at.TOP_SITES_INSERT,
data: {
site: {
url: this.state.draggedSite.url,
label: this.state.draggedTitle,
customScreenshotURL: this.state.draggedSite
.customScreenshotURL,
// Only if the search topsites experiment is enabled
...(this.state.draggedSite.searchTopSite && {
searchTopSite: true,
}),
},
index,
draggedFromIndex: this.state.draggedIndex,
},
})
);
this.userEvent("DROP", index);
}
break;
}
}
_getTopSites() {
// Make a copy of the sites to truncate or extend to desired length
let topSites = this.props.TopSites.rows.slice();
topSites.length = this.props.TopSitesRows * TOP_SITES_MAX_SITES_PER_ROW;
return topSites;
}
/**
* Make a preview of the topsites that will be the result of dropping the currently
* dragged site at the specified index.
*/
_makeTopSitesPreview(index) {
const topSites = this._getTopSites();
topSites[this.state.draggedIndex] = null;
const pinnedOnly = topSites.map(site =>
site && site.isPinned ? site : null
);
const unpinned = topSites.filter(site => site && !site.isPinned);
const siteToInsert = Object.assign({}, this.state.draggedSite, {
isPinned: true,
isDragged: true,
});
if (!pinnedOnly[index]) {
pinnedOnly[index] = siteToInsert;
} else {
// Find the hole to shift the pinned site(s) towards. We shift towards the
// hole left by the site being dragged.
let holeIndex = index;
const indexStep = index > this.state.draggedIndex ? -1 : 1;
while (pinnedOnly[holeIndex]) {
holeIndex += indexStep;
}
// Shift towards the hole.
const shiftingStep = index > this.state.draggedIndex ? 1 : -1;
while (holeIndex !== index) {
const nextIndex = holeIndex + shiftingStep;
pinnedOnly[holeIndex] = pinnedOnly[nextIndex];
holeIndex = nextIndex;
}
pinnedOnly[index] = siteToInsert;
}
// Fill in the remaining holes with unpinned sites.
const preview = pinnedOnly;
for (let i = 0; i < preview.length; i++) {
if (!preview[i]) {
preview[i] = unpinned.shift() || null;
}
}
return preview;
}
onActivate(index) {
this.setState({ activeIndex: index });
}
render() {
const { props } = this;
const topSites = this.state.topSitesPreview || this._getTopSites();
const topSitesUI = [];
const commonProps = {
onDragEvent: this.onDragEvent,
dispatch: props.dispatch,
};
// We assign a key to each placeholder slot. We need it to be independent
// of the slot index (i below) so that the keys used stay the same during
// drag and drop reordering and the underlying DOM nodes are reused.
// This mostly (only?) affects linux so be sure to test on linux before changing.
let holeIndex = 0;
// On narrow viewports, we only show 6 sites per row. We'll mark the rest as
// .hide-for-narrow to hide in CSS via @media query.
const maxNarrowVisibleIndex = props.TopSitesRows * 6;
for (let i = 0, l = topSites.length; i < l; i++) {
const link =
topSites[i] &&
Object.assign({}, topSites[i], {
iconType: this.props.topSiteIconType(topSites[i]),
});
const slotProps = {
key: link ? link.url : holeIndex++,
index: i,
};
if (i >= maxNarrowVisibleIndex) {
slotProps.className = "hide-for-narrow";
}
topSitesUI.push(
!link ? (
) : (
)
);
}
return (
);
}
}
================================================
FILE: content-src/components/TopSites/TopSiteForm.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 { A11yLinkButton } from "content-src/components/A11yLinkButton/A11yLinkButton";
import React from "react";
import { TOP_SITES_SOURCE } from "./TopSitesConstants";
import { TopSiteFormInput } from "./TopSiteFormInput";
import { TopSiteLink } from "./TopSite";
export class TopSiteForm extends React.PureComponent {
constructor(props) {
super(props);
const { site } = props;
this.state = {
label: site ? site.label || site.hostname : "",
url: site ? site.url : "",
validationError: false,
customScreenshotUrl: site ? site.customScreenshotURL : "",
showCustomScreenshotForm: site ? site.customScreenshotURL : false,
};
this.onClearScreenshotInput = this.onClearScreenshotInput.bind(this);
this.onLabelChange = this.onLabelChange.bind(this);
this.onUrlChange = this.onUrlChange.bind(this);
this.onCancelButtonClick = this.onCancelButtonClick.bind(this);
this.onClearUrlClick = this.onClearUrlClick.bind(this);
this.onDoneButtonClick = this.onDoneButtonClick.bind(this);
this.onCustomScreenshotUrlChange = this.onCustomScreenshotUrlChange.bind(
this
);
this.onPreviewButtonClick = this.onPreviewButtonClick.bind(this);
this.onEnableScreenshotUrlForm = this.onEnableScreenshotUrlForm.bind(this);
this.validateUrl = this.validateUrl.bind(this);
}
onLabelChange(event) {
this.setState({ label: event.target.value });
}
onUrlChange(event) {
this.setState({
url: event.target.value,
validationError: false,
});
}
onClearUrlClick() {
this.setState({
url: "",
validationError: false,
});
}
onEnableScreenshotUrlForm() {
this.setState({ showCustomScreenshotForm: true });
}
_updateCustomScreenshotInput(customScreenshotUrl) {
this.setState({
customScreenshotUrl,
validationError: false,
});
this.props.dispatch({ type: at.PREVIEW_REQUEST_CANCEL });
}
onCustomScreenshotUrlChange(event) {
this._updateCustomScreenshotInput(event.target.value);
}
onClearScreenshotInput() {
this._updateCustomScreenshotInput("");
}
onCancelButtonClick(ev) {
ev.preventDefault();
this.props.onClose();
}
onDoneButtonClick(ev) {
ev.preventDefault();
if (this.validateForm()) {
const site = { url: this.cleanUrl(this.state.url) };
const { index } = this.props;
if (this.state.label !== "") {
site.label = this.state.label;
}
if (this.state.customScreenshotUrl) {
site.customScreenshotURL = this.cleanUrl(
this.state.customScreenshotUrl
);
} else if (this.props.site && this.props.site.customScreenshotURL) {
// Used to flag that previously cached screenshot should be removed
site.customScreenshotURL = null;
}
this.props.dispatch(
ac.AlsoToMain({
type: at.TOP_SITES_PIN,
data: { site, index },
})
);
this.props.dispatch(
ac.UserEvent({
source: TOP_SITES_SOURCE,
event: "TOP_SITES_EDIT",
action_position: index,
})
);
this.props.onClose();
}
}
onPreviewButtonClick(event) {
event.preventDefault();
if (this.validateForm()) {
this.props.dispatch(
ac.AlsoToMain({
type: at.PREVIEW_REQUEST,
data: { url: this.cleanUrl(this.state.customScreenshotUrl) },
})
);
this.props.dispatch(
ac.UserEvent({
source: TOP_SITES_SOURCE,
event: "PREVIEW_REQUEST",
})
);
}
}
cleanUrl(url) {
// If we are missing a protocol, prepend http://
if (!url.startsWith("http:") && !url.startsWith("https:")) {
return `http://${url}`;
}
return url;
}
_tryParseUrl(url) {
try {
return new URL(url);
} catch (e) {
return null;
}
}
validateUrl(url) {
const validProtocols = ["http:", "https:"];
const urlObj =
this._tryParseUrl(url) || this._tryParseUrl(this.cleanUrl(url));
return urlObj && validProtocols.includes(urlObj.protocol);
}
validateCustomScreenshotUrl() {
const { customScreenshotUrl } = this.state;
return !customScreenshotUrl || this.validateUrl(customScreenshotUrl);
}
validateForm() {
const validate =
this.validateUrl(this.state.url) && this.validateCustomScreenshotUrl();
if (!validate) {
this.setState({ validationError: true });
}
return validate;
}
_renderCustomScreenshotInput() {
const { customScreenshotUrl } = this.state;
const requestFailed = this.props.previewResponse === "";
const validationError =
(this.state.validationError && !this.validateCustomScreenshotUrl()) ||
requestFailed;
// Set focus on error if the url field is valid or when the input is first rendered and is empty
const shouldFocus =
(validationError && this.validateUrl(this.state.url)) ||
!customScreenshotUrl;
const isLoading =
this.props.previewResponse === null &&
customScreenshotUrl &&
this.props.previewUrl === this.cleanUrl(customScreenshotUrl);
if (!this.state.showCustomScreenshotForm) {
return (
);
}
return (
);
}
render() {
const { customScreenshotUrl } = this.state;
const requestFailed = this.props.previewResponse === "";
// For UI purposes, editing without an existing link is "add"
const showAsAdd = !this.props.site;
const previous =
(this.props.site && this.props.site.customScreenshotURL) || "";
const changed =
customScreenshotUrl && this.cleanUrl(customScreenshotUrl) !== previous;
// Preview mode if changes were made to the custom screenshot URL and no preview was received yet
// or the request failed
const previewMode = changed && !this.props.previewResponse;
const previewLink = Object.assign({}, this.props.site);
if (this.props.previewResponse) {
previewLink.screenshot = this.props.previewResponse;
previewLink.customScreenshotURL = this.props.previewUrl;
}
// Handles the form submit so an enter press performs the correct action
const onSubmit = previewMode
? this.onPreviewButtonClick
: this.onDoneButtonClick;
return (
);
}
}
TopSiteForm.defaultProps = {
site: null,
index: -1,
};
================================================
FILE: content-src/components/TopSites/TopSiteFormInput.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 TopSiteFormInput extends React.PureComponent {
constructor(props) {
super(props);
this.state = { validationError: this.props.validationError };
this.onChange = this.onChange.bind(this);
this.onMount = this.onMount.bind(this);
this.onClearIconPress = this.onClearIconPress.bind(this);
}
componentWillReceiveProps(nextProps) {
if (nextProps.shouldFocus && !this.props.shouldFocus) {
this.input.focus();
}
if (nextProps.validationError && !this.props.validationError) {
this.setState({ validationError: true });
}
// If the component is in an error state but the value was cleared by the parent
if (this.state.validationError && !nextProps.value) {
this.setState({ validationError: false });
}
}
onClearIconPress(event) {
// If there is input in the URL or custom image URL fields,
// and we hit 'enter' while tabbed over the clear icon,
// we should execute the function to clear the field.
if (event.key === "Enter") {
this.props.onClear();
}
}
onChange(ev) {
if (this.state.validationError) {
this.setState({ validationError: false });
}
this.props.onChange(ev);
}
onMount(input) {
this.input = input;
}
renderLoadingOrCloseButton() {
const showClearButton = this.props.value && this.props.onClear;
if (this.props.loading) {
return (
);
} else if (showClearButton) {
return (
);
}
return null;
}
render() {
const { typeUrl } = this.props;
const { validationError } = this.state;
return (
);
}
}
TopSiteFormInput.defaultProps = {
showClearButton: false,
value: "",
validationError: false,
};
================================================
FILE: content-src/components/TopSites/TopSites.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 {
MIN_CORNER_FAVICON_SIZE,
MIN_RICH_FAVICON_SIZE,
TOP_SITES_SOURCE,
} from "./TopSitesConstants";
import { CollapsibleSection } from "content-src/components/CollapsibleSection/CollapsibleSection";
import { ComponentPerfTimer } from "content-src/components/ComponentPerfTimer/ComponentPerfTimer";
import { connect } from "react-redux";
import { ModalOverlayWrapper } from "../../asrouter/components/ModalOverlay/ModalOverlay";
import React from "react";
import { SearchShortcutsForm } from "./SearchShortcutsForm";
import { TOP_SITES_MAX_SITES_PER_ROW } from "common/Reducers.jsm";
import { TopSiteForm } from "./TopSiteForm";
import { TopSiteList } from "./TopSite";
function topSiteIconType(link) {
if (link.customScreenshotURL) {
return "custom_screenshot";
}
if (link.tippyTopIcon || link.faviconRef === "tippytop") {
return "tippytop";
}
if (link.faviconSize >= MIN_RICH_FAVICON_SIZE) {
return "rich_icon";
}
if (link.screenshot && link.faviconSize >= MIN_CORNER_FAVICON_SIZE) {
return "screenshot_with_icon";
}
if (link.screenshot) {
return "screenshot";
}
return "no_image";
}
/**
* Iterates through TopSites and counts types of images.
* @param acc Accumulator for reducer.
* @param topsite Entry in TopSites.
*/
function countTopSitesIconsTypes(topSites) {
const countTopSitesTypes = (acc, link) => {
acc[topSiteIconType(link)]++;
return acc;
};
return topSites.reduce(countTopSitesTypes, {
custom_screenshot: 0,
screenshot_with_icon: 0,
screenshot: 0,
tippytop: 0,
rich_icon: 0,
no_image: 0,
});
}
export class _TopSites extends React.PureComponent {
constructor(props) {
super(props);
this.onEditFormClose = this.onEditFormClose.bind(this);
this.onSearchShortcutsFormClose = this.onSearchShortcutsFormClose.bind(
this
);
}
/**
* Dispatch session statistics about the quality of TopSites icons and pinned count.
*/
_dispatchTopSitesStats() {
const topSites = this._getVisibleTopSites().filter(
topSite => topSite !== null && topSite !== undefined
);
const topSitesIconsStats = countTopSitesIconsTypes(topSites);
const topSitesPinned = topSites.filter(site => !!site.isPinned).length;
const searchShortcuts = topSites.filter(site => !!site.searchTopSite)
.length;
// Dispatch telemetry event with the count of TopSites images types.
this.props.dispatch(
ac.AlsoToMain({
type: at.SAVE_SESSION_PERF_DATA,
data: {
topsites_icon_stats: topSitesIconsStats,
topsites_pinned: topSitesPinned,
topsites_search_shortcuts: searchShortcuts,
},
})
);
}
/**
* Return the TopSites that are visible based on prefs and window width.
*/
_getVisibleTopSites() {
// We hide 2 sites per row when not in the wide layout.
let sitesPerRow = TOP_SITES_MAX_SITES_PER_ROW;
// $break-point-widest = 1072px (from _variables.scss)
if (!global.matchMedia(`(min-width: 1072px)`).matches) {
sitesPerRow -= 2;
}
return this.props.TopSites.rows.slice(
0,
this.props.TopSitesRows * sitesPerRow
);
}
componentDidUpdate() {
this._dispatchTopSitesStats();
}
componentDidMount() {
this._dispatchTopSitesStats();
}
onEditFormClose() {
this.props.dispatch(
ac.UserEvent({
source: TOP_SITES_SOURCE,
event: "TOP_SITES_EDIT_CLOSE",
})
);
this.props.dispatch({ type: at.TOP_SITES_CANCEL_EDIT });
}
onSearchShortcutsFormClose() {
this.props.dispatch(
ac.UserEvent({
source: TOP_SITES_SOURCE,
event: "SEARCH_EDIT_CLOSE",
})
);
this.props.dispatch({ type: at.TOP_SITES_CLOSE_SEARCH_SHORTCUTS_MODAL });
}
render() {
const { props } = this;
const { editForm, showSearchShortcutsForm } = props.TopSites;
const extraMenuOptions = ["AddTopSite"];
if (props.Prefs.values["improvesearch.topSiteSearchShortcuts"]) {
extraMenuOptions.push("AddSearchShortcut");
}
return (
{editForm && (
)}
{showSearchShortcutsForm && (
)}
);
}
}
export const TopSites = connect((state, props) => ({
// For SPOC Experiment only, take TopSites from DiscoveryStream TopSites that takes in SPOC Data
TopSites: props.TopSitesWithSpoc || state.TopSites,
Prefs: state.Prefs,
TopSitesRows: state.Prefs.values.topSitesRows,
}))(_TopSites);
================================================
FILE: content-src/components/TopSites/TopSitesConstants.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/. */
export const TOP_SITES_SOURCE = "TOP_SITES";
export const TOP_SITES_CONTEXT_MENU_OPTIONS = [
"CheckPinTopSite",
"EditTopSite",
"Separator",
"OpenInNewWindow",
"OpenInPrivateWindow",
"Separator",
"BlockUrl",
"DeleteUrl",
];
export const TOP_SITES_SPOC_CONTEXT_MENU_OPTIONS = [
"PinSpocTopSite",
"Separator",
"OpenInNewWindow",
"OpenInPrivateWindow",
"Separator",
"BlockUrl",
"ShowPrivacyInfo",
];
// the special top site for search shortcut experiment can only have the option to unpin (which removes) the topsite
export const TOP_SITES_SEARCH_SHORTCUTS_CONTEXT_MENU_OPTIONS = [
"CheckPinTopSite",
"Separator",
"BlockUrl",
];
// minimum size necessary to show a rich icon instead of a screenshot
export const MIN_RICH_FAVICON_SIZE = 96;
// minimum size necessary to show any icon in the top left corner with a screenshot
export const MIN_CORNER_FAVICON_SIZE = 16;
================================================
FILE: content-src/components/TopSites/_TopSites.scss
================================================
$top-sites-size: $grid-unit;
$top-sites-border-radius: 4px;
$top-sites-title-height: 30px;
$top-sites-vertical-space: 8px;
$screenshot-size: cover;
$rich-icon-size: 96px;
$default-icon-wrapper-size: 42px;
$default-icon-size: 32px;
$default-icon-offset: 6px;
$half-base-gutter: $base-gutter / 2;
$hover-transition-duration: 150ms;
.top-sites {
// Take back the margin from the bottom row of vertical spacing as well as the
// extra whitespace below the title text as it's vertically centered.
margin-bottom: $section-spacing - ($top-sites-vertical-space + $top-sites-title-height / 3);
}
.top-sites-list {
list-style: none;
margin: 0 (-$half-base-gutter);
padding: 0;
// Two columns
@media (max-width: $break-point-medium) {
:nth-child(2n+1) {
@include context-menu-open-middle;
}
:nth-child(2n) {
@include context-menu-open-left;
}
}
// Four columns
@media (min-width: $break-point-medium) and (max-width: $break-point-large) {
:nth-child(4n) {
@include context-menu-open-left;
}
}
@media (min-width: $break-point-medium) and (max-width: $break-point-medium + $card-width) {
:nth-child(4n+3) {
@include context-menu-open-left;
}
}
// Six columns
@media (min-width: $break-point-large) and (max-width: $break-point-large + 2 * $card-width) {
:nth-child(6n) {
@include context-menu-open-left;
}
}
@media (min-width: $break-point-large) and (max-width: $break-point-large + $card-width) {
:nth-child(6n+5) {
@include context-menu-open-left;
}
}
// Eight columns
@media (min-width: $break-point-widest) and (max-width: $break-point-widest + 2 * $card-width) {
:nth-child(8n) {
@include context-menu-open-left;
}
}
@media (min-width: $break-point-widest) and (max-width: $break-point-widest + $card-width) {
:nth-child(8n+7) {
@include context-menu-open-left;
}
}
.hide-for-narrow {
display: none;
}
@media (min-width: $break-point-medium) {
.hide-for-narrow {
display: inline-block;
}
}
@media (min-width: $break-point-large) {
.hide-for-narrow {
display: none;
}
}
@media (min-width: $break-point-widest) {
.hide-for-narrow {
display: inline-block;
}
}
li {
margin: 0 0 $top-sites-vertical-space;
}
&:not(.dnd-active) {
.top-site-outer:-moz-any(.active, :focus, :hover) {
.tile {
@include fade-in;
}
@include context-menu-button-hover;
}
}
}
// container for drop zone
.top-site-outer {
padding: 0 $half-base-gutter;
display: inline-block;
// container for context menu
.top-site-inner {
position: relative;
> a {
color: inherit;
display: block;
outline: none;
&:-moz-any(.active, :focus) {
.tile {
@include fade-in;
}
}
}
}
@include context-menu-button;
.tile { // sass-lint:disable-block property-sort-order
border-radius: $top-sites-border-radius;
box-shadow: inset $inner-box-shadow, var(--newtab-card-shadow);
cursor: pointer;
height: $top-sites-size;
position: relative;
width: $top-sites-size;
// For letter fallback
align-items: center;
color: var(--newtab-text-secondary-color);
display: flex;
font-size: 32px;
font-weight: 200;
justify-content: center;
text-transform: uppercase; // sass-lint:disable-line no-disallowed-properties
transition: box-shadow $hover-transition-duration;
&::before {
content: attr(data-fallback);
}
}
.screenshot {
background-color: $white;
background-position: top left;
background-size: $screenshot-size;
border-radius: $top-sites-border-radius;
box-shadow: inset $inner-box-shadow;
height: 100%;
left: 0;
opacity: 0;
position: absolute;
top: 0;
transition: opacity 1s;
width: 100%;
&.active {
opacity: 1;
}
}
// Some common styles for all icons (rich and default) in top sites
.top-site-icon {
background-color: var(--newtab-topsites-background-color);
background-position: center center;
background-repeat: no-repeat;
border-radius: $top-sites-border-radius;
box-shadow: var(--newtab-topsites-icon-shadow);
position: absolute;
}
.rich-icon {
background-size: cover;
height: 100%;
inset-inline-start: 0;
top: 0;
width: 100%;
}
.default-icon,
.search-topsite {
background-size: $default-icon-size;
bottom: -$default-icon-offset;
height: $default-icon-wrapper-size;
inset-inline-end: -$default-icon-offset;
width: $default-icon-wrapper-size;
// for corner letter fallback
align-items: center;
display: flex;
font-size: 20px;
justify-content: center;
&[data-fallback]::before {
content: attr(data-fallback);
}
}
.search-topsite {
background-image: url('#{$image-path}glyph-search-16.svg');
background-size: 26px;
background-color: $blue-60;
border-radius: $default-icon-wrapper-size;
-moz-context-properties: fill;
fill: $white;
box-shadow: var(--newtab-card-shadow);
transition-duration: $hover-transition-duration;
transition-property: background-size, bottom, inset-inline-end, height, width;
}
&:hover .search-topsite {
$hover-icon-wrapper-size: $default-icon-wrapper-size + 4;
$hover-icon-offset: -$default-icon-offset - 3;
background-size: 28px;
border-radius: $hover-icon-wrapper-size;
bottom: $hover-icon-offset;
height: $hover-icon-wrapper-size;
inset-inline-end: $hover-icon-offset;
width: $hover-icon-wrapper-size;
}
// We want all search shortcuts to have a white background in case they have transparency.
&.search-shortcut {
.rich-icon {
background-color: $white;
}
}
.title {
color: var(--newtab-topsites-label-color);
font: message-box;
height: $top-sites-title-height;
line-height: $top-sites-title-height;
text-align: center;
width: $top-sites-size;
position: relative;
.icon {
fill: var(--newtab-icon-tertiary-color);
inset-inline-start: 0;
position: absolute;
top: 10px;
}
span {
height: $top-sites-title-height;
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&.pinned {
span {
padding: 0 13px;
}
}
}
.edit-button {
background-image: url('#{$image-path}glyph-edit-16.svg');
}
&.placeholder {
.tile {
box-shadow: inset $inner-box-shadow;
}
.screenshot {
display: none;
}
}
&.dragged {
.tile {
background: $grey-20;
box-shadow: none;
*,
&::before {
display: none;
}
}
.title {
visibility: hidden;
}
}
}
.edit-topsites-wrapper {
.modal {
box-shadow: $shadow-secondary;
left: 0;
margin: 0 auto;
max-height: calc(100% - 40px);
overflow-y: auto;
overflow-x: hidden;
position: fixed;
right: 0;
top: 40px;
width: $wrapper-default-width;
@media (min-width: $break-point-medium) {
width: $wrapper-max-width-medium;
}
@media (min-width: $break-point-large) {
width: $wrapper-max-width-large;
}
}
}
.topsite-form {
$form-width: 300px;
$form-spacing: 32px;
.section-title {
font-size: 16px;
margin: 0 0 16px;
}
.form-input-container {
max-width: $form-width + 3 * $form-spacing + $rich-icon-size;
margin: 0 auto;
padding: $form-spacing;
.top-site-outer {
pointer-events: none;
}
}
.search-shortcuts-container {
max-width: 700px;
margin: 0 auto;
padding: $form-spacing;
> div {
margin-inline-end: -39px;
}
.top-site-outer {
margin-inline-start: 0;
margin-inline-end: 39px;
}
}
.top-site-outer {
padding: 0;
margin: 24px 0 0;
margin-inline-start: $form-spacing;
}
.fields-and-preview {
display: flex;
}
label {
font-size: $section-title-font-size;
}
.form-wrapper {
width: 100%;
.field {
position: relative;
.icon-clear-input {
position: absolute;
transform: translateY(-50%);
top: 50%;
inset-inline-end: 8px;
}
}
.url {
input:dir(ltr) {
padding-right: 32px;
}
input:dir(rtl) {
padding-left: 32px;
&:not(:placeholder-shown) {
direction: ltr;
text-align: right;
}
}
}
.enable-custom-image-input {
display: inline-block;
font-size: 13px;
margin-top: 4px;
cursor: pointer;
}
.custom-image-input-container {
margin-top: 4px;
.loading-container {
width: 16px;
height: 16px;
overflow: hidden;
position: absolute;
transform: translateY(-50%);
top: 50%;
inset-inline-end: 8px;
}
// This animation is derived from Firefox's tab loading animation
// See https://searchfox.org/mozilla-central/rev/b29daa46443b30612415c35be0a3c9c13b9dc5f6/browser/themes/shared/tabs.inc.css#208-216
.loading-animation {
@keyframes tab-throbber-animation {
100% { transform: translateX(-960px); }
}
@keyframes tab-throbber-animation-rtl {
100% { transform: translateX(960px); }
}
width: 960px;
height: 16px;
-moz-context-properties: fill;
fill: $blue-50;
background-image: url('chrome://browser/skin/tabbrowser/loading.svg');
animation: tab-throbber-animation 1.05s steps(60) infinite;
&:dir(rtl) {
animation-name: tab-throbber-animation-rtl;
}
}
}
input {
&[type='text'] {
background-color: var(--newtab-textbox-background-color);
border: $input-border;
margin: 8px 0;
padding: 0 8px;
height: 32px;
width: 100%;
font-size: 15px;
&:focus {
border: $input-border-active;
box-shadow: var(--newtab-textbox-focus-boxshadow);
}
&[disabled] {
border: $input-border;
box-shadow: none;
opacity: 0.4;
}
}
}
.invalid {
input {
&[type='text'] {
border: $input-error-border;
box-shadow: $input-error-boxshadow;
}
}
}
.error-tooltip {
animation: fade-up-tt 450ms;
background: $red-60;
border-radius: 2px;
color: $white;
inset-inline-start: 3px;
padding: 5px 12px;
position: absolute;
top: 44px;
z-index: 1;
// tooltip caret
&::before {
background: $red-60;
bottom: -8px;
content: '.';
height: 16px;
inset-inline-start: 12px;
position: absolute;
text-indent: -999px;
top: -7px;
transform: rotate(45deg);
white-space: nowrap;
width: 16px;
z-index: -1;
}
}
}
.actions {
justify-content: flex-end;
button {
margin-inline-start: 10px;
margin-inline-end: 0;
}
}
@media (max-width: $break-point-medium) {
.fields-and-preview {
flex-direction: column;
.top-site-outer {
margin-inline-start: 0;
}
}
}
// prevent text selection of keyword label when clicking to select
.title {
-moz-user-select: none;
}
// CSS styled checkbox
[type='checkbox']:not(:checked),
[type='checkbox']:checked {
inset-inline-start: -9999px;
position: absolute;
}
[type='checkbox']:not(:checked) + label,
[type='checkbox']:checked + label {
cursor: pointer;
display: block;
position: relative;
}
$checkbox-offset: -8px;
[type='checkbox']:not(:checked) + label::before,
[type='checkbox']:checked + label::before {
background: var(--newtab-background-color);
border: $input-border;
border-radius: $border-radius;
content: '';
height: 21px;
left: $checkbox-offset;
position: absolute;
top: $checkbox-offset;
width: 21px;
z-index: 1;
[dir='rtl'] & {
left: auto;
right: $checkbox-offset;
}
}
// checkmark
[type='checkbox']:not(:checked) + label::after,
[type='checkbox']:checked + label::after {
background: url('chrome://global/skin/icons/check.svg') no-repeat center center; // sass-lint:disable-line no-url-domains
content: '';
height: 21px;
left: $checkbox-offset;
position: absolute;
top: $checkbox-offset;
width: 21px;
-moz-context-properties: fill;
fill: var(--newtab-link-primary-color);
z-index: 2;
[dir='rtl'] & {
left: auto;
right: $checkbox-offset;
}
}
// when selected, highlight the tile
[type='checkbox']:checked + label {
.tile {
box-shadow: 0 0 0 2px var(--newtab-link-primary-color);
}
}
// checkmark changes
[type='checkbox']:not(:checked) + label::after {
opacity: 0;
}
[type='checkbox']:checked + label::after {
opacity: 1;
}
// accessibility
[type='checkbox']:checked:focus + label::before,
[type='checkbox']:not(:checked):focus + label::before {
border: 1px dotted var(--newtab-link-primary-color);
}
}
//used for tooltips below form element
@keyframes fade-up-tt {
0% {
opacity: 0;
transform: translateY(15px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
================================================
FILE: content-src/components/Topics/Topics.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 Topic extends React.PureComponent {
render() {
const { url, name } = this.props;
return (
{name}
);
}
}
export class Topics extends React.PureComponent {
render() {
const { topics } = this.props;
return (
{topics &&
topics.map(t => )}
);
}
}
================================================
FILE: content-src/components/Topics/_Topics.scss
================================================
.topics {
ul {
margin: 0;
padding: 0;
@media (min-width: $break-point-large) {
display: inline;
padding-inline-start: 12px;
}
}
ul li {
display: inline-block;
&::after {
content: '•';
padding: 8px;
}
&:last-child::after {
content: none;
}
}
}
================================================
FILE: content-src/lib/constants.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/. */
export const IS_NEWTAB =
global.document && global.document.documentURI === "about:newtab";
export const NEWTAB_DARK_THEME = {
ntp_background: {
r: 42,
g: 42,
b: 46,
a: 1,
},
ntp_text: {
r: 249,
g: 249,
b: 250,
a: 1,
},
sidebar: {
r: 56,
g: 56,
b: 61,
a: 1,
},
sidebar_text: {
r: 249,
g: 249,
b: 250,
a: 1,
},
};
================================================
FILE: content-src/lib/detect-user-session-start.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/. */
import { actionCreators as ac, actionTypes as at } from "common/Actions.jsm";
import { perfService as perfSvc } from "common/PerfService.jsm";
const VISIBLE = "visible";
const VISIBILITY_CHANGE_EVENT = "visibilitychange";
export class DetectUserSessionStart {
constructor(store, options = {}) {
this._store = store;
// Overrides for testing
this.document = options.document || global.document;
this._perfService = options.perfService || perfSvc;
this._onVisibilityChange = this._onVisibilityChange.bind(this);
}
/**
* sendEventOrAddListener - Notify immediately if the page is already visible,
* or else set up a listener for when visibility changes.
* This is needed for accurate session tracking for telemetry,
* because tabs are pre-loaded.
*/
sendEventOrAddListener() {
if (this.document.visibilityState === VISIBLE) {
// If the document is already visible, to the user, send a notification
// immediately that a session has started.
this._sendEvent();
} else {
// If the document is not visible, listen for when it does become visible.
this.document.addEventListener(
VISIBILITY_CHANGE_EVENT,
this._onVisibilityChange
);
}
}
/**
* _sendEvent - Sends a message to the main process to indicate the current
* tab is now visible to the user, includes the
* visibility_event_rcvd_ts time in ms from the UNIX epoch.
*/
_sendEvent() {
this._perfService.mark("visibility_event_rcvd_ts");
try {
let visibility_event_rcvd_ts = this._perfService.getMostRecentAbsMarkStartByName(
"visibility_event_rcvd_ts"
);
this._store.dispatch(
ac.AlsoToMain({
type: at.SAVE_SESSION_PERF_DATA,
data: { visibility_event_rcvd_ts },
})
);
} catch (ex) {
// If this failed, it's likely because the `privacy.resistFingerprinting`
// pref is true. We should at least not blow up.
}
}
/**
* _onVisibilityChange - If the visibility has changed to visible, sends a notification
* and removes the event listener. This should only be called once per tab.
*/
_onVisibilityChange() {
if (this.document.visibilityState === VISIBLE) {
this._sendEvent();
this.document.removeEventListener(
VISIBILITY_CHANGE_EVENT,
this._onVisibilityChange
);
}
}
}
================================================
FILE: content-src/lib/init-store.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-env mozilla/frame-script */
import {
actionCreators as ac,
actionTypes as at,
actionUtils as au,
} from "common/Actions.jsm";
import { applyMiddleware, combineReducers, createStore } from "redux";
export const MERGE_STORE_ACTION = "NEW_TAB_INITIAL_STATE";
export const OUTGOING_MESSAGE_NAME = "ActivityStream:ContentToMain";
export const INCOMING_MESSAGE_NAME = "ActivityStream:MainToContent";
export const EARLY_QUEUED_ACTIONS = [at.SAVE_SESSION_PERF_DATA];
/**
* A higher-order function which returns a reducer that, on MERGE_STORE action,
* will return the action.data object merged into the previous state.
*
* For all other actions, it merely calls mainReducer.
*
* Because we want this to merge the entire state object, it's written as a
* higher order function which takes the main reducer (itself often a call to
* combineReducers) as a parameter.
*
* @param {function} mainReducer reducer to call if action != MERGE_STORE_ACTION
* @return {function} a reducer that, on MERGE_STORE_ACTION action,
* will return the action.data object merged
* into the previous state, and the result
* of calling mainReducer otherwise.
*/
function mergeStateReducer(mainReducer) {
return (prevState, action) => {
if (action.type === MERGE_STORE_ACTION) {
return { ...prevState, ...action.data };
}
return mainReducer(prevState, action);
};
}
/**
* messageMiddleware - Middleware that looks for SentToMain type actions, and sends them if necessary
*/
const messageMiddleware = store => next => action => {
const skipLocal = action.meta && action.meta.skipLocal;
if (au.isSendToMain(action)) {
RPMSendAsyncMessage(OUTGOING_MESSAGE_NAME, action);
}
if (!skipLocal) {
next(action);
}
};
export const rehydrationMiddleware = store => next => action => {
if (store._didRehydrate) {
return next(action);
}
const isMergeStoreAction = action.type === MERGE_STORE_ACTION;
const isRehydrationRequest = action.type === at.NEW_TAB_STATE_REQUEST;
if (isRehydrationRequest) {
store._didRequestInitialState = true;
return next(action);
}
if (isMergeStoreAction) {
store._didRehydrate = true;
return next(action);
}
// If init happened after our request was made, we need to re-request
if (store._didRequestInitialState && action.type === at.INIT) {
return next(ac.AlsoToMain({ type: at.NEW_TAB_STATE_REQUEST }));
}
if (
au.isBroadcastToContent(action) ||
au.isSendToOneContent(action) ||
au.isSendToPreloaded(action)
) {
// Note that actions received before didRehydrate will not be dispatched
// because this could negatively affect preloading and the the state
// will be replaced by rehydration anyway.
return null;
}
return next(action);
};
/**
* This middleware queues up all the EARLY_QUEUED_ACTIONS until it receives
* the first action from main. This is useful for those actions for main which
* require higher reliability, i.e. the action will not be lost in the case
* that it gets sent before the main is ready to receive it. Conversely, any
* actions allowed early are accepted to be ignorable or re-sendable.
*/
export const queueEarlyMessageMiddleware = store => next => action => {
if (store._receivedFromMain) {
next(action);
} else if (au.isFromMain(action)) {
next(action);
store._receivedFromMain = true;
// Sending out all the early actions as main is ready now
if (store._earlyActionQueue) {
store._earlyActionQueue.forEach(next);
store._earlyActionQueue = [];
}
} else if (EARLY_QUEUED_ACTIONS.includes(action.type)) {
store._earlyActionQueue = store._earlyActionQueue || [];
store._earlyActionQueue.push(action);
} else {
// Let any other type of action go through
next(action);
}
};
/**
* initStore - Create a store and listen for incoming actions
*
* @param {object} reducers An object containing Redux reducers
* @param {object} intialState (optional) The initial state of the store, if desired
* @return {object} A redux store
*/
export function initStore(reducers) {
const store = createStore(
mergeStateReducer(combineReducers(reducers)),
global.RPMAddMessageListener &&
applyMiddleware(
rehydrationMiddleware,
queueEarlyMessageMiddleware,
messageMiddleware
)
);
store._didRehydrate = false;
store._didRequestInitialState = false;
if (global.RPMAddMessageListener) {
global.RPMAddMessageListener(INCOMING_MESSAGE_NAME, msg => {
try {
store.dispatch(msg.data);
} catch (ex) {
console.error("Content msg:", msg, "Dispatch error: ", ex); // eslint-disable-line no-console
dump(
`Content msg: ${JSON.stringify(msg)}\nDispatch error: ${ex}\n${
ex.stack
}`
);
}
});
}
return store;
}
================================================
FILE: content-src/lib/link-menu-options.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/. */
import { actionCreators as ac, actionTypes as at } from "common/Actions.jsm";
const _OpenInPrivateWindow = site => ({
id: "newtab-menu-open-new-private-window",
icon: "new-window-private",
action: ac.OnlyToMain({
type: at.OPEN_PRIVATE_WINDOW,
data: { url: site.url, referrer: site.referrer },
}),
userEvent: "OPEN_PRIVATE_WINDOW",
});
/**
* List of functions that return items that can be included as menu options in a
* LinkMenu. All functions take the site as the first parameter, and optionally
* the index of the site.
*/
export const LinkMenuOptions = {
Separator: () => ({ type: "separator" }),
EmptyItem: () => ({ type: "empty" }),
ShowPrivacyInfo: site => ({
id: "newtab-menu-show-privacy-info",
icon: "info",
action: {
type: at.SHOW_PRIVACY_INFO,
},
userEvent: "SHOW_PRIVACY_INFO",
}),
RemoveBookmark: site => ({
id: "newtab-menu-remove-bookmark",
icon: "bookmark-added",
action: ac.AlsoToMain({
type: at.DELETE_BOOKMARK_BY_ID,
data: site.bookmarkGuid,
}),
userEvent: "BOOKMARK_DELETE",
}),
AddBookmark: site => ({
id: "newtab-menu-bookmark",
icon: "bookmark-hollow",
action: ac.AlsoToMain({
type: at.BOOKMARK_URL,
data: { url: site.url, title: site.title, type: site.type },
}),
userEvent: "BOOKMARK_ADD",
}),
OpenInNewWindow: site => ({
id: "newtab-menu-open-new-window",
icon: "new-window",
action: ac.AlsoToMain({
type: at.OPEN_NEW_WINDOW,
data: {
referrer: site.referrer,
typedBonus: site.typedBonus,
url: site.url,
},
}),
userEvent: "OPEN_NEW_WINDOW",
}),
// This blocks the url for regular stories,
// but also sends a message to DiscoveryStream with flight_id.
// If DiscoveryStream sees this message for a flight_id
// it also blocks it on the flight_id.
BlockUrl: (site, index, eventSource) => ({
id: "newtab-menu-dismiss",
icon: "dismiss",
action: ac.AlsoToMain({
type: at.BLOCK_URL,
data: {
url: site.open_url || site.url,
pocket_id: site.pocket_id,
...(site.flight_id ? { flight_id: site.flight_id } : {}),
},
}),
impression: ac.ImpressionStats({
source: eventSource,
block: 0,
tiles: [
{
id: site.guid,
pos: index,
...(site.shim && site.shim.delete ? { shim: site.shim.delete } : {}),
},
],
}),
userEvent: "BLOCK",
}),
// This is an option for web extentions which will result in remove items from
// memory and notify the web extenion, rather than using the built-in block list.
WebExtDismiss: (site, index, eventSource) => ({
id: "menu_action_webext_dismiss",
string_id: "newtab-menu-dismiss",
icon: "dismiss",
action: ac.WebExtEvent(at.WEBEXT_DISMISS, {
source: eventSource,
url: site.url,
action_position: index,
}),
}),
DeleteUrl: (site, index, eventSource, isEnabled, siteInfo) => ({
id: "newtab-menu-delete-history",
icon: "delete",
action: {
type: at.DIALOG_OPEN,
data: {
onConfirm: [
ac.AlsoToMain({
type: at.DELETE_HISTORY_URL,
data: {
url: site.url,
pocket_id: site.pocket_id,
forceBlock: site.bookmarkGuid,
},
}),
ac.UserEvent(
Object.assign(
{ event: "DELETE", source: eventSource, action_position: index },
siteInfo
)
),
],
eventSource,
body_string_id: [
"newtab-confirm-delete-history-p1",
"newtab-confirm-delete-history-p2",
],
confirm_button_string_id: "newtab-topsites-delete-history-button",
cancel_button_string_id: "newtab-topsites-cancel-button",
icon: "modal-delete",
},
},
userEvent: "DIALOG_OPEN",
}),
ShowFile: site => ({
id: "newtab-menu-show-file",
icon: "search",
action: ac.OnlyToMain({
type: at.SHOW_DOWNLOAD_FILE,
data: { url: site.url },
}),
}),
OpenFile: site => ({
id: "newtab-menu-open-file",
icon: "open-file",
action: ac.OnlyToMain({
type: at.OPEN_DOWNLOAD_FILE,
data: { url: site.url },
}),
}),
CopyDownloadLink: site => ({
id: "newtab-menu-copy-download-link",
icon: "copy",
action: ac.OnlyToMain({
type: at.COPY_DOWNLOAD_LINK,
data: { url: site.url },
}),
}),
GoToDownloadPage: site => ({
id: "newtab-menu-go-to-download-page",
icon: "download",
action: ac.OnlyToMain({
type: at.OPEN_LINK,
data: { url: site.referrer },
}),
disabled: !site.referrer,
}),
RemoveDownload: site => ({
id: "newtab-menu-remove-download",
icon: "delete",
action: ac.OnlyToMain({
type: at.REMOVE_DOWNLOAD_FILE,
data: { url: site.url },
}),
}),
PinSpocTopSite: (site, index) => ({
id: "newtab-menu-pin",
icon: "pin",
action: ac.AlsoToMain({
type: at.TOP_SITES_PIN,
data: {
site,
index,
},
}),
userEvent: "PIN",
}),
PinTopSite: ({ url, searchTopSite, label }, index) => ({
id: "newtab-menu-pin",
icon: "pin",
action: ac.AlsoToMain({
type: at.TOP_SITES_PIN,
data: {
site: {
url,
...(searchTopSite && { searchTopSite, label }),
},
index,
},
}),
userEvent: "PIN",
}),
UnpinTopSite: site => ({
id: "newtab-menu-unpin",
icon: "unpin",
action: ac.AlsoToMain({
type: at.TOP_SITES_UNPIN,
data: { site: { url: site.url } },
}),
userEvent: "UNPIN",
}),
SaveToPocket: (site, index, eventSource) => ({
id: "newtab-menu-save-to-pocket",
icon: "pocket-save",
action: ac.AlsoToMain({
type: at.SAVE_TO_POCKET,
data: { site: { url: site.url, title: site.title } },
}),
impression: ac.ImpressionStats({
source: eventSource,
pocket: 0,
tiles: [
{
id: site.guid,
pos: index,
...(site.shim && site.shim.save ? { shim: site.shim.save } : {}),
},
],
}),
userEvent: "SAVE_TO_POCKET",
}),
DeleteFromPocket: site => ({
id: "newtab-menu-delete-pocket",
icon: "pocket-delete",
action: ac.AlsoToMain({
type: at.DELETE_FROM_POCKET,
data: { pocket_id: site.pocket_id },
}),
userEvent: "DELETE_FROM_POCKET",
}),
ArchiveFromPocket: site => ({
id: "newtab-menu-archive-pocket",
icon: "pocket-archive",
action: ac.AlsoToMain({
type: at.ARCHIVE_FROM_POCKET,
data: { pocket_id: site.pocket_id },
}),
userEvent: "ARCHIVE_FROM_POCKET",
}),
EditTopSite: (site, index) => ({
id: "newtab-menu-edit-topsites",
icon: "edit",
action: {
type: at.TOP_SITES_EDIT,
data: { index },
},
}),
CheckBookmark: site =>
site.bookmarkGuid
? LinkMenuOptions.RemoveBookmark(site)
: LinkMenuOptions.AddBookmark(site),
CheckPinTopSite: (site, index) =>
site.isPinned
? LinkMenuOptions.UnpinTopSite(site)
: LinkMenuOptions.PinTopSite(site, index),
CheckSavedToPocket: (site, index) =>
site.pocket_id
? LinkMenuOptions.DeleteFromPocket(site)
: LinkMenuOptions.SaveToPocket(site, index),
CheckBookmarkOrArchive: site =>
site.pocket_id
? LinkMenuOptions.ArchiveFromPocket(site)
: LinkMenuOptions.CheckBookmark(site),
OpenInPrivateWindow: (site, index, eventSource, isEnabled) =>
isEnabled ? _OpenInPrivateWindow(site) : LinkMenuOptions.EmptyItem(),
};
================================================
FILE: content-src/lib/screenshot-utils.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/. */
/**
* List of helper functions for screenshot-based images.
*
* There are two kinds of images:
* 1. Remote Image: This is the image from the main process and it refers to
* the image in the React props. This can either be an object with the `data`
* and `path` properties, if it is a blob, or a string, if it is a normal image.
* 2. Local Image: This is the image object in the content process and it refers
* to the image *object* in the React component's state. All local image
* objects have the `url` property, and an additional property `path`, if they
* are blobs.
*/
export const ScreenshotUtils = {
isBlob(isLocal, image) {
return !!(
image &&
image.path &&
((!isLocal && image.data) || (isLocal && image.url))
);
},
// This should always be called with a remote image and not a local image.
createLocalImageObject(remoteImage) {
if (!remoteImage) {
return null;
}
if (this.isBlob(false, remoteImage)) {
return {
url: global.URL.createObjectURL(remoteImage.data),
path: remoteImage.path,
};
}
return { url: remoteImage };
},
// Revokes the object URL of the image if the local image is a blob.
// This should always be called with a local image and not a remote image.
maybeRevokeBlobObjectURL(localImage) {
if (this.isBlob(true, localImage)) {
global.URL.revokeObjectURL(localImage.url);
}
},
// Checks if remoteImage and localImage are the same.
isRemoteImageLocal(localImage, remoteImage) {
// Both remoteImage and localImage are present.
if (remoteImage && localImage) {
return this.isBlob(false, remoteImage)
? localImage.path === remoteImage.path
: localImage.url === remoteImage;
}
// This will only handle the remaining three possible outcomes.
// (i.e. everything except when both image and localImage are present)
return !remoteImage && !localImage;
},
};
================================================
FILE: content-src/lib/section-menu-options.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/. */
import { actionCreators as ac, actionTypes as at } from "common/Actions.jsm";
/**
* List of functions that return items that can be included as menu options in a
* SectionMenu. All functions take the section as the only parameter.
*/
export const SectionMenuOptions = {
Separator: () => ({ type: "separator" }),
MoveUp: section => ({
id: "newtab-section-menu-move-up",
icon: "arrowhead-up",
action: ac.OnlyToMain({
type: at.SECTION_MOVE,
data: { id: section.id, direction: -1 },
}),
userEvent: "MENU_MOVE_UP",
disabled: !!section.isFirst,
}),
MoveDown: section => ({
id: "newtab-section-menu-move-down",
icon: "arrowhead-down",
action: ac.OnlyToMain({
type: at.SECTION_MOVE,
data: { id: section.id, direction: +1 },
}),
userEvent: "MENU_MOVE_DOWN",
disabled: !!section.isLast,
}),
RemoveSection: section => ({
id: "newtab-section-menu-remove-section",
icon: "dismiss",
action: ac.SetPref(section.showPrefName, false),
userEvent: "MENU_REMOVE",
}),
CollapseSection: section => ({
id: "newtab-section-menu-collapse-section",
icon: "minimize",
action: ac.OnlyToMain({
type: at.UPDATE_SECTION_PREFS,
data: { id: section.id, value: { collapsed: true } },
}),
userEvent: "MENU_COLLAPSE",
}),
ExpandSection: section => ({
id: "newtab-section-menu-expand-section",
icon: "maximize",
action: ac.OnlyToMain({
type: at.UPDATE_SECTION_PREFS,
data: { id: section.id, value: { collapsed: false } },
}),
userEvent: "MENU_EXPAND",
}),
ManageSection: section => ({
id: "newtab-section-menu-manage-section",
icon: "settings",
action: ac.OnlyToMain({ type: at.SETTINGS_OPEN }),
userEvent: "MENU_MANAGE",
}),
ManageWebExtension: section => ({
id: "newtab-section-menu-manage-webext",
icon: "settings",
action: ac.OnlyToMain({ type: at.OPEN_WEBEXT_SETTINGS, data: section.id }),
}),
AddTopSite: section => ({
id: "newtab-section-menu-add-topsite",
icon: "add",
action: { type: at.TOP_SITES_EDIT, data: { index: -1 } },
userEvent: "MENU_ADD_TOPSITE",
}),
AddSearchShortcut: section => ({
id: "newtab-section-menu-add-search-engine",
icon: "search",
action: { type: at.TOP_SITES_OPEN_SEARCH_SHORTCUTS_MODAL },
userEvent: "MENU_ADD_SEARCH",
}),
PrivacyNotice: section => ({
id: "newtab-section-menu-privacy-notice",
icon: "info",
action: ac.OnlyToMain({
type: at.OPEN_LINK,
data: { url: section.privacyNoticeURL },
}),
userEvent: "MENU_PRIVACY_NOTICE",
}),
CheckCollapsed: section =>
section.collapsed
? SectionMenuOptions.ExpandSection(section)
: SectionMenuOptions.CollapseSection(section),
};
================================================
FILE: content-src/lib/selectLayoutRender.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/. */
export const selectLayoutRender = ({
state = {},
prefs = {},
rollCache = [],
lang = "",
}) => {
const { layout, feeds, spocs } = state;
let spocIndexMap = {};
let bufferRollCache = [];
// Records the chosen and unchosen spocs by the probability selection.
let chosenSpocs = new Set();
let unchosenSpocs = new Set();
function rollForSpocs(data, spocsConfig, spocsData, placementName) {
if (!spocIndexMap[placementName] && spocIndexMap[placementName] !== 0) {
spocIndexMap[placementName] = 0;
}
const results = [...data];
for (let position of spocsConfig.positions) {
const spoc = spocsData[spocIndexMap[placementName]];
if (!spoc) {
break;
}
// Cache random number for a position
let rickRoll;
if (!rollCache.length) {
rickRoll = Math.random();
bufferRollCache.push(rickRoll);
} else {
rickRoll = rollCache.shift();
bufferRollCache.push(rickRoll);
}
if (rickRoll <= spocsConfig.probability) {
spocIndexMap[placementName]++;
if (!spocs.blocked.includes(spoc.url)) {
results.splice(position.index, 0, spoc);
chosenSpocs.add(spoc);
}
} else {
unchosenSpocs.add(spoc);
}
}
return results;
}
const positions = {};
const DS_COMPONENTS = [
"Message",
"TextPromo",
"SectionTitle",
"Navigation",
"CardGrid",
"Hero",
"HorizontalRule",
"List",
];
const filterArray = [];
if (!prefs["feeds.topsites"]) {
filterArray.push("TopSites");
}
if (!lang.startsWith("en-")) {
filterArray.push("Navigation");
}
if (!prefs["feeds.section.topstories"]) {
filterArray.push(...DS_COMPONENTS);
}
const placeholderComponent = component => {
if (!component.feed) {
// TODO we now need a placeholder for topsites and textPromo.
return {
...component,
data: {
spocs: [],
},
};
}
const data = {
recommendations: [],
};
let items = 0;
if (component.properties && component.properties.items) {
items = component.properties.items;
}
for (let i = 0; i < items; i++) {
data.recommendations.push({ placeholder: true });
}
return { ...component, data };
};
// TODO update devtools to show placements
const handleSpocs = (data, component) => {
let result = [...data];
// Do we ever expect to possibly have a spoc.
if (
component.spocs &&
component.spocs.positions &&
component.spocs.positions.length
) {
const placement = component.placement || {};
const placementName = placement.name || "spocs";
const spocsData = spocs.data[placementName];
// We expect a spoc, spocs are loaded, and the server returned spocs.
if (spocs.loaded && spocsData && spocsData.length) {
result = rollForSpocs(
result,
component.spocs,
spocsData,
placementName
);
}
}
return result;
};
const handleComponent = component => {
return {
...component,
data: {
spocs: handleSpocs([], component),
},
};
};
const handleComponentWithFeed = component => {
positions[component.type] = positions[component.type] || 0;
let data = {
recommendations: [],
};
const feed = feeds.data[component.feed.url];
if (feed && feed.data) {
data = {
...feed.data,
recommendations: [...(feed.data.recommendations || [])],
};
}
if (component && component.properties && component.properties.offset) {
data = {
...data,
recommendations: data.recommendations.slice(
component.properties.offset
),
};
}
data = {
...data,
recommendations: handleSpocs(data.recommendations, component),
};
let items = 0;
if (component.properties && component.properties.items) {
items = Math.min(component.properties.items, data.recommendations.length);
}
// loop through a component items
// Store the items position sequentially for multiple components of the same type.
// Example: A second card grid starts pos offset from the last card grid.
for (let i = 0; i < items; i++) {
data.recommendations[i] = {
...data.recommendations[i],
pos: positions[component.type]++,
};
}
return { ...component, data };
};
const renderLayout = () => {
const renderedLayoutArray = [];
for (const row of layout.filter(
r => r.components.filter(c => !filterArray.includes(c.type)).length
)) {
let components = [];
renderedLayoutArray.push({
...row,
components,
});
for (const component of row.components.filter(
c => !filterArray.includes(c.type)
)) {
const spocsConfig = component.spocs;
if (spocsConfig || component.feed) {
// TODO make sure this still works for different loading cases.
if (
(component.feed && !feeds.data[component.feed.url]) ||
(spocsConfig &&
spocsConfig.positions &&
spocsConfig.positions.length &&
!spocs.loaded)
) {
components.push(placeholderComponent(component));
return renderedLayoutArray;
}
if (component.feed) {
components.push(handleComponentWithFeed(component));
} else {
components.push(handleComponent(component));
}
} else {
components.push(component);
}
}
}
return renderedLayoutArray;
};
const layoutRender = renderLayout();
// If empty, fill rollCache with random probability values from bufferRollCache
if (!rollCache.length) {
rollCache.push(...bufferRollCache);
}
// Generate the payload for the SPOCS Fill ping. Note that a SPOC could be rejected
// by the `probability_selection` first, then gets chosen for the next position. For
// all other SPOCS that never went through the probabilistic selection, its reason will
// be "out_of_position".
let spocsFill = [];
if (spocs.loaded && feeds.loaded && spocs.data.spocs) {
const chosenSpocsFill = [...chosenSpocs].map(spoc => ({
id: spoc.id,
reason: "n/a",
displayed: 1,
full_recalc: 0,
}));
const unchosenSpocsFill = [...unchosenSpocs]
.filter(spoc => !chosenSpocs.has(spoc))
.map(spoc => ({
id: spoc.id,
reason: "probability_selection",
displayed: 0,
full_recalc: 0,
}));
const outOfPositionSpocsFill = spocs.data.spocs
.slice(spocIndexMap.spocs)
.filter(spoc => !unchosenSpocs.has(spoc))
.map(spoc => ({
id: spoc.id,
reason: "out_of_position",
displayed: 0,
full_recalc: 0,
}));
spocsFill = [
...chosenSpocsFill,
...unchosenSpocsFill,
...outOfPositionSpocsFill,
];
}
return { spocsFill, layoutRender };
};
================================================
FILE: content-src/styles/_activity-stream.scss
================================================
@import './normalize';
@import './variables';
@import './theme';
@import './icons';
@import './mixins';
html {
height: 100%;
}
body,
#root { // sass-lint:disable-line no-ids
min-height: 100vh;
}
#root { // sass-lint:disable-line no-ids
position: relative;
}
body {
background-color: var(--newtab-background-color);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Ubuntu', 'Helvetica Neue', sans-serif;
font-size: 16px;
}
.no-scroll {
overflow: hidden;
}
h1,
h2 {
font-weight: normal;
}
a {
text-decoration: none;
}
.inner-border {
border: $border-secondary;
border-radius: $border-radius;
height: 100%;
left: 0;
pointer-events: none;
position: absolute;
top: 0;
width: 100%;
z-index: 100;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.show-on-init {
opacity: 0;
transition: opacity 0.2s ease-in;
&.on {
animation: fadeIn 0.2s;
opacity: 1;
}
}
.actions {
border-top: $border-secondary;
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: flex-start;
margin: 0;
padding: 15px 25px 0;
}
// Default button (grey)
.button,
.actions button {
background-color: var(--newtab-button-secondary-color);
border: $border-primary;
border-radius: 4px;
color: inherit;
cursor: pointer;
margin-bottom: 15px;
padding: 10px 30px;
white-space: nowrap;
&:hover:not(.dismiss),
&:focus:not(.dismiss) {
box-shadow: $shadow-primary;
transition: box-shadow 150ms;
}
&.dismiss {
background-color: transparent;
border: 0;
padding: 0;
text-decoration: underline;
}
// Blue button
&.primary,
&.done {
background-color: var(--newtab-button-primary-color);
border: solid 1px var(--newtab-button-primary-color);
color: $white;
margin-inline-start: auto;
}
}
input {
&[type='text'],
&[type='search'] {
border-radius: $border-radius;
}
}
// These styles are needed for -webkit-line-clamp to work correctly, so reuse
// this class name while separately setting a clamp value via CSS or JS.
.clamp {
-webkit-box-orient: vertical;
display: -webkit-box;
overflow: hidden;
word-break: break-word;
}
// Components
@import '../components/A11yLinkButton/A11yLinkButton';
@import '../components/Base/Base';
@import '../components/ErrorBoundary/ErrorBoundary';
@import '../components/TopSites/TopSites';
@import '../components/Sections/Sections';
@import '../components/Topics/Topics';
@import '../components/Search/Search';
@import '../components/ContextMenu/ContextMenu';
@import '../components/ConfirmDialog/ConfirmDialog';
@import '../components/Card/Card';
@import '../components/CollapsibleSection/CollapsibleSection';
@import '../components/ASRouterAdmin/ASRouterAdmin';
@import '../components/PocketLoggedInCta/PocketLoggedInCta';
@import '../components/MoreRecommendations/MoreRecommendations';
@import '../components/DiscoveryStreamBase/DiscoveryStreamBase';
// Discovery Stream Components
@import '../components/DiscoveryStreamComponents/CardGrid/CardGrid';
@import '../components/DiscoveryStreamComponents/Hero/Hero';
@import '../components/DiscoveryStreamComponents/Highlights/Highlights';
@import '../components/DiscoveryStreamComponents/HorizontalRule/HorizontalRule';
@import '../components/DiscoveryStreamComponents/List/List';
@import '../components/DiscoveryStreamComponents/Navigation/Navigation';
@import '../components/DiscoveryStreamComponents/SectionTitle/SectionTitle';
@import '../components/DiscoveryStreamComponents/TopSites/TopSites';
@import '../components/DiscoveryStreamComponents/DSLinkMenu/DSLinkMenu';
@import '../components/DiscoveryStreamComponents/DSCard/DSCard';
@import '../components/DiscoveryStreamComponents/DSContextFooter/DSContextFooter';
@import '../components/DiscoveryStreamComponents/DSImage/DSImage';
@import '../components/DiscoveryStreamComponents/DSDismiss/DSDismiss';
@import '../components/DiscoveryStreamComponents/DSMessage/DSMessage';
@import '../components/DiscoveryStreamImpressionStats/ImpressionStats';
@import '../components/DiscoveryStreamComponents/DSEmptyState/DSEmptyState';
@import '../components/DiscoveryStreamComponents/DSTextPromo/DSTextPromo';
@import '../components/DiscoveryStreamComponents/DSPrivacyModal/DSPrivacyModal';
// AS Router
@import '../asrouter/components/Button/Button';
@import '../asrouter/components/SnippetBase/SnippetBase';
@import '../asrouter/components/ModalOverlay/ModalOverlay';
@import '../asrouter/templates/ReturnToAMO/ReturnToAMO';
@import '../asrouter/templates/SimpleBelowSearchSnippet/SimpleBelowSearchSnippet';
@import '../asrouter/templates/SimpleSnippet/SimpleSnippet';
@import '../asrouter/templates/SubmitFormSnippet/SubmitFormSnippet';
@import '../asrouter/templates/OnboardingMessage/OnboardingMessage';
@import '../asrouter/templates/EOYSnippet/EOYSnippet';
@import '../asrouter/components/FxASignupForm/FxASignupForm';
@import '../asrouter/templates/Trailhead/Trailhead';
@import '../asrouter/templates/FullPageInterrupt/FullPageInterrupt';
================================================
FILE: content-src/styles/_icons.scss
================================================
.icon {
background-position: center center;
background-repeat: no-repeat;
background-size: $icon-size;
-moz-context-properties: fill;
display: inline-block;
fill: var(--newtab-icon-primary-color);
height: $icon-size;
vertical-align: middle;
width: $icon-size;
// helper classes
&.icon-spacer {
margin-inline-end: 8px;
}
&.icon-small-spacer {
margin-inline-end: 6px;
}
&.icon-button-style {
fill: var(--newtab-icon-secondary-color);
border: 0;
&:focus,
&:hover {
fill: var(--newtab-text-primary-color);
}
}
// icon images
&.icon-bookmark-added {
background-image: url('chrome://browser/skin/bookmark.svg');
}
&.icon-bookmark-hollow {
background-image: url('chrome://browser/skin/bookmark-hollow.svg');
}
&.icon-clear-input {
background-image: url('#{$image-path}glyph-cancel-16.svg');
}
&.icon-delete {
background-image: url('#{$image-path}glyph-delete-16.svg');
}
&.icon-search {
background-image: url('chrome://browser/skin/search-glass.svg');
}
&.icon-modal-delete {
flex-shrink: 0;
background-image: url('#{$image-path}glyph-modal-delete-32.svg');
background-size: $larger-icon-size;
height: $larger-icon-size;
width: $larger-icon-size;
}
&.icon-dismiss {
background-image: url('#{$image-path}glyph-dismiss-16.svg');
}
&.icon-info {
background-image: url('#{$image-path}glyph-info-16.svg');
}
&.icon-new-window {
@include flip-icon;
background-image: url('#{$image-path}glyph-newWindow-16.svg');
}
&.icon-new-window-private {
background-image: url('chrome://browser/skin/privateBrowsing.svg');
}
&.icon-settings {
background-image: url('chrome://browser/skin/settings.svg');
}
&.icon-pin {
@include flip-icon;
background-image: url('#{$image-path}glyph-pin-16.svg');
}
&.icon-unpin {
@include flip-icon;
background-image: url('#{$image-path}glyph-unpin-16.svg');
}
&.icon-edit {
background-image: url('#{$image-path}glyph-edit-16.svg');
}
&.icon-pocket {
background-image: url('#{$image-path}glyph-pocket-16.svg');
}
&.icon-pocket-save {
background-image: url('#{$image-path}glyph-pocket-save-16.svg');
}
&.icon-pocket-delete {
background-image: url('#{$image-path}glyph-pocket-delete-16.svg');
}
&.icon-pocket-archive {
background-image: url('#{$image-path}glyph-pocket-archive-16.svg');
}
&.icon-history-item {
background-image: url('chrome://browser/skin/history.svg');
}
&.icon-trending {
background-image: url('#{$image-path}glyph-trending-16.svg');
transform: translateY(2px); // trending bolt is visually top heavy
}
&.icon-now {
background-image: url('chrome://browser/skin/history.svg');
}
&.icon-topsites {
background-image: url('#{$image-path}glyph-topsites-16.svg');
}
&.icon-pin-small {
@include flip-icon;
background-image: url('#{$image-path}glyph-pin-12.svg');
background-size: $smaller-icon-size;
height: $smaller-icon-size;
width: $smaller-icon-size;
}
&.icon-check {
background-image: url('chrome://global/skin/icons/check.svg');
}
&.icon-download {
background-image: url('chrome://browser/skin/downloads/download-icons.svg#arrow-with-bar');
}
&.icon-copy {
background-image: url('chrome://browser/skin/edit-copy.svg');
}
&.icon-open-file {
background-image: url('#{$image-path}glyph-open-file-16.svg');
}
&.icon-webextension {
background-image: url('#{$image-path}glyph-webextension-16.svg');
}
&.icon-highlights {
background-image: url('#{$image-path}glyph-highlights-16.svg');
}
&.icon-arrowhead-down {
background-image: url('#{$image-path}glyph-arrowhead-down-16.svg');
}
&.icon-arrowhead-down-small {
background-image: url('#{$image-path}glyph-arrowhead-down-12.svg');
background-size: $smaller-icon-size;
height: $smaller-icon-size;
width: $smaller-icon-size;
}
&.icon-arrowhead-forward-small {
background-image: url('#{$image-path}glyph-arrowhead-down-12.svg');
background-size: $smaller-icon-size;
height: $smaller-icon-size;
transform: rotate(-90deg);
width: $smaller-icon-size;
&:dir(rtl) {
transform: rotate(90deg);
}
}
&.icon-arrowhead-up {
background-image: url('#{$image-path}glyph-arrowhead-down-16.svg');
transform: rotate(180deg);
}
&.icon-add {
background-image: url('#{$image-path}glyph-add-16.svg');
}
&.icon-minimize {
background-image: url('#{$image-path}glyph-minimize-16.svg');
}
&.icon-maximize {
background-image: url('#{$image-path}glyph-maximize-16.svg');
}
&.icon-arrow {
background-image: url('#{$image-path}glyph-arrow.svg');
}
}
================================================
FILE: content-src/styles/_mixins.scss
================================================
// Shared styling of article images shown as background
@mixin image-as-background {
background-color: var(--newtab-card-placeholder-color);
background-position: center;
background-repeat: no-repeat;
background-size: cover;
border-radius: 4px;
box-shadow: inset 0 0 0 0.5px $black-15;
}
// Note: lineHeight and fontSize should be unitless but can be derived from pixel values
// Bug 1550624 to clean up / remove this mixin to avoid duplicate styles
@mixin limit-visible-lines($line-count, $line-height, $font-size) {
font-size: $font-size * 1px;
-webkit-line-clamp: $line-count;
line-height: $line-height * 1px;
}
@mixin dark-theme-only {
[lwt-newtab-brighttext] & {
@content;
}
}
@mixin ds-border-top {
@content;
@include dark-theme-only {
border-top: 1px solid $grey-60;
}
border-top: 1px solid $grey-30;
}
@mixin ds-border-bottom {
@content;
@include dark-theme-only {
border-bottom: 1px solid $grey-60;
}
border-bottom: 1px solid $grey-30;
}
@mixin ds-fade-in($halo-color: $blue-50-30) {
box-shadow: 0 0 0 5px $halo-color;
transition: box-shadow 150ms;
border-radius: 4px;
outline: none;
}
================================================
FILE: content-src/styles/_normalize.scss
================================================
html {
box-sizing: border-box;
}
*,
*::before,
*::after {
box-sizing: inherit;
}
*::-moz-focus-inner {
border: 0;
}
body {
margin: 0;
}
button,
input {
background-color: inherit;
color: inherit;
font-family: inherit;
font-size: inherit;
}
[hidden] {
display: none !important; // sass-lint:disable-line no-important
}
================================================
FILE: content-src/styles/_theme.scss
================================================
@function textbox-shadow($color) {
@return 0 0 0 1px $color, 0 0 0 $textbox-shadow-size rgba($color, 0.3);
}
@mixin textbox-focus($color) {
--newtab-textbox-focus-color: #{$color};
--newtab-textbox-focus-boxshadow: #{textbox-shadow($color)};
}
// scss variables related to the theme.
$border-primary: 1px solid var(--newtab-border-primary-color);
$border-secondary: 1px solid var(--newtab-border-secondary-color);
$inner-box-shadow: 0 0 0 1px var(--newtab-inner-box-shadow-color);
$input-border: 1px solid var(--newtab-textbox-border);
$input-border-active: 1px solid var(--newtab-textbox-focus-color);
$input-error-border: 1px solid $red-60;
$input-error-boxshadow: textbox-shadow($red-60);
$shadow-primary: 0 0 0 5px var(--newtab-card-active-outline-color);
$shadow-secondary: 0 1px 4px 0 $grey-90-20;
// Default theme
body {
// General styles
--newtab-background-color: #{$grey-10};
--newtab-border-primary-color: #{$grey-40};
--newtab-border-secondary-color: #{$grey-30};
--newtab-element-active-color: #{$grey-30-60};
--newtab-element-hover-color: #{$grey-20};
--newtab-icon-primary-color: #{$grey-90-80};
--newtab-icon-secondary-color: #{$grey-90-60};
--newtab-icon-tertiary-color: #{$grey-30};
--newtab-inner-box-shadow-color: #{$black-10};
--newtab-link-primary-color: #{$blue-60};
--newtab-link-secondary-color: #{$teal-70};
--newtab-text-conditional-color: #{$grey-60};
--newtab-text-primary-color: #{$grey-90};
--newtab-text-secondary-color: #{$grey-50};
--newtab-textbox-background-color: #{$white};
--newtab-textbox-border: #{$grey-90-20};
@include textbox-focus($blue-60); // sass-lint:disable-line mixins-before-declarations
// Buttons
--newtab-button-primary-color: #{$blue-60};
--newtab-button-secondary-color: inherit;
// Feed buttons
--newtab-feed-button-background: #{$grey-20};
--newtab-feed-button-text: #{$grey-90};
--newtab-feed-button-background-faded: #{$grey-20-60};
--newtab-feed-button-text-faded: #{$grey-90-00};
--newtab-feed-button-spinner: #{$grey-50};
// Context menu
--newtab-contextmenu-background-color: #{$grey-10};
--newtab-contextmenu-button-color: #{$white};
// Modal + overlay
--newtab-modal-color: #{$white};
--newtab-overlay-color: #{$grey-20-80};
// Sections
--newtab-section-header-text-color: #{$grey-50};
--newtab-section-navigation-text-color: #{$grey-50};
--newtab-section-active-contextmenu-color: #{$grey-90};
// Search
--newtab-search-border-color: transparent;
--newtab-search-dropdown-color: #{$white};
--newtab-search-dropdown-header-color: #{$grey-10};
--newtab-search-header-background-color: #{$grey-10-95};
--newtab-search-icon-color: #{$grey-90-40};
--newtab-search-wordmark-color: #{$firefox-wordmark-default-color};
// Top Sites
--newtab-topsites-background-color: #{$white};
--newtab-topsites-icon-shadow: inset #{$inner-box-shadow};
--newtab-topsites-label-color: inherit;
// Cards
--newtab-card-active-outline-color: #{$grey-30};
--newtab-card-background-color: #{$white};
--newtab-card-hairline-color: #{$black-10};
--newtab-card-placeholder-color: #{$grey-30};
--newtab-card-shadow: 0 1px 4px 0 #{$grey-90-10};
// Snippets
--newtab-snippets-background-color: #{$white};
--newtab-snippets-hairline-color: transparent;
// Trailhead
--trailhead-header-text-color: #{$trailhead-purple};
--trailhead-cards-background-color: #{$grey-20};
--trailhead-card-button-background-color: #{$grey-90-10};
--trailhead-card-button-background-hover-color: #{$grey-90-20};
--trailhead-card-button-background-active-color: #{$grey-90-30};
&[lwt-newtab-brighttext] {
// General styles
--newtab-background-color: #{$grey-80};
--newtab-border-primary-color: #{$grey-10-80};
--newtab-border-secondary-color: #{$grey-10-10};
--newtab-button-primary-color: #{$blue-60};
--newtab-button-secondary-color: #{$grey-70};
--newtab-element-active-color: #{$grey-10-20};
--newtab-element-hover-color: #{$grey-10-10};
--newtab-icon-primary-color: #{$grey-10-80};
--newtab-icon-secondary-color: #{$grey-10-40};
--newtab-icon-tertiary-color: #{$grey-10-40};
--newtab-inner-box-shadow-color: #{$grey-10-20};
--newtab-link-primary-color: #{$blue-40};
--newtab-link-secondary-color: #{$pocket-teal};
--newtab-text-conditional-color: #{$grey-10};
--newtab-text-primary-color: #{$grey-10};
--newtab-text-secondary-color: #{$grey-10-80};
--newtab-textbox-background-color: #{$grey-70};
--newtab-textbox-border: #{$grey-10-20};
@include textbox-focus($blue-40); // sass-lint:disable-line mixins-before-declarations
// Feed buttons
--newtab-feed-button-background: #{$grey-70};
--newtab-feed-button-text: #{$grey-10};
--newtab-feed-button-background-faded: #{$grey-70-60};
--newtab-feed-button-text-faded: #{$grey-10-00};
--newtab-feed-button-spinner: #{$grey-30};
// Context menu
--newtab-contextmenu-background-color: #{$grey-60};
--newtab-contextmenu-button-color: #{$grey-80};
// Modal + overlay
--newtab-modal-color: #{$grey-80};
--newtab-overlay-color: #{$grey-90-80};
// Sections
--newtab-section-header-text-color: #{$grey-10-80};
--newtab-section-navigation-text-color: #{$grey-10-80};
--newtab-section-active-contextmenu-color: #{$white};
// Search
--newtab-search-border-color: #{$grey-10-20};
--newtab-search-dropdown-color: #{$grey-70};
--newtab-search-dropdown-header-color: #{$grey-60};
--newtab-search-header-background-color: #{$grey-80-95};
--newtab-search-icon-color: #{$grey-10-60};
--newtab-search-wordmark-color: #{$firefox-wordmark-darktheme-color};
// Top Sites
--newtab-topsites-background-color: #{$grey-70};
--newtab-topsites-icon-shadow: none;
--newtab-topsites-label-color: #{$grey-10-80};
// Cards
--newtab-card-active-outline-color: #{$grey-60};
--newtab-card-background-color: #{$grey-70};
--newtab-card-hairline-color: #{$grey-10-10};
--newtab-card-placeholder-color: #{$grey-60};
--newtab-card-shadow: 0 1px 8px 0 #{$grey-90-20};
// Snippets
--newtab-snippets-background-color: #{$grey-70};
--newtab-snippets-hairline-color: #{$white-10};
// Trailhead
--trailhead-header-text-color: #{$white-60};
--trailhead-cards-background-color: #{$grey-90-10};
--trailhead-card-button-background-color: #{$grey-90-30};
--trailhead-card-button-background-hover-color: #{$grey-90-50};
--trailhead-card-button-background-active-color: #{$grey-90-70};
}
}
================================================
FILE: content-src/styles/_variables.scss
================================================
// Photon colors from http://design.firefox.com/photon/visuals/color.html
$blue-40: #45A1FF;
$blue-50: #0A84FF;
$blue-60: #0060DF;
$blue-70: #003EAA;
$blue-80: #002275;
$grey-10: #F9F9FA;
$grey-20: #EDEDF0;
$grey-30: #D7D7DB;
$grey-40: #B1B1B3;
$grey-50: #737373;
$grey-60: #4A4A4F;
$grey-70: #38383D;
$grey-80: #2A2A2E;
$grey-90: #0C0C0D;
$teal-10: #A7FFFE;
$teal-60: #00C8D7;
$teal-70: #008EA4;
$teal-80: #005A71;
$red-60: #D70022;
$yellow-50: #FFE900;
$violet-20: #CB9EFF;
// Photon opacity from http://design.firefox.com/photon/visuals/color.html#opacity
$grey-10-00: rgba($grey-10, 0);
$grey-10-10: rgba($grey-10, 0.1);
$grey-10-20: rgba($grey-10, 0.2);
$grey-10-30: rgba($grey-10, 0.3);
$grey-10-40: rgba($grey-10, 0.4);
$grey-10-50: rgba($grey-10, 0.5);
$grey-10-60: rgba($grey-10, 0.6);
$grey-10-80: rgba($grey-10, 0.8);
$grey-10-95: rgba($grey-10, 0.95);
$grey-20-60: rgba($grey-20, 0.6);
$grey-20-80: rgba($grey-20, 0.8);
$grey-30-60: rgba($grey-30, 0.6);
$grey-60-60: rgba($grey-60, 0.6);
$grey-60-70: rgba($grey-60, 0.7);
$grey-70-40: rgba($grey-70, 0.4);
$grey-70-60: rgba($grey-70, 0.6);
$grey-80-95: rgba($grey-80, 0.95);
$grey-90-00: rgba($grey-90, 0);
$grey-90-10: rgba($grey-90, 0.1);
$grey-90-20: rgba($grey-90, 0.2);
$grey-90-30: rgba($grey-90, 0.3);
$grey-90-40: rgba($grey-90, 0.4);
$grey-90-50: rgba($grey-90, 0.5);
$grey-90-60: rgba($grey-90, 0.6);
$grey-90-70: rgba($grey-90, 0.7);
$grey-90-80: rgba($grey-90, 0.8);
$grey-90-90: rgba($grey-90, 0.9);
$blue-40-40: rgba($blue-40, 0.4);
$blue-50-50: rgba($blue-50, 0.5);
$blue-50-30: rgba($blue-50, 0.3);
$blue-50-50: rgba($blue-50, 0.5);
$black: #000;
$black-5: rgba($black, 0.05);
$black-10: rgba($black, 0.1);
$black-12: rgba($black, 0.12);
$black-15: rgba($black, 0.15);
$black-20: rgba($black, 0.2);
$black-25: rgba($black, 0.25);
$black-30: rgba($black, 0.3);
// Other colors
$white: #FFF;
$white-10: rgba($white, 0.1);
$white-50: rgba($white, 0.5);
$white-60: rgba($white, 0.6);
$white-70: rgba($white, 0.7);
$ghost-white: #FAFAFC;
$pocket-teal: #50BCB6;
$pocket-red: #EF4056;
$shadow-10: rgba(12, 12, 13, 0.1);
$bookmark-icon-fill: #0A84FF;
$download-icon-fill: #12BC00;
$pocket-icon-fill: #D70022;
$email-input-focus: rgba($blue-50, 0.3);
$email-input-invalid: rgba($red-60, 0.3);
$aw-extra-blue-1: #004EC2;
$aw-extra-blue-2: #0080FF;
$aw-extra-blue-3: #00C7FF;
$about-welcome-gradient: linear-gradient(to bottom, $blue-70 40%, $aw-extra-blue-1 60%, $blue-60 80%, $aw-extra-blue-2 90%, $aw-extra-blue-3 100%);
$about-welcome-extra-links: #676F7E;
$firefox-wordmark-default-color: #363959;
$firefox-wordmark-darktheme-color: $white;
$trailhead-violet: #7542E5;
$trailhead-purple: #2B2156;
$trailhead-purple-80: #36296D;
$trailhead-blue-60: #0250BB;
$trailhead-blue-70: #054096;
// Photon transitions from http://design.firefox.com/photon/motion/duration-and-easing.html
$photon-easing: cubic-bezier(0.07, 0.95, 0, 1);
$border-radius: 3px;
// Grid related styles
$base-gutter: 32px;
$section-horizontal-padding: 25px;
$section-vertical-padding: 10px;
$section-spacing: 40px - $section-vertical-padding * 2;
$grid-unit: 96px; // 1 top site
$icon-size: 16px;
$smaller-icon-size: 12px;
$larger-icon-size: 32px;
$searchbar-width-small: $grid-unit * 2 + $base-gutter * 1;
$searchbar-width-medium: $grid-unit * 4 + $base-gutter * 3;
$searchbar-width-large: $grid-unit * 6 + $base-gutter * 5;
$wrapper-default-width: $grid-unit * 2 + $base-gutter * 1 + $section-horizontal-padding * 2; // 2 top sites
$wrapper-max-width-medium: $grid-unit * 4 + $base-gutter * 3 + $section-horizontal-padding * 2; // 4 top sites
$wrapper-max-width-large: $grid-unit * 6 + $base-gutter * 5 + $section-horizontal-padding * 2; // 6 top sites
$wrapper-max-width-widest: $grid-unit * 8 + $base-gutter * 7 + $section-horizontal-padding * 2; // 8 top sites
// For the breakpoints, we need to add space for the scrollbar to avoid weird
// layout issues when the scrollbar is visible. 16px is wide enough to cover all
// OSes and keeps it simpler than a per-OS value.
$scrollbar-width: 16px;
// Breakpoints
$break-point-medium: $wrapper-max-width-medium + $base-gutter * 2 + $scrollbar-width;
$break-point-large: $wrapper-max-width-large + $base-gutter * 2 + $scrollbar-width;
$break-point-widest: $wrapper-max-width-widest + $base-gutter * 2 + $scrollbar-width;
$section-title-font-size: 13px;
$card-width: $grid-unit * 2 + $base-gutter;
$card-height: 266px;
$card-preview-image-height: 122px;
$card-title-margin: 2px;
$card-text-line-height: 19px;
// Larger cards for wider screens:
$card-width-large: 309px;
$card-height-large: 370px;
$card-preview-image-height-large: 155px;
// Compact cards for Highlights
$card-height-compact: 160px;
$card-preview-image-height-compact: 108px;
$topic-margin-top: 12px;
$context-menu-button-size: 27px;
$context-menu-button-boxshadow: 0 2px $grey-90-10;
$context-menu-shadow: 0 5px 10px $black-30, 0 0 0 1px $black-20;
$context-menu-font-size: 14px;
$context-menu-border-radius: 5px;
$context-menu-outer-padding: 5px;
$context-menu-item-padding: 3px 12px;
$error-fallback-font-size: 12px;
$error-fallback-line-height: 1.5;
$image-path: '../data/content/assets/';
$snippets-container-height: 120px;
$textbox-shadow-size: 4px;
@mixin fade-in {
box-shadow: inset $inner-box-shadow, $shadow-primary;
transition: box-shadow 150ms;
}
@mixin fade-in-card {
box-shadow: $shadow-primary;
transition: box-shadow 150ms;
}
@mixin context-menu-button {
.context-menu-button {
background-clip: padding-box;
background-color: var(--newtab-contextmenu-button-color);
background-image: url('chrome://global/skin/icons/more.svg');
background-position: 55%;
border: $border-primary;
border-radius: 100%;
box-shadow: $context-menu-button-boxshadow;
cursor: pointer;
fill: var(--newtab-icon-primary-color);
height: $context-menu-button-size;
inset-inline-end: -($context-menu-button-size / 2);
opacity: 0;
position: absolute;
top: -($context-menu-button-size / 2);
transform: scale(0.25);
transition-duration: 150ms;
transition-property: transform, opacity;
width: $context-menu-button-size;
&:-moz-any(:active, :focus) {
opacity: 1;
transform: scale(1);
}
}
}
@mixin context-menu-button-hover {
.context-menu-button {
opacity: 1;
transform: scale(1);
transition-delay: 333ms;
}
}
@mixin context-menu-open-middle {
.context-menu {
margin-inline-end: auto;
margin-inline-start: auto;
inset-inline-end: auto;
inset-inline-start: -$base-gutter;
}
}
@mixin context-menu-open-left {
.context-menu {
margin-inline-end: 5px;
margin-inline-start: auto;
inset-inline-end: 0;
inset-inline-start: auto;
}
}
@mixin flip-icon {
&:dir(rtl) {
transform: scaleX(-1);
}
}
================================================
FILE: content-src/styles/activity-stream-linux.scss
================================================
// sass-lint:disable no-css-comments
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
/* This is the linux variant */
// sass-lint:enable no-css-comments
$os-infopanel-arrow-height: 10px;
$os-infopanel-arrow-offset-end: 6px;
$os-infopanel-arrow-width: 20px;
@import './activity-stream';
================================================
FILE: content-src/styles/activity-stream-mac.scss
================================================
// sass-lint:disable no-css-comments
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
/* This is the mac variant */
// sass-lint:enable no-css-comments
$os-infopanel-arrow-height: 10px;
$os-infopanel-arrow-offset-end: 7px;
$os-infopanel-arrow-width: 18px;
[lwt-newtab-brighttext] {
-moz-osx-font-smoothing: grayscale;
}
@import './activity-stream';
================================================
FILE: content-src/styles/activity-stream-windows.scss
================================================
// sass-lint:disable no-css-comments
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
/* This is the windows variant */
// sass-lint:enable no-css-comments
$os-infopanel-arrow-height: 10px;
$os-infopanel-arrow-offset-end: 6px;
$os-infopanel-arrow-width: 20px;
@import './activity-stream';
================================================
FILE: contributing.md
================================================
# Contributing to Activity Stream
Activity Stream is an enhancement to the functionality of Firefox's about:newtab page. We welcome new 'streamers' to contribute to the project!
## Where to ask questions
- Most of the core dev team can be found on the `#activity-stream` channel on `irc.mozilla.org`.
You can also direct message the core team (`dmose`, `emtwo`, `jkerim`, `k88hudson`, `Mardak`, `nanj`, `r1cky`, `ursula`, `andreio`)
or our manager (`tspurway`)
- Slack channel (staff only): #activitystream
- Mailing List: [activity-stream-dev](https://groups.google.com/a/mozilla.com/d/forum/activity-stream-dev)
- File issues/questions on Github: https://github.com/mozilla/activity-stream/issues. We typically triage new issues every Monday.
## Architecture ##
Activity Stream is a Firefox system add-on. One of the cool things about Activity Stream is that the
[content side of the add-on](https://developer.mozilla.org/en-US/Add-ons/SDK/Guides/Content_Scripts)
is written using [ReactJS](https://facebook.github.io/react/). This makes it an awesome project for React hackers to contribute to!
## Finding Bugs, Filing Tickets, Earning Karma ##
Activity Stream lives on [GitHub](https://github.com/mozilla/activity-stream), but you already knew that! If you've found
a bug, or have a feature idea that you you'd like to see in Activity Stream, follow these simple guidelines:
- Pick a thoughtful and terse title for the issue (ie. *not* Thing Doesn't Work!)
- Make sure to mention your Firefox version, OS and basic system parameters (eg. Firefox 49.0, Windows XP, 512KB RAM)
- If you can reproduce the bug, give a step-by-step recipe
- Include [stack traces from the console(s)](https://developer.mozilla.org/en-US/docs/Mozilla/Debugging/Debugging_JavaScript) where appropriate
- Screenshots welcome!
- When in doubt, take a look at some [existing issues](https://github.com/mozilla/activity-stream/issues) and emulate
## Take a Ticket, Hack some Code ##
If you are new to the repo, you might want to pay close attention to [`Good first bug`](https://github.com/mozilla/activity-stream/issues?q=is%3Aopen+is%3Aissue+label%3A%22Good+first+bug%22),
[`Bug`](https://github.com/mozilla/activity-stream/issues?q=is%3Aopen%20is%3Aissue%20label%3ABug%20),
[`Chore`](https://github.com/mozilla/activity-stream/issues?q=is%3Aopen+is%3Aissue+label%3AChore) and
[`Polish`](https://github.com/mozilla/activity-stream/issues?q=is%3Aopen+is%3Aissue+label%3APolish) tags, as these are
typically a great way to get started. You might see a bug that is not yet assigned to anyone, or start a conversation with
an engineer in the ticket itself, expressing your interest in taking the bug. If you take the bug, someone will set
the ticket to [`Assigned to Contributor`](https://github.com/mozilla/activity-stream/issues?utf8=%E2%9C%93&q=is%3Aopen%20is%3Aissue%20label%3A%22Assigned%20to%20contributor%22%20), which is a way we can be pro-active about helping you succeed in fixing the bug.
When you have some code written, you can open up a [Pull Request](#pull-requests), get your code [reviewed](#code-reviews), and see your code merged into the Activity Stream codebase.
If you are thinking about contributing on a longer-term basis, check out the section on [milestones](#milestones) and [priorities](#priorities)
to get a sense of how we plan and prioritize work.
## Setting up your development environment
Check out [this guide](docs/v2-system-addon/1.GETTING_STARTED.md) on how to install dependencies, get set up, and run tests.
## Pull Requests ##
You have identified the bug, written code and now want to get it into the main repo using a [Pull Request](https://help.github.com/articles/about-pull-requests/).
All code is added using a pull request against the `master` branch of our repo. Before submitting a PR, please go through this checklist:
- all [unit tests](docs/v2-system-addon/unit_testing_guide.md) must pass
- if you haven't written unit tests for your patch, eyebrows will be curmudgeonly furrowed (write unit tests!)
- if your pull request fixes a particular ticket (it does, right?), please use the `fixes #nnn` github annotation to indicate this
- please add a `PR / Needs review` tag to your PR (if you have permission). This starts the code review process. If you cannot add a tag, don't worry, we will add it during triage.
- if you can pick a module owner to be your reviewer by including `r? @username` in the comment (if not, don't worry, we will assign a reviewer)
- make sure your PR will merge gracefully with `master` at the time you create the PR, and that your commit history is 'clean'
### Setting up pre-push hooks
If you contribute often and would like to set up a pre-push hook to always run `npm lint` before you push to Github,
you can run the following from the root of the activity-stream directory:
```
cp hooks/pre-push .git/hooks/pre-push && chmod +x .git/hooks/pre-push
```
Your hook should now run whenever you run `git push`. To skip it, use the `--no-verify` option:
```
git push --no-verify
```
## Code Reviews ##
You have created a PR and submitted it to the repo, and now are waiting patiently for you code review feedback. One of the projects
module owners will be along and will either:
- make suggestions for some improvements
- give you an `R+` in the comments section, indicating the review is done and the code can be merged
Typically, you will iterate on the PR, making changes and pushing your changes to new commits on the PR. When the reviewer is
satisfied that your code is good-to-go, you will get the coveted `R+` comment, and your code can be merged. If you have
commit permission, you can go ahead and merge the code to `master`, otherwise, it will be done for you.
Our project prides itself on it's respectful, patient and positive attitude when it comes to reviewing contributor's code, and as such,
we expect contributors to be respectful, patient and positive in their communications as well. Let's be friends and learn
from each other for a free and awesome web!
[Mozilla Committing Rules and Responsibilities](https://developer.mozilla.org/en-US/docs/Mozilla/Developer_guide/Committing_Rules_and_Responsibilities)
## Git Commit Guidelines ##
We loosely follow the [Angular commit guidelines](https://github.com/angular/angular.js/blob/master/CONTRIBUTING.md#type) of `(): ` where `type` must be one of:
* **feat**: A new feature
* **fix**: A bug fix
* **docs**: Documentation only changes
* **style**: Changes that do not affect the meaning of the code (white-space, formatting, missing
semi-colons, etc)
* **refactor**: A code change that neither fixes a bug or adds a feature
* **perf**: A code change that improves performance
* **test**: Adding missing tests
* **chore**: Changes to the build process or auxiliary tools and libraries such as documentation
generation
### Scope
The scope could be anything specifying place of the commit change. For example `timeline`,
`metadata`, `reporting`, `experiments` etc...
### Subject
The subject contains succinct description of the change:
* use the imperative, present tense: "change" not "changed" nor "changes"
* don't capitalize first letter
* no dot (.) at the end
### Body
In order to maintain a reference to the context of the commit, add
`fixes #` if it closes a related issue or `issue #`
if it's a partial fix.
You can also write a detailed description of the commit:
Just as in the **subject**, use the imperative, present tense: "change" not "changed" nor "changes"
It should include the motivation for the change and contrast this with previous behavior.
### Footer
The footer should contain any information about **Breaking Changes** and is also the place to
reference GitHub issues that this commit **Closes**.
## Milestones ##
All work on Activity Stream is broken into two week iterations, which we map into a GitHub [Milestone](https://github.com/mozilla/activity-stream/milestones). At the beginning of the iteration, we prioritize and estimate tickets
into the milestone, attempting to figure out how much progress we can make during the iteration.
## Priorities ##
All tickets that have been [triaged](#triage) will have a priority tag of either `P1`, `P2`, `P3`, or `P4` which are highest to lowest
priorities of tickets in Activity Stream. We love ticket tags and you might also see `Blocked`, `Critical` or `Chemspill` tags, which
indicate our level of anxiety about that particular ticket.
## Triage ##
The project team meets weekly (in a closed meeting, for the time being), to discuss project priorities, to triage new tickets, and to
redistribute the work amongst team members. Any contributors tickets or PRs are carefully considered, prioritized, and if needed,
assigned a reviewer. The project's GitHub [Milestone page](https://github.com/mozilla/activity-stream/milestones) is the best
place to look for up-to-date information on project priorities and current workload.
## License
MPL 2.0
================================================
FILE: data/content/tippytop/top_sites.json
================================================
[
{
"title": "aliexpress",
"url": "https://www.aliexpress.com/",
"image_url": "aliexpress-com@2x.png"
},
{
"title": "allegro",
"url": "https://www.allegro.pl/",
"image_url": "allegro-pl@2x.png"
},
{
"title": "amazon",
"urls": ["https://www.amazon.ca/", "https://www.amazon.co.uk/", "https://www.amazon.com/", "https://www.amazon.de/", "https://www.amazon.fr/"],
"image_url": "amazon@2x.png"
},
{
"title": "avito",
"url": "https://www.avito.ru/",
"image_url": "avito-ru@2x.png"
},
{
"title": "baidu",
"url": "https://www.baidu.com/",
"image_url": "baidu-com@2x.png"
},
{
"title": "bbc",
"url": "https://www.bbc.co.uk/",
"image_url": "bbc-uk@2x.png"
},
{
"title": "bing",
"url": "https://www.bing.com/",
"image_url": "bing-com@2x.png"
},
{
"title": "duckduckgo",
"url": "https://www.duckduckgo.com/",
"image_url": "duckduckgo-com@2x.png"
},
{
"title": "ebay",
"urls": ["https://www.ebay.com", "https://www.ebay.co.uk/", "https://ebay.de"],
"image_url": "ebay@2x.png"
},
{
"title": "facebook",
"url": "https://www.facebook.com/",
"image_url": "facebook-com@2x.png"
},
{
"title": "google",
"url": "https://www.google.com/",
"image_url": "google-com@2x.png"
},
{
"title": "leboncoin",
"url": "http://www.leboncoin.fr/",
"image_url": "leboncoin-fr@2x.png"
},
{
"title": "ok",
"url": "https://www.ok.ru/",
"image_url": "ok-ru@2x.png"
},
{
"title": "olx",
"url": "https://www.olx.pl/",
"image_url": "olx-pl@2x.png"
},
{
"title": "reddit",
"url": "https://www.reddit.com/",
"image_url": "reddit-com@2x.png"
},
{
"title": "twitter",
"url": "https://twitter.com/",
"image_url": "twitter-com@2x.png"
},
{
"title": "vk",
"url": "https://vk.com/",
"image_url": "vk-com@2x.png"
},
{
"title": "wikipedia",
"url": "https://www.wikipedia.org/",
"image_url": "wikipedia-org@2x.png"
},
{
"title": "wykop",
"url": "https://www.wykop.pl/",
"image_url": "wykop-pl@2x.png"
},
{
"title": "yandex",
"url": "https://www.yandex.com/",
"image_url": "yandex-com@2x.png"
},
{
"title": "youtube",
"url": "https://www.youtube.com/",
"image_url": "youtube-com@2x.png"
}
]
================================================
FILE: docs/ISSUE_TEMPLATE.md
================================================
Please file new bugs in Bugzilla:
https://bugzilla.mozilla.org/enter_bug.cgi?product=Firefox&component=Activity%20Streams%3A%20Newtab
Activity Stream is no longer accepting new issues via GitHub, but Issues are kept open, so old issues can still be viewed.
Thanks for contributing!
================================================
FILE: docs/index.rst
================================================
======================
Firefox Home (New Tab)
======================
All files related to Firefox Home, which includes content that appears on `about:home`,
`about:newtab`, and `about:welcome`, can we found in the `browser/components/newtab` directory.
Some of these source files (such as `.js`, `.jsx`, and `.sass`) require an additional build step.
We are working on migrating this to work with `mach`, but in the meantime, please
follow the following steps if you need to make changes in this directory:
For .jsm files
---------------
No build step is necessary. Use `mach` and run mochitests according to your regular Firefox workflow.
For .js, .jsx, .sass, or .css files
-----------------------------------
Prerequisites
`````````````
You will need the following:
- Node.js 8+ (On Mac, the best way to install Node.js is to use the install link on the `Node.js homepage`_)
- npm (packaged with Node.js)
To install dependencies, run the following from the root of the mozilla-central repository
(or cd into browser/components/newtab to omit the `--prefix` in any of these commands):
.. code-block:: shell
npm install --prefix browser/components/newtab
Which files should you edit?
````````````````````````````
You should not make changes to `.js` or `.css` files in `browser/components/newtab/css` or
`browser/components/newtab/data` directory. Instead, you should edit the `.jsx`, `.js`, and `.sass` files
in `browser/components/newtab/content-src` directory.
Building assets and running Firefox
```````````````````````````````````
To build assets and run Firefox, run the following from the root of the mozilla-central repository:
.. code-block:: shell
npm run bundle --prefix browser/components/newtab && ./mach run
Running tests
`````````````
The majority of New Tab / Messaging unit tests are written using
`mocha `_, and other errors that may show up there are
`SCSS `_ issues flagged by
`sasslint `_. These things
are all run using `npm test` under the `newtab` slug in Treeherder/Try, so if
that slug turns red, these tests are what is failing. To execute them, do this:
.. code-block:: shell
npm test --prefix browser/components/newtab
These tests are not currently run by `mach test`, but there's a
`task filed to fix that `_.
Mochitests and xpcshell tests run normally, using `mach test`.
GitHub workflow
---------------
The files in this directory, including vendor dependencies, are synchronized with the https://github.com/mozilla/activity-stream repository. If you prefer a GitHub-based workflow, you can look at the documentation there to learn more.
.. _Node.js homepage: https://nodejs.org/
================================================
FILE: docs/v2-system-addon/1.GETTING_STARTED.md
================================================
# Activity Stream Development Guide
## Contents of this guide
- Installation, set-up, and other basics (this page)
- [Writing unit tests](./unit_testing_guide.md)
- [Adding new Telemetry (user and performance metrics)](./telemetry.md)
- [Reading/changing Firefox prefs](./preferences.md)
- [Adding new sections](./sections.md)
- [Changing geo and locale](./geo_locale.md)
## How to try Activity Stream
If you just want to try out the current version of Activity Stream in Firefox, you can
install [Firefox Nightly](https://www.mozilla.org/en-US/firefox/channel/desktop/#nightly)
or any version of Firefox >= 57.0. If you still don't see activity stream, go to `about:config`,
and make sure the `browser.newtabpage.activity-stream.enabled` pref is set to `true`.
## Source code and submitting pull requests
A copy of the code in the root directory of this repository
is exported to Mozilla central on a regular basis, which can be found at [browser/components/newtab](https://searchfox.org/mozilla-central/source/browser/components/newtab).
Keep in mind that some of these files are generated, so if you intend on editing any files, you should
do so in the Github version.
Pull requests should be sent against the master branch of https://github.com/mozilla/activity-stream,
NOT against Mozilla central.
## Prerequisites for development
### Operating system and software
The Activity Stream development environment is designed to work on Mac and Linux.
If you need to develop on Windows, you might want to reach out on IRC (#activity-stream)
if you run into any problems.
You will also need to install:
- Node.js version 8 (To install this legacy version of Node, [use this URL](https://nodejs.org/en/download/releases/))
- npm (packaged with Node.js)
### Activity Stream Github repository
You will need to to clone Activity Stream to a local directory from the `master`
branch of our Github repository: https://github.com/mozilla/activity-stream
```
git clone https://github.com/mozilla/activity-stream.git
```
Also be sure to install the hooks for this repository so that (at least)
eslint and prettier fixing happens at commit time.
```bash
cd activity-stream
./bin/bootstrap
```
### Mozilla Central
You will need a local copy of Mozilla Central in a directory named `mozilla-central`. Check the detail of how to get and build Mozilla Central in [Building Firefox](https://developer.mozilla.org/en-US/docs/Mozilla/Developer_guide/Build_Instructions/Simple_Firefox_build).
That directory should be a sibling of your local `activity-stream` directory (like so):
```
/
mozilla-central/
activity-stream/
```
Check out [these docs on artifact builds](https://developer.mozilla.org/en-US/docs/Mozilla/Developer_guide/Build_Instructions/Artifact_builds)
for instructions about how to download and configure Mozilla Central if you have
never done so before.
**To build Firefox way faster**, you should definitely enable Artifact builds.
To do that, create a `.mozconfig` (or `mozconfig`) file in the root of your
mozilla-central directory and add the following to it:
```bash
# Automatically download and use compiled C++ components:
ac_add_options --enable-artifact-builds
# Write build artifacts to:
mk_add_options MOZ_OBJDIR=./objdir-frontend
```
## Building
1. Install required dependencies by running `npm install`.
2. To build Activity Stream, run `npm run buildmc` from the root of the
`activity-stream` directory. This will build the js and css files and copy them
into the `browser/components/newtab` directory inside Mozilla Central.
3. Build and run Firefox from the `mozilla-central` directory by running `./mach build && ./mach run`.
## Continuous development / debugging
Running `npm run startmc` will start a process that watches files in `activity-stream`
and continuously builds/copies changes to `mozilla-central`. You will
still need to rebuild Firefox (`./mach build`) if you change `.jsm` files.
**IMPORTANT NOTE**: This task will add inline source maps to help with debugging, which changes the memory footprint.
Do not use the `startmc` task for profiling or performance testing!
## Unit Tests
Run `npm run testmc` to run the unit tests with karma/mocha. The source code for these
tests can be found in `system-addon/test/unit/`.
We have a [detailed write-up](unit_testing_guide.md) on
Activity Stream unit testing. This is an important read, as there are **significant** JavaScript differences when
writing Firefox add-on code that must be taken into consideration.
Our build process will run unit tests and code coverage tools automatically. Make that all tests pass,
and that you are not responsible for unduly decreasing the overall code coverage percentage.
If you see any missing test coverage, you can inspect the coverage report by running `npm run testmc && npm run debugcoverage`.
================================================
FILE: docs/v2-system-addon/data_dictionary.md
================================================
# Activity Stream Pings
The Activity Stream system add-on sends various types of pings to the backend (HTTPS POST) [Onyx server](https://github.com/mozilla/onyx) :
- a `health` ping that reports whether or not a user has a custom about:home or about:newtab page
- a `session` ping that describes the ending of an Activity Stream session (a new tab is closed or refreshed), and
- an `event` ping that records specific data about individual user interactions while interacting with Activity Stream
- a `performance` ping that records specific performance related events
- an `undesired` ping that records data about bad app states and missing data
- an `impression_stats` ping that records data about Pocket impressions and user interactions
Schema definitions/validations that can be used for tests can be found in `system-addon/test/schemas/pings.js`.
## Example Activity Stream `health` log
```js
{
"addon_version": "20180710100040",
"client_id": "374dc4d8-0cb2-4ac5-a3cf-c5a9bc3c602e",
"locale": "en-US",
"version": "62.0a1",
"release_channel": "nightly",
"event": "AS_ENABLED",
"value": 10,
// These fields are generated on the server
"date": "2016-03-07",
"ip": "10.192.171.13",
"ua": "python-requests/2.9.1",
"receive_at": 1457396660000
}
```
## Example Activity Stream `session` Log
```js
{
// These fields are sent from the client
"action": "activity_stream_session",
"addon_version": "20180710100040",
"client_id": "374dc4d8-0cb2-4ac5-a3cf-c5a9bc3c602e",
"locale": "en-US",
"page": ["about:newtab" | "about:home" | "about:welcome" | "unknown"],
"session_duration": 1635,
"session_id": "{12dasd-213asda-213dkakj}",
"region": "US",
"profile_creation_date": 14786,
"user_prefs": 7
// These fields are generated on the server
"date": "2016-03-07",
"ip": "10.192.171.13",
"ua": "python-requests/2.9.1",
"receive_at": 1457396660000
}
```
## Example Activity Stream `user_event` Log
```js
{
"action": "activity_stream_user_event",
"action_position": "3",
"addon_version": "20180710100040",
"client_id": "374dc4d8-0cb2-4ac5-a3cf-c5a9bc3c602e",
"event": "click or scroll or search or delete",
"locale": "en-US",
"page": ["about:newtab" | "about:home" | "about:welcome" | "unknown"],
"source": "top sites, or bookmarks, or...",
"session_id": "{12dasd-213asda-213dkakj}",
"recommender_type": "pocket-trending",
"metadata_source": "MetadataService or Local or TippyTopProvider",
"user_prefs": 7
// These fields are generated on the server
"ip": "10.192.171.13",
"ua": "python-requests/2.9.1",
"receive_at": 1457396660000,
"date": "2016-03-07",
}
```
## Example Activity Stream `performance` Log
```js
{
"action": "activity_stream_performance_event",
"addon_version": "20180710100040",
"client_id": "374dc4d8-0cb2-4ac5-a3cf-c5a9bc3c602e",
"event": "previewCacheHit",
"event_id": "45f1912165ca4dfdb5c1c2337dbdc58f",
"locale": "en-US",
"page": "unknown", // all session-specific perf events should be part of the session perf object
"receive_at": 1457396660000,
"source": "TOP_FRECENT_SITES",
"value": 1,
"user_prefs": 7,
// These fields are generated on the server
"ip": "10.192.171.13",
"ua": "python-requests/2.9.1",
"receive_at": 1457396660000,
"date": "2016-03-07"
}
```
## Example Activity Stream `undesired event` Log
```js
{
"action": "activity_stream_undesired_event",
"addon_version": "20180710100040",
"client_id": "26288a14-5cc4-d14f-ae0a-bb01ef45be9c",
"event": "MISSING_IMAGE",
"locale": "en-US",
"page": ["about:newtab" | "about:home" | "about:welcome" | "unknown"]
"source": "HIGHLIGHTS",
"value": 0,
"user_prefs": 7,
// These fields are generated on the server
"ip": "10.192.171.13",
"ua": "python-requests/2.9.1",
"receive_at": 1457396660000,
"date": "2016-03-07"
}
```
## Example Activity Stream `impression_stats` Logs
```js
{
"action": "activity_stream_impression_stats",
"client_id": "n/a",
"session_id": "n/a",
"impression_id": "{005deed0-e3e4-4c02-a041-17405fd703f6}",
"addon_version": "20180710100040",
"locale": "en-US",
"source": "pocket",
"page": ["about:newtab" | "about:home" | "about:welcome" | "unknown"]
"tiles": [{"id": 10000}, {"id": 10001}, {"id": 10002}]
"user_prefs": 7
}
```
```js
{
"action": "activity_stream_impression_stats",
"client_id": "n/a",
"session_id": "n/a",
"impression_id": "{005deed0-e3e4-4c02-a041-17405fd703f6}",
"addon_version": "20180710100040",
"locale": "en-US",
"source": "pocket",
"page": "unknown",
"user_prefs": 7,
// "pos" is the 0-based index to record the tile's position in the Pocket section.
// "shim" is a base64 encoded shim attached to spocs, unique to the impression from the Ad server.
"tiles": [{"id": 10000, "pos": 0, "shim": "enuYa1j73z3RzxgTexHNxYPC/b,9JT6w5KB0CRKYEU+"}],
// A 0-based index to record which tile in the "tiles" list that the user just interacted with.
"click|block|pocket": 0
}
```
## Example Discovery Stream `SPOCS Fill` log
```js
{
// both "client_id" and "session_id" are set to "n/a" in this ping.
"client_id": "n/a",
"session_id": "n/a",
"impression_id": "{005deed0-e3e4-4c02-a041-17405fd703f6}",
"addon_version": "20180710100040",
"locale": "en-US",
"version": "68",
"release_channel": "release",
"spoc_fills": [
{"id": 10000, displayed: 0, reason: "frequency_cap", full_recalc: 1},
{"id": 10001, displayed: 0, reason: "blocked_by_user", full_recalc: 1},
{"id": 10002, displayed: 0, reason: "below_min_score", full_recalc: 1},
{"id": 10003, displayed: 0, reason: "flight_duplicate", full_recalc: 1},
{"id": 10004, displayed: 0, reason: "probability_selection", full_recalc: 0},
{"id": 10004, displayed: 0, reason: "out_of_position", full_recalc: 0},
{"id": 10005, displayed: 1, reason: "n/a", full_recalc: 0}
]
}
```
## Example Activity Stream `Router` Pings
```js
{
"client_id": "n/a",
"action": ["snippets_user_event" | "onboarding_user_event"],
"impression_id": "{005deed0-e3e4-4c02-a041-17405fd703f6}",
"source": "pocket",
"addon_version": "20180710100040",
"locale": "en-US",
"source": "NEWTAB_FOOTER_BAR",
"message_id": "some_snippet_id",
"event": "IMPRESSION",
"event_context": "{\"view\":\"application_menu\"}"
}
```
| KEY | DESCRIPTION | |
|-----|-------------|:-----:|
| `action_position` | [Optional] The index of the element in the `source` that was clicked. | :one:
| `action` | [Required] Either `activity_stream_event`, `activity_stream_session`, or `activity_stream_performance`. | :one:
| `addon_version` | [Required] Firefox build ID, i.e. `Services.appinfo.appBuildID`. | :one:
| `client_id` | [Required] An identifier for this client. | :one:
| `card_type` | [Optional] ("bookmark", "pocket", "trending", "pinned", "search", "spoc", "organic") | :one:
| `search_vendor` | [Optional] the vendor of the search shortcut, one of ("google", "amazon", "wikipedia", "duckduckgo", "bing", etc.). This field only exists when `card_type = "search"` | :one:
| `date` | [Auto populated by Onyx] The date in YYYY-MM-DD format. | :three:
| `experiment_id` | [Optional] The unique identifier for a specific experiment. | :one:
| `event_id` | [Required] An identifier shared by multiple performance pings that describe an entire request flow. | :one:
| `event` | [Required] The type of event. Any user defined string ("click", "share", "delete", "more_items") | :one:
| `event_context` | [Optional] A string to record the context of an AS Router event ping. Compound context values will be stringified by JSON.stringify| :one:
| `highlight_type` | [Optional] Either ["bookmarks", "recommendation", "history"]. | :one:
| `impression_id` | [Optional] The unique impression identifier for a specific client. | :one:
| `ip` | [Auto populated by Onyx] The IP address of the client. | :two:
| `locale` | [Auto populated by Onyx] The browser chrome's language (eg. en-US). | :two:
| `load_trigger_ts` | [Optional][Server Counter][Server Alert for too many omissions] DOMHighResTimeStamp of the action perceived by the user to trigger the load of this page. | :one:
| `load_trigger_type` | [Server Counter][Server Alert for too many omissions] Either ["first_window_opened", "menu_plus_or_keyboard", "unexpected"]. | :one:
| `metadata_source` | [Optional] The source of which we computed metadata. Either (`MetadataService` or `Local` or `TippyTopProvider`). | :one:
| `page` | [Required] One of ["about:newtab", "about:home", "about:welcome", "unknown" (which either means not-applicable or is a bug)]. | :one:
| `recommender_type` | [Optional] The type of recommendation that is being shown, if any. | :one:
| `session_duration` | [Optional][Server Counter][Server Alert for too many omissions] Time in (integer) milliseconds of the difference between the new tab becoming visible
and losing focus. | :one:
| `session_id` | [Optional] The unique identifier for a specific session. | :one:
| `source` | [Required] Either ("recent_links", "recent_bookmarks", "frecent_links", "top_sites", "spotlight", "sidebar") and indicates what `action`. | :two:
| `received_at` | [Auto populated by Onyx] The time in ms since epoch. | :three:
| `total_bookmarks` | [Optional] The total number of bookmarks in the user's places db. | :one:
| `total_history_size` | [Optional] The number of history items currently in the user's places db. | :one:
| `ua` | [Auto populated by Onyx] The user agent string. | :two:
| `unload_reason` | [Required] The reason the Activity Stream page lost focus. | :one:
| `url` | [Optional] The URL of the recommendation shown in one of the highlights spots, if any. | :one:
| `value` (performance) | [Required] An integer that represents the measured performance value. Can store counts, times in milliseconds, and should always be a positive integer.| :one:
| `value` (event) | [Optional] An object with keys "icon_type" and "card_type" to record the extra information for event ping| :one:
| `ver` | [Auto populated by Onyx] The version of the Onyx API the ping was sent to. | :one:
| `highlights_size` | [Optional] The size of the Highlights set. | :one:
| `highlights_data_late_by_ms` | [Optional] Time in ms it took for Highlights to become initialized | :one:
| `topsites_data_late_by_ms` | [Optional] Time in ms it took for TopSites to become initialized | :one:
| `topstories.domain.affinity.calculation.ms` | [Optional] Time in ms it took for domain affinities to be calculated | :one:
| `topsites_first_painted_ts` | [Optional][Service Counter][Server Alert for too many omissions] Timestamp of when the Top Sites element finished painting (possibly with only placeholder screenshots) | :one:
| `custom_screenshot` | [Optional] Number of topsites that display a custom screenshot. | :one:
| `screenshot_with_icon` | [Optional] Number of topsites that display a screenshot and a favicon. | :one:
| `screenshot` | [Optional] Number of topsites that display only a screenshot. | :one:
| `tippytop` | [Optional] Number of topsites that display a tippytop icon. | :one:
| `rich_icon` | [Optional] Number of topsites that display a high quality favicon. | :one:
| `no_image` | [Optional] Number of topsites that have no screenshot. | :one:
| `topsites_pinned` | [Optional] Number of topsites that are pinned. | :one:
| `topsites_search_shortcuts` | [Optional] Number of search shortcut topsites. | :one:
| `visibility_event_rcvd_ts` | [Optional][Server Counter][Server Alert for too many omissions] DOMHighResTimeStamp of when the page itself receives an event that document.visibilityState == visible. | :one:
| `tiles` | [Required] A list of tile objects for the Pocket articles. Each tile object mush have a ID, optionally a "pos" property to indicate the tile position, and optionally a "shim" property unique to the impression from the Ad server | :one:
| `click` | [Optional] An integer to record the 0-based index when user clicks on a Pocket tile. | :one:
| `block` | [Optional] An integer to record the 0-based index when user blocks a Pocket tile. | :one:
| `pocket` | [Optional] An integer to record the 0-based index when user saves a Pocket tile to Pocket. | :one:
| `user_prefs` | [Required] The encoded integer of user's preferences. | :one: & :four:
| `is_preloaded` | [Required] A boolean to signify whether the page is preloaded or not | :one:
| `icon_type` | [Optional] ("tippytop", "rich_icon", "screenshot_with_icon", "screenshot", "no_image", "custom_screenshot") | :one:
| `region` | [Optional] A string maps to pref "browser.search.region", which is essentially the two letter ISO 3166-1 country code populated by the Firefox search service. Note that: 1). it reports "OTHER" for those regions with smaller Firefox user base (less than 10000) so that users cannot be uniquely identified; 2). it reports "UNSET" if this pref is missing; 3). it reports "EMPTY" if the value of this pref is an empty string. | :one:
| `profile_creation_date` | [Optional] An integer to record the age of the Firefox profile as the total number of days since the UNIX epoch. | :one:
| `message_id` | [required] A string identifier of the message in Activity Stream Router. | :one:
| `has_flow_params` | [required] One of [true, false]. A boolean identifier that indicates if Firefox Accounts flow parameters are set or unset. | :one:
| `displayed` | [required] 1: a SPOC is displayed; 0: non-displayed | :one:
| `reason` | [required] The reason if a SPOC is not displayed, "n/a" for the displayed, one of ("frequency_cap", "blocked_by_user", "flight_duplicate", "probability_selection", "below_min_score", "out_of_position", "n/a") | :one:
| `full_recalc` | [required] Is it a full SPOCS recalculation: 0: false; 1: true. Recalculation case: 1). fetch SPOCS from Pocket endpoint. Non-recalculation cases: 1). An impression updates the SPOCS; 2). Any action that triggers the `selectLayoutRender ` | :one:
**Where:**
:one: Firefox data
:two: HTTP protocol data
:three: server augmented data
:four: User preferences encoding table
Note: the following session-related fields are not yet implemented in the system-addon,
but will likely be added in future versions:
```js
{
"total_bookmarks": 19,
"total_history_size": 9,
"highlights_size": 20
}
```
## Encoding and decoding of `user_prefs`
This encoding mapping was defined in `system-addon/lib/TelemetryFeed.jsm`
| Preference | Encoded value (binary) |
| --- | ---: |
| `showSearch` | 1 (00000001) |
| `showTopSites` | 2 (00000010) |
| `showTopStories` | 4 (00000100) |
| `showHighlights` | 8 (00001000) |
| `showSnippets` | 16 (00010000) |
| `showSponsored` | 32 (00100000) |
| `showCFRAddons` | 64 (01000000) |
| `showCFRFeatures` | 128 (10000000) |
Each item above could be combined with other items through bitwise OR (`|`) operation.
Examples:
* Everything is on, `user_prefs = 1 | 2 | 4 | 8 | 16 | 32 | 64 | 128 = 255`
* Everything is off, `user_prefs = 0`
* Only show search and Top Stories, `user_prefs = 1 | 4 = 5`
* Everything except Highlights, `user_prefs = 1 | 2 | 4 | 16 | 32 | 64 | 128 = 247`
Likewise, one can use bitwise AND (`&`) for decoding.
* Check if everything is shown, `user_prefs & (1 | 2 | 4 | 8 | 16 | 32 | 64 | 128)` or `user_prefs == 255`
* Check if everything is off, `user_prefs == 0`
* Check if search is shown, `user_prefs & 1`
* Check if both Top Sites and Top Stories are shown, `(user_prefs & 2) && (user_prefs & 4)`, or `(user_prefs & (2 | 4)) == (2 | 4)`
================================================
FILE: docs/v2-system-addon/data_events.md
================================================
# Metrics we collect
By default, the about:newtab, about:welcome and about:home pages in Firefox (the pages you see when you open a new tab and when you start the browser), will send data back to Mozilla servers about usage of these pages. The intent is to collect data in order to improve the user's experience while using Activity Stream. Data about your specific browsing behaior or the sites you visit is **never transmitted to any Mozilla server**. At any time, it is easy to **turn off** this data collection by [opting out of Firefox telemetry](https://support.mozilla.org/kb/share-telemetry-data-mozilla-help-improve-firefox).
Data is sent to our servers in the form of discreet HTTPS 'pings' or messages whenever you do some action on the Activity Stream about:home, about:newtab or about:welcome pages. We try to minimize the amount and frequency of pings by batching them together. Pings are sent in [JSON serialized format](http://www.json.org/).
At Mozilla, [we take your privacy very seriously](https://www.mozilla.org/privacy/). The Activity Stream page will never send any data that could personally identify you. We do not transmit what you are browsing, searches you perform or any private settings. Activity Stream does not set or send cookies, and uses [Transport Layer Security](https://en.wikipedia.org/wiki/Transport_Layer_Security) to securely transmit data to Mozilla servers.
Data collected from Activity Stream is retained on Mozilla secured servers for a period of 30 days before being rolled up into an anonymous aggregated format. After this period the raw data is deleted permanently. Mozilla **never shares data with any third party**.
The following is a detailed overview of the different kinds of data we collect in the Activity Stream. See [data_dictionary.md](data_dictionary.md) for more details for each field.
## Health ping
This is a heartbeat ping indicating whether Activity Stream is currently being used or not, it's submitted once upon the browser initialization.
```js
{
"client_id": "374dc4d8-0cb2-4ac5-a3cf-c5a9bc3c602e",
"locale": "en-US",
"version": "62.0a1",
"release_channel": "nightly",
"event": "AS_ENABLED",
"value": 10
}
```
where the "value" is encoded as:
* Value 0: default
* Value 1: about:blank
* Value 2: web extension
* Value 3: other custom URL(s)
Two encoded integers for about:newtab and about:home are combined in a bitwise fashion. For instance, if both about:home and about:newtab were set to about:blank, then `value = 5 = (1 | (1 << 2))`, i.e `value = (bitfield of about:newtab) | (bitfield of about:newhome << 2)`.
## Page takeover ping
This ping is submitted once upon Activity Stream initialization if either about:home or about:newtab are set to a custom URL. It sends the category of the custom URL. It also includes the web extension id of the extension controlling the home and/or newtab page.
```js
{
"event": "PAGE_TAKEOVER_DATA",
"value": {
"home_url_category": ["search-engine" | "search-engine-mozilla-tag" | "search-engine-other-tag" | "news-portal" | "ecommerce" | "social-media" | "known-hijacker" | "other"],
"home_extension_id": "26288a14-5cc4-d14f-ae0a-bb01ef45be9c",
"newtab_url_category": ["search-engine" | "search-engine-mozilla-tag" | "search-engine-other-tag" | "news-portal" | "ecommerce" | "social-media" | "known-hijacker" | "other"],
"newtab_extension_id": "26288a14-5cc4-d14f-ae0a-bb01ef45be9c",
},
// Basic metadata
"action": "activity_stream_event",
"client_id": "26288a14-5cc4-d14f-ae0a-bb01ef45be9c",
"session_id": "005deed0-e3e4-4c02-a041-17405fd703f6",
"addon_version": "20180710100040",
"locale": "en-US",
"user_prefs": 7
}
```
## User event pings
These pings are captured when a user **performs some kind of interaction** in the add-on.
### Basic shape
A user event ping includes some basic metadata (tab id, addon version, etc.) as well as variable fields which indicate the location and action of the event.
```js
{
// This indicates the type of interaction
"event": ["CLICK", "SEARCH", "BLOCK", "DELETE", "OPEN_NEW_WINDOW", "OPEN_PRIVATE_WINDOW", "BOOKMARK_DELETE", "BOOKMARK_ADD", "OPEN_NEWTAB_PREFS", "CLOSE_NEWTAB_PREFS", "SEARCH_HANDOFF"],
// Optional field indicating the UI component type
"source": "TOP_SITES",
// Optional field if there is more than one of a component type on a page.
// It is zero-indexed.
// For example, clicking the second Highlight would result in an action_position of 1
"action_position": 1,
// Basic metadata
"page": ["about:newtab" | "about:home" | "about:welcome" | "unknown" ],
"client_id": "26288a14-5cc4-d14f-ae0a-bb01ef45be9c",
"session_id": "005deed0-e3e4-4c02-a041-17405fd703f6",
"addon_version": "20180710100040",
"locale": "en-US",
"action": "activity_stream_event",
"user_prefs": 7
}
```
### Types of user interactions
#### Performing a search
```js
{
"event": "SEARCH",
// Basic metadata
"action": "activity_stream_event",
"page": ["about:newtab" | "about:home" | "about:welcome" | "unknown"],
"client_id": "26288a14-5cc4-d14f-ae0a-bb01ef45be9c",
"session_id": "005deed0-e3e4-4c02-a041-17405fd703f6",
"addon_version": "20180710100040",
"locale": "en-US",
"user_prefs": 7
}
```
#### Performing a search handoff
```js
{
"event": "SEARCH_HANDOFF",
// Basic metadata
"action": "activity_stream_event",
"page": ["about:newtab" | "about:home" | "about:welcome" | "unknown"],
"client_id": "26288a14-5cc4-d14f-ae0a-bb01ef45be9c",
"session_id": "005deed0-e3e4-4c02-a041-17405fd703f6",
"addon_version": "20180710100040",
"locale": "en-US",
"user_prefs": 7
}
```
#### Clicking a top site item
```js
{
"event": "CLICK",
"source": "TOP_SITES",
"action_position": 2,
"value": {
"card_type": ["pinned" | "search" | "spoc"],
"icon_type": ["screenshot_with_icon" | "screenshot" | "tippytop" | "rich_icon" | "no_image" | "custom_screenshot"],
// only exists if its card_type = "search"
"search_vendor": "google"
}
// Basic metadata
"action": "activity_stream_event",
"page": ["about:newtab" | "about:home" | "about:welcome" | "unknown"],
"client_id": "26288a14-5cc4-d14f-ae0a-bb01ef45be9c",
"session_id": "005deed0-e3e4-4c02-a041-17405fd703f6",
"addon_version": "20180710100040",
"locale": "en-US",
"user_prefs": 7
}
```
#### Clicking a top story item
```js
{
"event": "CLICK",
"source": "CARDGRID",
"action_position": 2,
"value": {
// "spoc" for sponsored stories, "organic" for regular stories.
"card_type": ["organic" | "spoc"],
}
// Basic metadata
"action": "activity_stream_event",
"page": ["about:newtab" | "about:home" | "about:welcome" | "unknown"],
"client_id": "26288a14-5cc4-d14f-ae0a-bb01ef45be9c",
"session_id": "005deed0-e3e4-4c02-a041-17405fd703f6",
"addon_version": "20180710100040",
"locale": "en-US",
"user_prefs": 7
}
```
#### Adding a search shortcut
```js
{
"event": "SEARCH_EDIT_ADD",
"source": "TOP_SITES",
"action_position": 2,
"value": {
"search_vendor": "google"
}
// Basic metadata
"action": "activity_stream_event",
"page": ["about:newtab" | "about:home" | "about:welcome" | "unknown"],
"client_id": "26288a14-5cc4-d14f-ae0a-bb01ef45be9c",
"session_id": "005deed0-e3e4-4c02-a041-17405fd703f6",
"addon_version": "20180710100040",
"locale": "en-US",
"user_prefs": 7
}
```
#### Showing privacy information
```js
{
"event": "SHOW_PRIVACY_INFO",
"source": "TOP_SITES",
"action_position": 2,
// Basic metadata
"action": "activity_stream_event",
"page": ["about:newtab" | "about:home" | "about:welcome" | "unknown"],
"client_id": "26288a14-5cc4-d14f-ae0a-bb01ef45be9c",
"session_id": "005deed0-e3e4-4c02-a041-17405fd703f6",
"addon_version": "20180710100040",
"locale": "en-US",
"user_prefs": 7
}
```
#### Clicking on privacy information link
```js
{
"event": "CLICK_PRIVACY_INFO",
"source": "DS_PRIVACY_MODAL",
// Basic metadata
"action": "activity_stream_event",
"page": ["about:newtab" | "about:home" | "about:welcome" | "unknown"],
"client_id": "26288a14-5cc4-d14f-ae0a-bb01ef45be9c",
"session_id": "005deed0-e3e4-4c02-a041-17405fd703f6",
"addon_version": "20180710100040",
"locale": "en-US",
"user_prefs": 7
}
```
#### Deleting a search shortcut
```js
{
"event": "SEARCH_EDIT_DELETE",
"source": "TOP_SITES",
"action_position": 2,
"value": {
"search_vendor": "google"
}
// Basic metadata
"action": "activity_stream_event",
"page": ["about:newtab" | "about:home" | "about:welcome" | "unknown"],
"client_id": "26288a14-5cc4-d14f-ae0a-bb01ef45be9c",
"session_id": "005deed0-e3e4-4c02-a041-17405fd703f6",
"addon_version": "20180710100040",
"locale": "en-US",
"user_prefs": 7
}
```
#### Deleting an item from history
```js
{
"event": "DELETE",
"source": "TOP_SITES",
"action_position": 2,
"value": {
"card_type": "pinned",
"icon_type": ["screenshot_with_icon" | "screenshot" | "tippytop" | "rich_icon" | "no_image" | "custom_screenshot"]
}
// Basic metadata
"action": "activity_stream_event",
"page": ["about:newtab" | "about:home" | "about:welcome" | "unknown"],
"client_id": "26288a14-5cc4-d14f-ae0a-bb01ef45be9c",
"session_id": "005deed0-e3e4-4c02-a041-17405fd703f6",
"addon_version": "20180710100040",
"locale": "en-US",
"user_prefs": 7
}
```
#### Blocking a site
```js
{
"event": "BLOCK",
"source": "TOP_SITES",
"action_position": 2,
"value": {
"card_type": ["pinned" | "search" | "spoc"],
"icon_type": ["screenshot_with_icon" | "screenshot" | "tippytop" | "rich_icon" | "no_image" | "custom_screenshot"],
// only exists if its card_type = "search"
"search_vendor": "google"
}
// Basic metadata
"action": "activity_stream_event",
"page": ["about:newtab" | "about:home" | "about:welcome" | "unknown"],
"client_id": "26288a14-5cc4-d14f-ae0a-bb01ef45be9c",
"session_id": "005deed0-e3e4-4c02-a041-17405fd703f6",
"addon_version": "20180710100040",
"locale": "en-US",
"user_prefs": 7
}
```
#### Bookmarking a link
```js
{
"event": "BOOKMARK_ADD",
"source": "HIGHLIGHTS",
"action_position": 2,
"value": {
"card_type": "trending"
}
// Basic metadata
"action": "activity_stream_event",
"page": ["about:newtab" | "about:home" | "about:welcome"],
"client_id": "26288a14-5cc4-d14f-ae0a-bb01ef45be9c",
"session_id": "005deed0-e3e4-4c02-a041-17405fd703f6",
"addon_version": "20180710100040",
"locale": "en-US",
"user_prefs": 7
}
```
#### Removing a bookmark from a link
```js
{
"event": "BOOKMARK_DELETE",
"source": "HIGHLIGHTS",
"action_position": 2,
"value": {
"card_type": "bookmark"
}
// Basic metadata
"action": "activity_stream_event",
"page": ["about:newtab" | "about:home" | "about:welcome" | "unknown"],
"client_id": "26288a14-5cc4-d14f-ae0a-bb01ef45be9c",
"session_id": "005deed0-e3e4-4c02-a041-17405fd703f6",
"addon_version": "20180710100040",
"locale": "en-US",
"user_prefs": 7
}
```
#### Opening a link in a new window
```js
{
"event": "OPEN_NEW_WINDOW",
"source": "TOP_SITES",
"action_position": 2,
"value": {
"card_type": "pinned",
"icon_type": ["screenshot_with_icon" | "screenshot" | "tippytop" | "rich_icon" | "no_image" | "custom_screenshot"]
}
// Basic metadata
"action": "activity_stream_event",
"page": ["about:newtab" | "about:home" | "about:welcome" | "unknown"],
"client_id": "26288a14-5cc4-d14f-ae0a-bb01ef45be9c",
"session_id": "005deed0-e3e4-4c02-a041-17405fd703f6",
"addon_version": "20180710100040",
"locale": "en-US",
"user_prefs": 7
}
```
#### Opening a link in a new private window
```js
{
"event": "OPEN_PRIVATE_WINDOW",
"source": "TOP_SITES",
"action_position": 2,
"value": {
"card_type": "pinned",
"icon_type": ["screenshot_with_icon" | "screenshot" | "tippytop" | "rich_icon" | "no_image" | "custom_screenshot"]
}
// Basic metadata
"action": "activity_stream_event",
"page": ["about:newtab" | "about:home" | "about:welcome" | "unknown"],
"client_id": "26288a14-5cc4-d14f-ae0a-bb01ef45be9c",
"session_id": "005deed0-e3e4-4c02-a041-17405fd703f6",
"addon_version": "20180710100040",
"locale": "en-US",
"user_prefs": 7
}
```
#### Opening the new tab preferences pane
```js
{
"event": "OPEN_NEWTAB_PREFS",
// Basic metadata
"action": "activity_stream_event",
"page": ["about:newtab" | "about:home" | "about:welcome" | "unknown"],
"client_id": "26288a14-5cc4-d14f-ae0a-bb01ef45be9c",
"session_id": "005deed0-e3e4-4c02-a041-17405fd703f6",
"addon_version": "20180710100040",
"locale": "en-US",
"user_prefs": 7
}
```
#### Closing the new tab preferences pane
```js
{
"event": "CLOSE_NEWTAB_PREFS",
// Basic metadata
"action": "activity_stream_event",
"page": ["about:newtab" | "about:home" | "about:welcome" | "unknown"],
"client_id": "26288a14-5cc4-d14f-ae0a-bb01ef45be9c",
"session_id": "005deed0-e3e4-4c02-a041-17405fd703f6",
"addon_version": "20180710100040",
"locale": "en-US",
"user_prefs": 7
}
```
#### Pinning a tab
```js
{
"event": "TABPINNED",
"source": "TAB_CONTEXT_MENU",
"value": "{\"total_pinned_tabs\":2}",
// Basic metadata
"action": "activity_stream_user_event",
"client_id": "aabaace5-35f4-7345-a28e-5502147dc93c",
"version": "67.0a1",
"addon_version": "20190218094427",
"locale": "en-US",
"user_prefs": 59,
"page": "n/a",
"session_id": "n/a",
}
```
#### Adding or editing a new TopSite
```js
{
"event": "TOP_SITES_EDIT",
"source": "TOP_SITES_SOURCE",
// "-1" Is used for prepending a new TopSite at the front of the list, while
// any other possible value is used for editing an existing TopSite slot.
"action_position": [-1 | "0..TOP_SITES_LENGTH"]
}
```
#### Requesting a custom screenshot preview
```js
{
"event": "PREVIEW_REQUEST",
"source": "TOP_SITES"
}
```
### Onboarding user events on about:welcome
#### Form Submit Events
```js
{
"event": ["SUBMIT_EMAIL" | "SUBMIT_SIGNIN" | "SKIPPED_SIGNIN"],
"value": {
"has_flow_params": false,
}
// Basic metadata
"action": "activity_stream_event",
"page": "about:welcome",
"client_id": "26288a14-5cc4-d14f-ae0a-bb01ef45be9c",
"session_id": "005deed0-e3e4-4c02-a041-17405fd703f6",
"addon_version": "20180710100040",
"locale": "en-US",
"user_prefs": 7
}
```
#### Firefox Accounts Metrics flow errors
```js
{
"event": ["FXA_METRICS_FETCH_ERROR" | "FXA_METRICS_ERROR"],
"value": 500, // Only FXA_METRICS_FETCH_ERROR provides this value, this value is any valid HTTP status code except 200.
// Basic metadata
"action": "activity_stream_event",
"page": "about:welcome",
"client_id": "26288a14-5cc4-d14f-ae0a-bb01ef45be9c",
"session_id": "005deed0-e3e4-4c02-a041-17405fd703f6",
"addon_version": "20180710100040",
"locale": "en-US",
"user_prefs": 7
}
```
## Session end pings
When a session ends, the browser will send a `"activity_stream_session"` ping to our metrics servers. This ping contains the length of the session, a unique reason for why the session ended, and some additional metadata.
### Basic event
All `"activity_stream_session"` pings have the following basic shape. Some fields are variable.
```js
{
"action": "activity_stream_session",
"client_id": "26288a14-5cc4-d14f-ae0a-bb01ef45be9c",
"session_id": "005deed0-e3e4-4c02-a041-17405fd703f6",
"addon_version": "20180710100040",
"locale": "en-US",
"page": ["about:newtab" | "about:home" | "about:welcome" | "unknown"],
"session_duration": 4199,
"region": "US",
"profile_creation_date": 14786,
"user_prefs": 7
}
```
### What causes a session end?
Here are different scenarios that cause a session end event to be sent:
1. After a search
2. Clicking on something that causes navigation (top site, highlight, etc.)
3. Closing the browser
5. Refreshing
6. Navigating to a new URL via the url bar or file menu
### Session performance data
This data is held in a child object of the `activity_stream_session` event called `perf`. All fields suffixed by `_ts` are type `DOMHighResTimeStamp` (aka a double of milliseconds, with a 5 microsecond precision) with 0 being the [timeOrigin](https://developer.mozilla.org/en-US/docs/Web/API/DOMHighResTimeStamp#The_time_origin) of the browser's hidden chrome window.
An example might look like this:
```javascript
perf: {
// Timestamp of the action perceived by the user to trigger the load
// of this page.
//
// Not required at least for error cases where the
// observer event doesn't fire
"load_trigger_ts": 1,
// What was the perceived trigger of the load action:
"load_trigger_type": [
"first_window_opened" | // home only
"menu_plus_or_keyboard" | // newtab only
"unexpected" // sessions lacking actual start times
],
// when the page itself receives an event that document.visibilityStat=visible
"visibility_event_rcvd_ts": 2,
// When did the topsites element finish painting? Note that, at least for
// the first tab to be loaded, and maybe some others, this will be before
// topsites has yet to receive screenshots updates from the add-on code,
// and is therefore just showing placeholder screenshots.
"topsites_first_painted_ts": 5,
// The 6 different types of TopSites icons and how many of each kind did the
// user see.
"topsites_icon_stats": {
"custom_screenshot": 0,
"screenshot_with_icon": 2,
"screenshot": 1,
"tippytop": 2,
"rich_icon": 1,
"no_image": 0
},
// The number of Top Sites that are pinned.
"topsites_pinned": 3,
// The number of search shortcut Top Sites.
"topsites_search_shortcuts": 2,
// How much longer the data took, in milliseconds, to be ready for display
// than it would have been in the ideal case. The user currently sees placeholder
// cards instead of real cards for approximately this length of time. This is
// sent when the first call of the component's `render()` method happens with
// `this.props.initialized` set to `false`, and the value is the amount of
// time in ms until `render()` is called with `this.props.initialized` set to `true`.
"highlights_data_late_by_ms": 67,
"topsites_data_late_by_ms": 35,
// Whether the page is preloaded or not.
"is_preloaded": [true|false],
}
```
## Top Story pings
When Top Story (currently powered by Pocket) is enabled in Activity Stream, the browser will send following `activity_stream_impression_stats` to our metrics servers.
### Impression stats
This reports all the Pocket recommended articles (a list of `id`s) when the user opens a newtab.
```js
{
"action": "activity_stream_impression_stats",
// both "client_id" and "session_id" are set to "n/a" in this ping.
"client_id": "n/a",
"session_id": "n/a",
"impression_id": "{005deed0-e3e4-4c02-a041-17405fd703f6}",
"addon_version": "20180710100040",
"locale": "en-US",
"source": "pocket",
"page": ["about:newtab" | "about:home" | "about:welcome" | "unknown"],
"user_prefs": 7,
"tiles": [{"id": 10000}, {"id": 10001}, {"id": 10002}]
}
```
### Click/block/save_to_pocket ping
This reports the user's interaction with those Pocket tiles.
```js
{
"action": "activity_stream_impression_stats",
// both "client_id" and "session_id" are set to "n/a" in this ping.
"client_id": "n/a",
"session_id": "n/a",
"impression_id": "{005deed0-e3e4-4c02-a041-17405fd703f6}",
"addon_version": "20180710100040",
"locale": "en-US",
"source": "pocket",
"page": ["about:newtab" | "about:home" | "about:welcome" | "unknown"],
"user_prefs": 7,
// "pos" is the 0-based index to record the tile's position in the Pocket section.
// "shim" is a base64 encoded shim attached to spocs, unique to the impression from the Ad server.
"tiles": [{"id": 10000, "pos": 0, "shim": "enuYa1j73z3RzxgTexHNxYPC/b,9JT6w5KB0CRKYEU+"}],
// A 0-based index to record which tile in the "tiles" list that the user just interacted with.
"click|block|pocket": 0
}
```
## Performance pings
These pings are captured to record performance related events i.e. how long certain operations take to execute.
### Domain affinity calculation v1
This reports the duration of the domain affinity calculation in milliseconds.
```js
{
"action": "activity_stream_performance_event",
"client_id": "26288a14-5cc4-d14f-ae0a-bb01ef45be9c",
"addon_version": "20180710100040",
"locale": "en-US",
"user_prefs": 7,
"event": "topstories.domain.affinity.calculation.ms",
"value": 43
}
```
### Domain affinity calculation v2
These report the duration of the domain affinity v2 calculations in milliseconds.
#### Total calculation in ms
```js
{
"action": "activity_stream_performance_event",
"client_id": "26288a14-5cc4-d14f-ae0a-bb01ef45be9c",
"addon_version": "20180710100040",
"locale": "en-US",
"user_prefs": 7,
"event": "PERSONALIZATION_V2_TOTAL_DURATION",
"value": 43
}
```
#### getRecipe calculation in ms
```js
{
"action": "activity_stream_performance_event",
"client_id": "26288a14-5cc4-d14f-ae0a-bb01ef45be9c",
"addon_version": "20180710100040",
"locale": "en-US",
"user_prefs": 7,
"event": "PERSONALIZATION_V2_GET_RECIPE_DURATION",
"value": 43
}
```
#### RecipeExecutor calculation in ms
```js
{
"action": "activity_stream_performance_event",
"client_id": "26288a14-5cc4-d14f-ae0a-bb01ef45be9c",
"addon_version": "20180710100040",
"locale": "en-US",
"user_prefs": 7,
"event": "PERSONALIZATION_V2_RECIPE_EXECUTOR_DURATION",
"value": 43
}
```
#### taggers calculation in ms
```js
{
"action": "activity_stream_performance_event",
"client_id": "26288a14-5cc4-d14f-ae0a-bb01ef45be9c",
"addon_version": "20180710100040",
"locale": "en-US",
"user_prefs": 7,
"event": "PERSONALIZATION_V2_TAGGERS_DURATION",
"value": 43
}
```
#### createInterestVector calculation in ms
```js
{
"action": "activity_stream_performance_event",
"client_id": "26288a14-5cc4-d14f-ae0a-bb01ef45be9c",
"addon_version": "20180710100040",
"locale": "en-US",
"user_prefs": 7,
"event": "PERSONALIZATION_V2_CREATE_INTEREST_VECTOR_DURATION",
"value": 43
}
```
#### calculateItemRelevanceScore calculation in ms
```js
{
"action": "activity_stream_performance_event",
"client_id": "26288a14-5cc4-d14f-ae0a-bb01ef45be9c",
"addon_version": "20180710100040",
"locale": "en-US",
"user_prefs": 7,
"event": "PERSONALIZATION_V2_ITEM_RELEVANCE_SCORE_DURATION",
"value": 43
}
```
### History size used for v2 calculation
```js
{
"action": "activity_stream_performance_event",
"client_id": "26288a14-5cc4-d14f-ae0a-bb01ef45be9c",
"addon_version": "20180710100040",
"locale": "en-US",
"user_prefs": 7,
"event": "PERSONALIZATION_V2_HISTORY_SIZE",
"value": 43
}
```
### Error events for v2 calculation
These report any failures during domain affinity v2 calculations, and where it failed.
#### getRecipe error
```js
{
"action": "activity_stream_performance_event",
"client_id": "26288a14-5cc4-d14f-ae0a-bb01ef45be9c",
"addon_version": "20180710100040",
"locale": "en-US",
"user_prefs": 7,
"event": "PERSONALIZATION_V2_GET_RECIPE_ERROR"
}
```
#### generateRecipeExecutor error
```js
{
"action": "activity_stream_performance_event",
"client_id": "26288a14-5cc4-d14f-ae0a-bb01ef45be9c",
"addon_version": "20180710100040",
"locale": "en-US",
"user_prefs": 7,
"event": "PERSONALIZATION_V2_GENERATE_RECIPE_EXECUTOR_ERROR"
}
```
#### createInterestVector error
```js
{
"action": "activity_stream_performance_event",
"client_id": "26288a14-5cc4-d14f-ae0a-bb01ef45be9c",
"addon_version": "20180710100040",
"locale": "en-US",
"user_prefs": 7,
"event": "PERSONALIZATION_V2_CREATE_INTEREST_VECTOR_ERROR"
}
```
### Discovery Stream loaded content
This reports all the loaded content (a list of `id`s and positions) when the user opens a newtab page and the page becomes visible. Note that this ping is a superset of the Discovery Stream impression ping, as impression pings are also subject to the individual visibility.
```js
{
"action": "activity_stream_impression_stats",
// Both "client_id" and "session_id" are set to "n/a" in this ping.
"client_id": "n/a",
"session_id": "n/a",
"impression_id": "{005deed0-e3e4-4c02-a041-17405fd703f6}",
"addon_version": "20180710100040",
"locale": "en-US",
"source": ["HERO" | "CARDGRID" | "LIST"],
"page": ["about:newtab" | "about:home" | "about:welcome" | "unknown"],
"user_prefs": 7,
// Indicating this is a `loaded content` ping (as opposed to impression) as well as the size of `tiles`
"loaded": 3,
"tiles": [{"id": 10000, "pos": 0}, {"id": 10001, "pos": 1}, {"id": 10002, "pos": 2}]
}
```
### Discovery Stream performance pings
#### Request time of layout feed in ms
```js
{
"action": "activity_stream_performance_event",
"client_id": "26288a14-5cc4-d14f-ae0a-bb01ef45be9c",
"addon_version": "20180710100040",
"locale": "en-US",
"user_prefs": 7,
"event": "LAYOUT_REQUEST_TIME",
"value": 42
}
```
#### Request time of SPOCS feed in ms
```js
{
"action": "activity_stream_performance_event",
"client_id": "26288a14-5cc4-d14f-ae0a-bb01ef45be9c",
"addon_version": "20180710100040",
"locale": "en-US",
"user_prefs": 7,
"event": "SPOCS_REQUEST_TIME",
"value": 42
}
```
#### Request time of component feed feed in ms
```js
{
"action": "activity_stream_performance_event",
"client_id": "26288a14-5cc4-d14f-ae0a-bb01ef45be9c",
"addon_version": "20180710100040",
"locale": "en-US",
"user_prefs": 7,
"event": "COMPONENT_FEED_REQUEST_TIME",
"value": 42
}
```
#### Request time of total Discovery Stream feed in ms
```js
{
"action": "activity_stream_performance_event",
"client_id": "26288a14-5cc4-d14f-ae0a-bb01ef45be9c",
"addon_version": "20180710100040",
"locale": "en-US",
"user_prefs": 7,
"event": "DS_FEED_TOTAL_REQUEST_TIME",
"value": 136
}
```
#### Cache age of Discovery Stream feed in second
```js
{
"action": "activity_stream_performance_event",
"client_id": "26288a14-5cc4-d14f-ae0a-bb01ef45be9c",
"addon_version": "20180710100040",
"locale": "en-US",
"user_prefs": 7,
"event": "DS_CACHE_AGE_IN_SEC",
"value": 1800 // 30 minutes
}
```
### Discovery Stream SPOCS Fill ping
This reports the internal status of Pocket SPOCS (Sponsored Contents).
```js
{
// both "client_id" and "session_id" are set to "n/a" in this ping.
"client_id": "n/a",
"session_id": "n/a",
"impression_id": "{005deed0-e3e4-4c02-a041-17405fd703f6}",
"addon_version": "20180710100040",
"locale": "en-US",
"version": "68",
"release_channel": "release",
"spoc_fills": [
{"id": 10000, displayed: 0, reason: "frequency_cap", full_recalc: 1},
{"id": 10001, displayed: 0, reason: "blocked_by_user", full_recalc: 1},
{"id": 10002, displayed: 0, reason: "below_min_score", full_recalc: 1},
{"id": 10003, displayed: 0, reason: "campaign_duplicate", full_recalc: 1},
{"id": 10004, displayed: 0, reason: "probability_selection", full_recalc: 0},
{"id": 10004, displayed: 0, reason: "out_of_position", full_recalc: 0},
{"id": 10005, displayed: 1, reason: "n/a", full_recalc: 0}
]
}
```
## Undesired event pings
These pings record the undesired events happen in the addon for further investigation.
### Addon initialization failure
This reports when the addon fails to initialize
```js
{
"action": "activity_stream_undesired_event",
"client_id": "26288a14-5cc4-d14f-ae0a-bb01ef45be9c",
"addon_version": "20180710100040",
"locale": "en-US",
"user_prefs": 7,
"event": "ADDON_INIT_FAILED",
"value": -1
}
```
## Activity Stream Router pings
These pings record the impression and user interactions within Activity Stream Router.
### Impression ping
This reports the impression of Activity Stream Router.
#### Snippets impression
```js
{
"client_id": "26288a14-5cc4-d14f-ae0a-bb01ef45be9c",
"action": "snippets_user_event",
"impression_id": "n/a",
"source": "SNIPPETS",
"addon_version": "20180710100040",
"locale": "en-US",
"source": "NEWTAB_FOOTER_BAR",
"message_id": "some_snippet_id",
"event": "IMPRESSION"
}
```
CFR impression ping has two forms, in which the message_id could be of different meanings.
#### CFR impression for all the prerelease channels and shield experiment
```js
{
"client_id": "26288a14-5cc4-d14f-ae0a-bb01ef45be9c",
"action": "cfr_user_event",
"impression_id": "n/a",
"addon_version": "20180710100040",
"locale": "en-US",
"source": "CFR",
// message_id could be the ID of the recommendation, such as "wikipedia_addon"
"message_id": "wikipedia_addon",
"event": "IMPRESSION"
}
```
#### CFR impression for the release channel
```js
{
"client_id": "n/a",
"action": "cfr_user_event",
"impression_id": "{005deed0-e3e4-4c02-a041-17405fd703f6}",
"addon_version": "20180710100040",
"locale": "en-US",
"source": "CFR",
// message_id should be a bucket ID in the release channel, we may not use the
// individual ID, such as addon ID, per legal's request
"message_id": "bucket_id",
"event": "IMPRESSION"
}
```
#### Onboarding impression
```js
{
"client_id": "26288a14-5cc4-d14f-ae0a-bb01ef45be9c",
"action": "onboarding_user_event",
"impression_id": "n/a",
"source": "FIRST_RUN",
"addon_version": "20180710100040",
"locale": "en-US",
"message_id": "EXTENDED_TRIPLETS_1",
"event": "IMPRESSION"
}
```
### User interaction pings
This reports the user's interaction with Activity Stream Router.
#### Snippets interaction pings
```js
{
"client_id": "26288a14-5cc4-d14f-ae0a-bb01ef45be9c",
"action": "snippets_user_event",
"addon_version": "20180710100040",
"impression_id": "n/a",
"locale": "en-US",
"source": "NEWTAB_FOOTER_BAR",
"message_id": "some_snippet_id",
"event": ["CLICK_BUTTION" | "BLOCK"]
}
```
#### Onboarding interaction pings
```js
{
"client_id": "26288a14-5cc4-d14f-ae0a-bb01ef45be9c",
"action": "onboarding_user_event",
"addon_version": "20180710100040",
"impression_id": "n/a",
"locale": "en-US",
"source": "ONBOARDING",
"message_id": "onboarding_message_1",
"event": ["IMPRESSION" | "CLICK_BUTTION" | "INSTALL" | "BLOCK"]
}
```
#### CFR interaction pings for all the prerelease channels and shield experiment
```js
{
"client_id": "26288a14-5cc4-d14f-ae0a-bb01ef45be9c",
"action": "cfr_user_event",
"addon_version": "20180710100040",
"impression_id": "n/a",
"locale": "en-US",
"source": "CFR",
// message_id could be the ID of the recommendation, such as "wikipedia_addon"
"message_id": "wikipedia_addon",
"event": "[IMPRESSION | INSTALL | PIN | BLOCK | DISMISS | RATIONALE | LEARN_MORE | CLICK | CLICK_DOORHANGER | MANAGE]",
// "modelVersion" records the model identifier for the CFR machine learning experiment, see more detail in Bug 1594422.
// Non-experiment users will not report this field.
"event_context": "{ \"modelVersion\": \"some_model_version_id\" }"
}
```
#### CFR interaction pings for release channel
```js
{
"client_id": "n/a",
"action": "cfr_user_event",
"addon_version": "20180710100040",
"impression_id": "{005deed0-e3e4-4c02-a041-17405fd703f6}",
"locale": "en-US",
"source": "CFR",
// message_id should be a bucket ID in the release channel, we may not use the
// individual ID, such as addon ID, per legal's request
"message_id": "bucket_id",
"event": "[IMPRESSION | INSTALL | PIN | BLOCK | DISMISS | RATIONALE | LEARN_MORE | CLICK | CLICK_DOORHANGER | MANAGE]"
}
```
### Targeting error pings
This reports when an error has occurred when parsing/evaluating a JEXL targeting string in a message.
```js
{
"client_id": "n/a",
"action": "asrouter_undesired_event",
"addon_version": "20180710100040",
"impression_id": "{005deed0-e3e4-4c02-a041-17405fd703f6}",
"locale": "en-US",
"message_id": "some_message_id",
"event": "TARGETING_EXPRESSION_ERROR",
"event_context": ["MALFORMED_EXPRESSION" | "OTHER_ERROR"]
}
```
### Remote Settings error pings
This reports a failure in the Remote Settings loader to load messages for Activity Stream Router.
```js
{
"action": "asrouter_undesired_event",
"client_id": "n/a",
"addon_version": "20180710100040",
"locale": "en-US",
"user_prefs": 7,
"event": ["ASR_RS_NO_MESSAGES" | "ASR_RS_ERROR"],
// The value is set to the ID of the message provider. For example: remote-cfr, remote-onboarding, etc.
"event_context": "REMOTE_PROVIDER_ID"
}
```
## Trailhead experiment enrollment ping
This reports an enrollment ping when a user gets enrolled in a Trailhead experiment. Note that this ping is only collected through the Mozilla Events telemetry pipeline.
```js
{
"category": "activity_stream",
"method": "enroll",
"object": "preference_study"
"value": "activity-stream-firstup-trailhead-interrupts",
"extra_keys": {
"experimentType": "as-firstrun",
"branch": ["supercharge" | "join" | "sync" | "privacy" ...]
}
}
```
## Feature Callouts interaction pings
This reports when a user has seen or clicked a badge/notification in the browser toolbar in a non-PBM window
```
{
"locale": "en-US",
"client_id": "9da773d8-4356-f54f-b7cf-6134726bcf3d",
"version": "70.0a1",
"release_channel": "default",
"addon_version": "20190712095934",
"action": "cfr_user_event",
"source": "CFR",
"message_id": "FXA_ACCOUNTS_BADGE",
"event": ["CLICK" | "IMPRESSION"],
}
```
## Panel interaction pings
This reports when a user opens the panel, views messages and clicks on a message.
For message impressions we concatenate the ids of all messages in the panel.
```
{
"locale": "en-US",
"client_id": "9da773d8-4356-f54f-b7cf-6134726bcf3d",
"version": "70.0a1",
"release_channel": "default",
"addon_version": "20190712095934",
"action": "cfr_user_event",
"source": "CFR",
"message_id": "WHATS_NEW_70",
"event": ["CLICK" | "IMPRESSION"],
"value": { "view": ["application_menu" | "toolbar_dropdown"] }
}
```
================================================
FILE: docs/v2-system-addon/geo_locale.md
================================================
# Setting custom `geo`, `locale`, and update channels
There are instances where you may need to change your local build's locale, geo, and update channel (such as changes to the visibility of Discovery Stream on a per-geo/locale basis in `ActivityStream.jsm`).
## Changing update channel
- Change `app.update.channel` to desired value (eg: `release`) by editing `LOCAL_BUILD/Contents/Resources/defaults/pref/channel-prefs.js`. (**NOTE:** Changing pref `app.update.channel` from `about:config` seems to have no effect!)
## Changing geo
- Set `browser.search.region` to desired geo (eg `CA`)
## Changing locale
*Note: These prefs are only configurable on a nightly or local build.*
- Toggle `extensions.langpacks.signatures.required` to `false`
- Toggle `xpinstall.signatures.required` to `false`
- Toggle `intl.multilingual.downloadEnabled` to `true`
- Toggle `intl.multilingual.enabled` to `true`
- Open the [langpack](https://archive.mozilla.org/pub/firefox/nightly/latest-mozilla-central-l10n/mac/xpi/) for target locale in your local build (eg `firefox-70.0a1.en-CA.langpack.xpi` if you want an `en-CA` locale)
- In `about:preferences` click "Set Alternatives" under "Language", move desired locale to the top position, click OK, click "Apply And Restart"
================================================
FILE: docs/v2-system-addon/mochitests.md
================================================
We use [mochitests](https://developer.mozilla.org/en-US/docs/Mozilla/Projects/Mochitest) to do functional (and possibly integration) testing. Mochitests are part of Firefox and allow us to test activity stream literally as you would use it.
Mochitests require a local checkout of the Firefox source code. This is because they are used to test a lot of Firefox, and you would usually run them inside Firefox. We are developing activity stream outside of Firefox, but still want to test it as part of Firefox, so we've borrowed the debugger.html infrastructure for using them.
Mochitests live in `system-addon/test/functional/mochitest`, and as of this writing, they are all the [`browser-chrome`](https://developer.mozilla.org/en-US/docs/Mozilla/Browser_chrome_tests) flavor of mochitests. They currently only run against the bootstrapped version of the add-on in system-addon, not the test pilot version at the top level directory.
## Getting Started
**Requirements**
* mercurial ( `brew install mercurial` )
* autoconf213 ( `brew install autoconf@2.13 && brew unlink autoconf` )
If you haven't set up the mochitest environment yet, just run this:
```bash
./bin/prepare-mochitests-dev
```
This will set up everything you need. You should run this *every time* you start working on mochitests, as it makes sure your local copy of Firefox is up-to-date.
On the first run, if you don't already have a mozilla-central repo as a sibling of your activity stream repo, this will download one and set up an [artifact build](https://developer.mozilla.org/en-US/docs/Mozilla/Developer_guide/Build_Instructions/Artifact_builds) (just think of a super fast Firefox build). It may take a while (10-15 minutes) to download and build Firefox.
If you do already have a mozilla-central repo, the script ask you if you're ok with losing any local changes in that repo, and, if so, it will merely update to the latest bits and then export your current activity-stream repo to that
mozilla-central.
Now, you can run the mochitests like this:
```
npm run buildmc
npm run mochitest
```
The reason we use npm to run them is because, as of this writing, both the
add-on and the tests are turned off in the export to mozilla-central, so special arguments are needed to turn them both on.
Visit the [mochitest](https://developer.mozilla.org/en-US/docs/Mozilla/Projects/Mochitest) and [`browser chrome`](https://developer.mozilla.org/en-US/docs/Mozilla/Browser_chrome_tests) MDN pages to learn more about mochitests. A few tips:
* Doing ```npm run mochitest-debug``` will open a JavaScript debugger and allow you to debug the tests (sometimes can be fickle)
### For Windows Developers
*NOT YET TESTED FOR ACTIVITY STREAM*: The detailed instructions for setting up your environment to build Firefox for Windows can be found [here](https://developer.mozilla.org/en-US/docs/Mozilla/Developer_guide/Build_Instructions/Windows_Prerequisites). You need to install the latest `MozBuild` package. You can open a unix-flavor shell by starting:
```
C:\mozilla-build\start-shell.bat
```
In the shell, navigate to the activity-stream project folder, and follow the Getting Started instructions as mentioned.
## Making code changes
The mochitests are running against the compiled activity-stream bundle inside the Firefox checkout. This means that you need to update the bundle whenever you make code changes. `./bin/prepare-mochitests-dev` does this for you initially, but you can manually update it with:
```
npm run buildmc
```
or have it automatically be updated whenever it changes if you leave ```npm run startmc``` running in a shell.
## Adding New Tests
If you add new tests, make sure to list them in the `browser.ini` file. You will see the other tests there. Add a new entry with the same format as the others. You can also add new JS or HTML files by listing in under `support-files`. Make sure to start your test name with "browser_", so that the test suite knows the pick it up. E.g: "browser_as_my_new_test.js".
## Writing Tests
Here are a few tips for writing mochitests:
* Only write mochitests for testing the interaction of multiple components on the page and to make sure that the protocol is working.
* If you need to access the content page, use `ContentTask.spawn`:
```js
ContentTask.spawn(gBrowser.selectedBrowser, null, function* () {
content.wrappedJSObject.foo();
});
```
The above calls the function `foo` that exists in the page itself. You can also access the DOM this way: `content.document.querySelector`, if you want to click a button or do other things. You can even you use assertions inside this callback to check DOM state.
* If you run into problems running tests in e10s, refer to the [wiki](https://wiki.mozilla.org/Electrolysis/e10s_test_tips) for tips
* Nobody likes to see intermittent oranges in their tests, so read the [docs on how to avoid them](https://developer.mozilla.org/en-US/docs/Mozilla/QA/Avoiding_intermittent_oranges)!
================================================
FILE: docs/v2-system-addon/preferences.md
================================================
# Preferences in Activity Stream
## Preference branch
The preference branch for activity stream is `browser.newtabpage.activity-stream.`.
Any preferences defined in the preference configuration will be relative to that
branch. For example, if a preference is defined with the name `foo`, the full
preference as it is displayed in `about:config` will be `browser.newtabpage.activity-stream.foo`.
## Defining new preferences
All preferences for Activity Stream should be defined in the `PREFS_CONFIG` Array
found in [lib/ActivityStream.jsm](../../system-addon/lib/ActivityStream.jsm).
The configuration object should have a `name` (the name of the pref), a `title`
that describes the functionality of the pref, and a `value`, the default value
of the pref. Optionally a `getValue` function can be provided to dynamically
generate a default pref value based on args, e.g., geo and locale. For
developers-specific defaults, an optional `value_local_dev` will be used instead
of `value`. For example:
```js
{
name: "telemetry.log",
title: "Log telemetry events in the console",
value: false,
value_local_dev: true,
getValue: ({geo}) => geo === "CA"
}
```
### IMPORTANT: Setting test-specific values for Mozilla Central
If a feed or feature behind a pref makes any network calls or would other be
disruptive for automated tests and that pref is on by default, make sure you
disable it for tests in Mozilla Central.
You should create a bug in Bugzilla and a patch that adds lines to turn off your
pref in the following files:
- layout/tools/reftest/reftest-preferences.js
- testing/profiles/prefs_general.js
- testing/talos/talos/config.py
You can see an example in [this patch](https://github.com/mozilla/activity-stream/pull/2977).
## Reading, setting, and observing preferences from `.jsm`s
To read/set/observe Activity Stream preferences, construct a `Prefs` instance found in [lib/ActivityStreamPrefs.jsm](../../system-addon/lib/ActivityStreamPrefs.jsm).
```js
// Import Prefs
XPCOMUtils.defineLazyModuleGetter(this, "Prefs",
"resource://activity-stream/lib/ActivityStreamPrefs.jsm");
// Create an instance
const prefs = new Prefs();
```
The `Prefs` utility will set the Activity Stream branch for you by default, so you
don't need to worry about prefixing every pref with `browser.newtabpage.activity-stream.`:
```js
const prefs = new Prefs();
// This will return the value of browser.newtabpage.activity-stream.foo
prefs.get("foo");
// This will set the value of browser.newtabpage.activity-stream.foo to true
prefs.set("foo", true)
// This will call aCallback when browser.newtabpage.activity-stream.foo is changed
prefs.observe("foo", aCallback);
// This will stop listening to browser.newtabpage.activity-stream.foo
prefs.ignore("foo", aCallback);
```
See [toolkit/modules/Preferences.jsm](https://dxr.mozilla.org/mozilla-central/source/toolkit/modules/Preferences.jsm)
for more information about what methods are available.
## Discovery Stream Preferences
Preferences specific to the Discovery Stream are nested under the sub-branch `browser.newtabpage.activity-stream.discoverystream` (with the exception of `browser.newtabpage.blocked`).
#### `browser.newtabpage.activity-stream.discoverystream.flight.blocks`
- Type: `string (JSON)`
- Default: `{}`
- Pref Type: AS
Not intended for user configuration, but is programmatically updated. Used for tracking blocked flight IDs when a user dismisses a SPOC. Keys are flight IDs. Values don't have a specific meaning.
#### `browser.newtabpage.blocked`
- Type: `string (JSON)`
- Default: `null`
- Pref Type: AS
Not intended for user configuration, but is programmatically updated. Used for tracking blocked story IDs when a user dismisses one. Keys are story IDs. Values don't have a specific meaning.
#### `browser.newtabpage.activity-stream.discoverystream.config`
- Type `string (JSON)`
- Default:
```
{
"api_key_pref": "extensions.pocket.oAuthConsumerKey",
"collapsible": true,
"enabled": true,
"show_spocs": true,
"hardcoded_layout": true,
"personalized": true,
"layout_endpoint": "https://getpocket.cdn.mozilla.net/v3/newtab/layout?version=1&consumer_key=$apiKey&layout_variant=basic"
}
```
- `api_key_pref` (string): The name of a variable containing the key for the Pocket API.
- `collapsible` (boolean): Controls whether the sections in new tab can be collapsed.
- `enabled` (boolean): Controls whether DS is turned on and is programmatically set based on a user's locale. DS enablement is a logical `AND` of this and the value of `browser.newtabpage.activity-stream.discoverystream.enabled`.
- `show_spocs` (boolean): Show sponsored content in new tab.
- `hardcoded_layout` (boolean): When this is true, a hardcoded layout shipped with Firefox will be used instead of a remotely fetched layout definition.
- `personalized` (boolean): When this is `true` personalized content based on browsing history will be displayed.
- `layout_endpoint` (string): The URL for a remote layout definition that will be used if `hardcoded_layout` is `false`.
- `unused_key` (string): This is not set by default and is unused by this codebase. It's a standardized way to differentiate configurations to prevent experiment participants from being unenrolled.
#### `browser.newtabpage.activity-stream.discoverystream.enabled`
- Type: `boolean`
- Default: `true`
- Pref Type: Firefox
When this is set to `true` the Discovery Stream experience will show up if `enabled` is also `true` on `browser.newtabpage.activity-stream.discoverystream.config`. Otherwise the old Activity Stream experience will be shown.
#### `browser.newtabpage.activity-stream.discoverystream.endpointSpocsClear`
- Type: `string (URL)`
- Default: `https://spocs.getpocket.com/user`
- Pref Type: AS
Endpoint for when a user opts-out of sponsored content to delete the corresponding data from the ad server.
#### `browser.newtabpage.activity-stream.discoverystream.endpoints`
- Type: `string (URLs, CSV)`
- Default: `https://getpocket.cdn.mozilla.net/,https://spocs.getpocket.com/`
- Pref Type: AS
A whitelist of endpoints that are allowed to be used by Discovery Stream for remote content (eg: story metadata) and configuration (eg: remote layout definitions for experimentation).
#### `browser.newtabpage.activity-stream.discoverystream.engagementLabelEnabled`
- Type: `boolean`
- Default: `false`
- Pref Type: AS
A flag controlling the visibility of engagement labels on cards (eg: "Trending" or "Popular").
#### `browser.newtabpage.activity-stream.discoverystream.hardcoded-basic-layout`
- Type: `boolean`
- Default: `false`
- Pref Type: Firefox
If this is `false` the default hardcoded layout is used, and if it's `true` then an alternate hardcoded layout (that currently simulates the older AS experience) is used.
#### `browser.newtabpage.activity-stream.discoverystream.rec.impressions`
- Type: `string (JSON)`
- Default: `{}`
- Pref Type: AS
Programmatically generated hash table where the keys are recommendation IDs and the values are timestamps representing the first impression.
#### `browser.newtabpage.activity-stream.discoverystream.spoc.impressions`
- Type: `string`
- Default: `{}`
- Pref Type: AS
Programmatically generated hash table where the keys are sponsored content IDs and the values are arrays of timestamps for every impression.
#### `browser.newtabpage.activity-stream.discoverystream.spocs-endpoint`
- Type: `string`
- Default: `null`
- Pref Type: Firefox
Override to specify endpoint for SPOCs. Will take precedence over remote and hardcoded layout SPOC endpoints.
================================================
FILE: docs/v2-system-addon/remote_cfr.md
================================================
# Remote CFR Messages
Starting in Firefox 68, CFR messages will be defined using [Remote Settings](https://remote-settings.readthedocs.io/en/latest/index.html). In this document, we'll cover how how to set up a dev environment.
## Using a dev server for Remote CFR
**1. Setup the Remote Settings dev server with the CFR messages.**
Note that the dev server gets wiped every 24 hours so this will be have to be done once a day. You can check if there currently are any messages [here](https://kinto.dev.mozaws.net/v1//buckets/main/collections/cfr/records).
```bash
SERVER=https://kinto.dev.mozaws.net/v1
# create user
curl -X PUT ${SERVER}/accounts/devuser \
-d '{"data": {"password": "devpass"}}' \
-H 'Content-Type:application/json'
BASIC_AUTH=devuser:devpass
# create collection
CID=cfr
curl -X PUT ${SERVER}/buckets/main/collections/${CID} \
-H 'Content-Type:application/json' \
-u ${BASIC_AUTH}
# post a message
curl -X POST ${SERVER}/buckets/main/collections/${CID}/records \
-d '{"data":{"id":"PIN_TAB","template":"cfr_doorhanger","content":{"category":"cfrFeatures","bucket_id":"CFR_PIN_TAB","notification_text":{"string_id":"cfr-doorhanger-extension-notification"},"heading_text":{"string_id":"cfr-doorhanger-pintab-heading"},"info_icon":{"label":{"string_id":"cfr-doorhanger-extension-sumo-link"},"sumo_path":"extensionrecommendations"},"text":{"string_id":"cfr-doorhanger-pintab-description"},"descriptionDetails":{"steps":[{"string_id":"cfr-doorhanger-pintab-step1"},{"string_id":"cfr-doorhanger-pintab-step2"},{"string_id":"cfr-doorhanger-pintab-step3"}]},"buttons":{"primary":{"label":{"string_id":"cfr-doorhanger-pintab-ok-button"},"action":{"type":"PIN_CURRENT_TAB"}},"secondary":[{"label":{"string_id":"cfr-doorhanger-extension-cancel-button"},"action":{"type":"CANCEL"}},{"label":{"string_id":"cfr-doorhanger-extension-never-show-recommendation"}},{"label":{"string_id":"cfr-doorhanger-extension-manage-settings-button"},"action":{"type":"OPEN_PREFERENCES_PAGE","data":{"category":"general-cfrfeatures"}}}]}},"targeting":"locale == \"en-US\" && !hasPinnedTabs && recentVisits[.timestamp > (currentDate|date - 3600 * 1000 * 1)]|length >= 3","frequency":{"lifetime":3},"trigger":{"id":"frequentVisits","params":["docs.google.com","www.docs.google.com","calendar.google.com","messenger.com","www.messenger.com","web.whatsapp.com","mail.google.com","outlook.live.com","facebook.com","www.facebook.com","twitter.com","www.twitter.com","reddit.com","www.reddit.com","github.com","www.github.com","youtube.com","www.youtube.com","feedly.com","www.feedly.com","drive.google.com","amazon.com","www.amazon.com","messages.android.com"]}}}' \
-H 'Content-Type:application/json' \
-u ${BASIC_AUTH}
```
Now there should be a message listed: https://kinto.dev.mozaws.net/v1//buckets/main/collections/cfr/records
NOTE: The collection and messages can also be created manually using the [admin interface](https://kinto.dev.mozaws.net/v1/admin/).
**2. Set Remote Settings prefs to use the dev server.**
```javascript
Services.prefs.setStringPref("services.settings.server", "https://kinto.dev.mozaws.net/v1");
// Disable signature verification
const { RemoteSettings } = ChromeUtils.import("resource://services-settings/remote-settings.js", {});
RemoteSettings("cfr").verifySignature = false;
```
**3. Set ASRouter CFR pref to use Remote Settings provider and enable asrouter devtools.**
```javascript
Services.prefs.setStringPref("browser.newtabpage.activity-stream.asrouter.providers.cfr", JSON.stringify({"id":"cfr-remote","enabled":true,"type":"remote-settings","bucket":"cfr","frequency":{"custom":[{"period":"daily","cap":1}]},"categories":["cfrAddons","cfrFeatures"]}));
Services.prefs.setBoolPref("browser.newtabpage.activity-stream.asrouter.devtoolsEnabled", true);
```
**4. Go to `about:newtab#devtools`**
There should be a "cfr-remote" provider listed.
## Using the staging server for Remote CFR
If your message is published in the staging environment the easiest way to test is using the [Remote Settings Devtools](https://github.com/mozilla/remote-settings-devtools/releases) addon. You can install this by going to `about:debugging` and using the `Load Temporary Addon` feature.
The devtools allow you to switch your profile between production and staging and takes care of correctly flipping all the required preferences.
## Remote l10n
By default, all CFR messages are localized with the remote Fluent files hosted in `ms-language-packs` on Remote Settings. For local test and development, you can force ASRouter to use the local Fluent files by flipping the pref `browser.newtabpage.activity-stream.asrouter.useRemoteL10n`.
================================================
FILE: docs/v2-system-addon/sections.md
================================================
# Sections in Activity Stream
Each section in Activity Stream displays data from a corresponding section feed
in a standardised `Section` UI component. Each section feed is responsible for
listening to events and updating the section options such as the title, icon,
and rows (the cards for the section to display).
The `Section` UI component displays the rows provided by the section feed. If no
rows are available it displays an empty state consisting of an icon and a
message. Optionally, the section may have a info option menu that is displayed
when users hover over the info icon.
On load, `SectionsManager` and `SectionsFeed` in `SectionsManager.jsm` add the
sections configured in the `BUILT_IN_SECTIONS` map to the state. These sections
are initially disabled, so aren't visible. The section's feed may use the
methods provided by the `SectionsManager` to enable its section and update its
properties.
The section configuration in `BUILT_IN_SECTIONS` consists of a generator
function keyed by the pref name for the section feed. The generator function
takes an `options` argument as the only parameter, which is passed the object
stored as serialised JSON in the pref `{feed_pref_name}.options`, or the empty
object if this doesn't exist. The generator returns a section configuration
object which may have the following properties:
Property | Type | Description
--- | --- | ---
id | String | Non-optional unique id.
title | Localisation object | Has property `id`, the string localisation id, and optionally a `values` object to fill in placeholders.
icon | String | Icon id. New icons should be added in icons.scss.
maxRows | Integer | Maximum number of rows of cards to display. Should be >= 1.
contextMenuOptions | Array of strings | The menu options to provide in the card context menus.
shouldHidePref | Boolean | If true, will the section preference in the preferences pane will not be shown.
pref | Object | Configures the section preference to show in the preferences pane. Has properties `titleString` and `descString`.
emptyState | Object | Configures the empty state of the section. Has properties `message` and `icon`.
## Section feeds
Each section feed should be controlled by the pref `feeds.section.{section_id}`.
### Enabling the section
The section feed must listen for the events `INIT` (dispatched when Activity
Stream is initialised) and `FEED_INIT` (dispatched when a feed is re-enabled
having been turned off, with the feed id as the `data`). On these events it must
call `SectionsManager.enableSection(id)`. Care should be taken that this happens
only once `SectionsManager` has also initialised; the feed can use the method
`SectionsManager.onceInitialized()`.
### Disabling the section
The section feed must have an `uninit` method. This is called when the section
feed is disabled by turning the section's pref off. In `uninit` the feed must
call `SectionsManager.disableSection(id)`. This will remove the section's UI
component from every existing Activity Stream page.
### Updating the section rows
The section feed can call `SectionsManager.updateSection(id, options)` to update
section options. The `rows` array property of `options` stores the cards of
sites to display. Each card object may have the following properties:
```js
{
type, // One of the types in Card/types.js, e.g. "Trending"
title, // Title string
description, // Description string
image, // Image url
url // Site url
}
```
================================================
FILE: docs/v2-system-addon/telemetry.md
================================================
# Adding/Changing Telemetry Checklist
Adding telemetry generally involves a few steps:
1. File a "user story" bug about who wants what question answered. This will be used to track the client-side implementation as well as the data review request. If the server side changes are needed, ask Nan (:nanj / @ncloudio) if in doubt, bugs will be filed separately as dependencies.
1. Implement as usual...
1. Update `system-addon/test/schemas/pings.js` with a commented JOI schema of your changes, and add tests to system-addon/test/unit/TelemetryFeed.test.js to exercise the ping creation.
1. Update [data_events.md](data_events.md) with an example of the data in question.
1. Update any fields that you've added, deleted, or changed in [data_dictionary.md](data_dictionary.md).
1. Get review from Nan on the data schema and the documentation changes.
1. Request `data-review` of your documentation changes from a [data steward](https://wiki.mozilla.org/Firefox/Data_Collection) to ensure suitability for collection controlled by the opt-out `datareporting.healthreport.uploadEnabled` pref. Download and fill out the [data review request form](https://github.com/mozilla/data-review/blob/master/request.md) and then attach it as a text file on Bugzilla so you can r? a data steward. We've been working with Chris H-C (:chutten) for the Firefox specific telemetry, and Kenny Long (kenny@getpocket.com) for the Pocket specific telemetry, they are the best candidates for the review work as they know well about the context.
1. After landing the implementation, check with Nan to make sure the pings are making it to the database.
1. Once data flows in, you can build dashboard for the new telemetry on [Redash](https://sql.telemetry.mozilla.org/dashboards). If you're looking for some help about Redash or dashboard building, Nan is the guy for that.
================================================
FILE: docs/v2-system-addon/test-merges.md
================================================
## bin/test-merges.js documentation
A script intended to be run from cron regularly. It notices when a new PR has been merged to github, and then exports the code to a copy of mozilla-central and pushes it to pine, so that all the tests can be run. It annotates the PR with the link to treeherder with the test results.
Setup, needs to happen before first run:
Ensure that mozilla/activity-stream has a label called pushed-to-pine.
```bash
# mkdir /home/monkey/as-pine-testing
# cd /home/monkey/as-pine-testing
# git clone https://github.com/mozilla/activity-stream.git
# npm install
```
Example usage:
```bash
AS_PINE_TOKEN=01234567890 \
AS_PINE_TEST_DIR=/home/monkey/as-pine-testing \
node bin/test-merges.js
```
AS_PINE_TOKEN is a github token for accessing mozilla/activity-stream. We use a token from the github user that has access to the mozilla/activity-stream repo (in order to label issues), and nothing else.
AS_PINE_TEST_DIR should be a single directory which will contain local copies of both the activity-stream github repo and mozilla-central. It's highly advised that AS_PINE_TEST_DIR be used for nothing else, to avoid accidentally clobbering real work.
================================================
FILE: docs/v2-system-addon/tippytop.md
================================================
# TippyTop in Activity Stream
TippyTop, a collection of icons from the Alexa top sites, provides high quality images for the Top Sites in Activity Stream. The TippyTop manifest is hosted on S3, and then moved to [Remote Settings](https://remote-settings.readthedocs.io/en/latest/index.html) since Firefox 63. In this document, we'll cover how we produce and manage TippyTop manifest for Activity Stream.
## TippyTop manifest production
TippyTop manifest is produced by [tippy-top-sites](https://github.com/mozilla/tippy-top-sites).
```sh
# set up the environment, only needed for the first time
$ pip install -r requirements.txt
$ python make_manifest.py --count 2000 > icons.json # Alexa top 2000 sites
```
Because the manifest is hosted remotely, we use another repo [tippytop-service](https://github.com/mozilla-services/tippytop-service) for the version control and deployment. Ask :nanj or :r1cky for permission to access this private repo.
## TippyTop manifest publishing
For each new manifest release, firstly you should tag it in the tippytop-service repo, then publish it as follows:
### For Firefox 62 and below
File a deploy bug with the tagged version at Bugzilla as [Activity Streams: Application Servers](https://bugzilla.mozilla.org/enter_bug.cgi?product=Firefox&component=Activity%20Streams%3A%20Application%20Servers), assign it to our system engineer :jbuck, he will take care of the rest.
### For Firefox 63 and beyond
Activity Stream started using Remote Settings to manage TippyTop manifest since Firefox 63. To be able to publish new manifest, you need to be in the author&reviewer group of Remote Settings. See more details in this [mana page](https://mana.mozilla.org/wiki/pages/viewpage.action?pageId=66655528). You can also ask :nanj or :leplatram to get this set up for you.
To publish the manifest to Remote Settings, go to the tippytop-service repo, and run the script as follows,
```sh
# set up the remote setting, only needed for the first time
$ python3 -m venv .venv
$ source .venv/bin/activate
$ pip install -r requirements.txt
# publish it to prod
$ source .venv/bin/activate
# It will ask you for your LDAP user name and password.
$ ./upload2remotesettings.py prod
```
After uploading it to Remote Setting, you can request for review in the [dashboard](https://settings-writer.prod.mozaws.net/v1/admin/). Note that you will need to log in the Mozilla LDAP VPN for both uploading and accessing Remote Setting's dashboard. Once your request gets approved by the reviewer, the new manifest will be content signed and published to production.
## TippyTop Viewer
You can use this [viewer](https://mozilla.github.io/tippy-top-sites/manifest-viewer/) to load all the icons in the current manifest.
================================================
FILE: docs/v2-system-addon/unit_testing_guide.md
================================================
# Unit testing in Activity Stream
## Overview
Our unit tests in Activity Stream are written with mocha, chai, and sinon, and run
with karma. They include unit tests for both content code (React components, etc.)
and `.jsm`s.
You can find unit tests in `system-addon/tests/unit`.
## Running tests
To run the unit tests once, run `npm run testmc`.
To run unit tests continuously (i.e. in "test-driven development" mode), you can
run `npm run tddmc`.
## Debugging tests
To debug tests, you should run them in continuous mode with `npm run tddmc`. In the
Firefox window that is opened (it should say "Karma... - connected"), click the
"debug" button and open your console to see test output, set breakpoints, etc.
Unfortunately, source maps for tests do not currently work in Firefox. If you need
to see line numbers, you can run the tests with Chrome by running
`npm run tddmc -- --browsers Chrome`
## Where to put new tests
If you are creating a new test, add it to a subdirectory of the `system-addon/tests/unit`
that corresponds to the file you are testing. Tests should end with `.test.js` or
`.test.jsx` if the test includes any jsx.
For example, if the file you are testing is `system-addon/lib/Foo.jsm`, the test
file should be `system-addon/test/unit/lib/Foo.test.js`
## Mocha tests
All our unit tests are written with [mocha](https://mochajs.org), which injects
globals like `describe`, `it`, `beforeEach`, and others. It can be used to write
synchronous or asynchronous tests:
```js
describe("FooModule", () => {
// A synchronous test
it("should create an instance", () => {
assert.instanceOf(new FooModule(), FooModule);
});
describe("#meaningOfLife", () => {
// An asynchronous test
it("should eventually get the meaning of life", async () => {
const foo = new FooModule();
const result = await foo.meaningOfLife();
assert.equal(result, 42);
});
});
});
```
## Assertions
To write assertions, use the globally available `assert` object (this is provided
by karma-chai, so you do not need to `require` it).
For example:
```js
assert.equal(foo, 3);
assert.propertyVal(someObj, "foo", 3);
assert.calledOnce(someStub);
```
You can use any of the assertions from:
- [`chai`](http://chaijs.com/api/assert/).
- [`sinon-chai`](https://github.com/domenic/sinon-chai#assertions)
### Custom assertions
We have some custom assertions for checking various types of actions:
#### `.isUserEventAction(action)`
Asserts that a given `action` is a valid User Event, i.e. that it contains only
expected/valid properties for User Events in Activity Stream.
```js
// This will pass
assert.isUserEventAction(ac.UserEvent({event: "CLICK"}));
// This will fail
assert.isUserEventAction({type: "FOO"});
// This will fail because BLOOP is not a valid event type
assert.isUserEventAction(ac.UserEvent({event: "BLOOP"}));
```
## Overriding globals in `.jsm`s
Most `.jsm`s you will be testing use `Cu.import` or `XPCOMUtils` to inject globals.
In order to add mocks/stubs/fakes for these globals, you should use the `GlobalOverrider`
utility in `system-addon/test/unit/utils`:
```js
const {GlobalOverrider} = require("test/unit/utils");
describe("MyModule", () => {
let globals;
let sandbox;
beforeEach(() => {
globals = new GlobalOverrider();
sandbox = globals.sandbox; // this is a sinon sandbox
// This will inject a "AboutNewTab" global before each test
globals.set("AboutNewTab", {override: sandbox.stub()});
});
// globals.restore() clears any globals you added as well as the sinon sandbox
afterEach(() => globals.restore());
});
```
## Testing React components
You should use the [enzyme](https://github.com/airbnb/enzyme) suite of test utilities
to test React Components for Activity Stream.
Where possible, use the [shallow rendering method](https://github.com/airbnb/enzyme/blob/master/docs/api/shallow.md) (this will avoid unnecessarily
rendering child components):
```js
const React = require("react");
const {shallow} = require("enzyme");
describe("", () => {
it("should be hidden by default", () => {
const wrapper = shallow( );
assert.isTrue(wrapper.find(".wrapper").props().hidden);
});
});
```
If you need to, you can also do [Full DOM rendering](https://github.com/airbnb/enzyme/blob/master/docs/api/mount.md)
with enzyme's `mount` utility.
```js
const React = require("react");
const {mount} = require("enzyme");
...
const wrapper = mount( );
```
================================================
FILE: hooks/post-commit
================================================
#!/bin/sh
#
# Clean up any weirdness left around by prettier execution from pre-commit
# hook. Can happen for some workflows (eg `git commit .`).
#
# Install by executing
#
# ln -s ../../hooks/post-commit .git/hooks/post-commit
#
# at the top-level of the activity-stream github repo.
git update-index -g
================================================
FILE: hooks/pre-commit
================================================
#!/bin/sh
#
# Recommended pre-commit git hook for activity-stream github repo
#
# Install by executing
#
# ln -s ../../hooks/pre-commit .git/hooks/pre-commit
#
# at the top-level of the activity-stream github repo.
#
# Runs `eslint --fix` on all selected files, which, given our current
# prettier configuration, means prettifying these files as well. The
# commit will be aborted if eslint exits with a failure code.
#
# Based on the example script in the prettier docs at
# https://prettier.io/docs/en/precommit.html
FILES=$(git diff --cached --name-only --diff-filter=ACM "*.js" "*.jsx" "*.jsm" | sed 's| |\\ |g')
[ -z "$FILES" ] && exit 0
echo "$FILES" | xargs ./node_modules/.bin/eslint --cache --fix
if [ $? -ne 0 ]
then
echo "eslint found errors but was unable to fix them all with --fix."
echo "Please check the output, resolve any issues, and retry."
echo "If you want to commit anyway, pass the --no-verify flag to git commit."
exit -1
fi
# Add back the modified/prettified files to staging
echo "$FILES" | xargs git add
exit 0
================================================
FILE: 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:
% resource activity-stream %res/activity-stream/ contentaccessible=yes
res/activity-stream/lib/ (./lib/*)
res/activity-stream/common/ (./common/*)
res/activity-stream/vendor/Redux.jsm (./vendor/Redux.jsm)
res/activity-stream/vendor/react.js (./vendor/react.js)
res/activity-stream/vendor/react-dom.js (./vendor/react-dom.js)
#ifndef RELEASE_OR_BETA
res/activity-stream/vendor/react-dev.js (./vendor/react-dev.js)
res/activity-stream/vendor/react-dom-dev.js (./vendor/react-dom-dev.js)
#endif
res/activity-stream/vendor/prop-types.js (./vendor/prop-types.js)
res/activity-stream/vendor/react-transition-group.js (./vendor/react-transition-group.js)
res/activity-stream/vendor/redux.js (./vendor/redux.js)
res/activity-stream/vendor/react-redux.js (./vendor/react-redux.js)
res/activity-stream/data/content/assets/ (./data/content/assets/*)
res/activity-stream/data/content/tippytop/ (./data/content/tippytop/*)
res/activity-stream/data/content/activity-stream.bundle.js (./data/content/activity-stream.bundle.js)
#ifdef XP_MACOSX
res/activity-stream/css/activity-stream.css (./css/activity-stream-mac.css)
#elifdef XP_WIN
res/activity-stream/css/activity-stream.css (./css/activity-stream-windows.css)
#else
res/activity-stream/css/activity-stream.css (./css/activity-stream-linux.css)
#endif
res/activity-stream/prerendered/activity-stream.html (./prerendered/activity-stream.html)
#ifndef RELEASE_OR_BETA
res/activity-stream/prerendered/activity-stream-debug.html (./prerendered/activity-stream-debug.html)
#endif
res/activity-stream/prerendered/activity-stream-noscripts.html (./prerendered/activity-stream-noscripts.html)
================================================
FILE: karma.mc.config.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/. */
const path = require("path");
const PATHS = {
// Where is the entry point for the unit tests?
testEntryFile: path.resolve(__dirname, "test/unit/unit-entry.js"),
// A glob-style pattern matching all unit tests
testFilesPattern: "test/unit/**/*.js",
// The base directory of all source files (used for path resolution in webpack importing)
moduleResolveDirectory: __dirname,
// a RegEx matching all Cu.import statements of local files
resourcePathRegEx: /^resource:\/\/activity-stream\//,
coverageReportingPath: "logs/coverage/",
};
// When tweaking here, be sure to review the docs about the execution ordering
// semantics of the preprocessors array, as they are somewhat odd.
const preprocessors = {};
preprocessors[PATHS.testFilesPattern] = [
"webpack", // require("karma-webpack")
"sourcemap", // require("karma-sourcemap-loader")
];
module.exports = function(config) {
const isTDD = config.tdd;
const browsers = isTDD ? ["Firefox"] : ["FirefoxHeadless"]; // require("karma-firefox-launcher")
config.set({
singleRun: !isTDD,
browsers,
customLaunchers: {
FirefoxHeadless: {
base: "Firefox",
flags: ["--headless"],
},
},
frameworks: [
"chai", // require("chai") require("karma-chai")
"mocha", // require("mocha") require("karma-mocha")
"sinon", // require("sinon") require("karma-sinon")
],
reporters: [
"coverage-istanbul", // require("karma-coverage")
"mocha", // require("karma-mocha-reporter")
// for bin/try-runner.js to parse the output easily
"json", // require("karma-json-reporter")
],
jsonReporter: {
// So this doesn't get interleaved with other karma output
stdout: false,
outputFile: path.join("logs", "karma-run-results.json"),
},
coverageIstanbulReporter: {
reports: ["html", "text-summary"],
dir: PATHS.coverageReportingPath,
// This will make karma fail if coverage reporting is less than the minimums here
thresholds: !isTDD && {
each: {
statements: 100,
lines: 100,
functions: 100,
branches: 66,
overrides: {
"lib/ActivityStreamStorage.jsm": {
statements: 100,
lines: 100,
functions: 100,
branches: 83,
},
"lib/UTEventReporting.jsm": {
statements: 100,
lines: 100,
functions: 100,
branches: 75,
},
"lib/*.jsm": {
statements: 100,
lines: 100,
functions: 100,
branches: 84,
},
"content-src/components/DiscoveryStreamComponents/**/*.jsx": {
statements: 90.48,
lines: 90.48,
functions: 85.71,
branches: 68.75,
},
"content-src/asrouter/**/*.jsx": {
statements: 57,
lines: 58,
functions: 60,
branches: 50,
},
"content-src/components/ASRouterAdmin/*.jsx": {
statements: 0,
lines: 0,
functions: 0,
branches: 0,
},
"content-src/components/**/*.jsx": {
statements: 51.1,
lines: 52.38,
functions: 31.2,
branches: 31.2,
},
},
},
},
},
files: [PATHS.testEntryFile],
preprocessors,
webpack: {
mode: "none",
devtool: "inline-source-map",
// This loader allows us to override required files in tests
resolveLoader: {
alias: { inject: path.join(__dirname, "loaders/inject-loader") },
},
// This resolve config allows us to import with paths relative to the root directory, e.g. "lib/ActivityStream.jsm"
resolve: {
extensions: [".js", ".jsx"],
modules: [PATHS.moduleResolveDirectory, "node_modules"],
},
externals: {
// enzyme needs these for backwards compatibility with 0.13.
// see https://github.com/airbnb/enzyme/blob/master/docs/guides/webpack.md#using-enzyme-with-webpack
"react/addons": true,
"react/lib/ReactContext": true,
"react/lib/ExecutionEnvironment": true,
},
module: {
rules: [
// This rule rewrites importing/exporting in .jsm files to be compatible with esmodules
{
test: /\.jsm$/,
exclude: [/node_modules/],
use: [
{
loader: "babel-loader", // require("babel-core")
options: {
plugins: [
// Converts .jsm files into common-js modules
[
"jsm-to-commonjs",
{
basePath: PATHS.resourcePathRegEx,
removeOtherImports: true,
replace: true,
},
], // require("babel-plugin-jsm-to-commonjs")
],
},
},
],
},
{
test: /\.js$/,
exclude: [/node_modules\/(?!(fluent|fluent-react)\/).*/, /test/],
loader: "babel-loader",
},
{
test: /\.jsx$/,
exclude: /node_modules/,
loader: "babel-loader",
options: {
presets: ["@babel/preset-react"],
},
},
{
test: /\.md$/,
use: "raw-loader",
},
{
enforce: "post",
test: /\.js[mx]?$/,
loader: "istanbul-instrumenter-loader",
options: { esModules: true },
include: [
path.resolve("content-src"),
path.resolve("lib"),
path.resolve("common"),
],
exclude: [
path.resolve("test"),
path.resolve("vendor"),
path.resolve("lib/ASRouterTargeting.jsm"),
path.resolve("lib/ASRouterTriggerListeners.jsm"),
path.resolve("lib/OnboardingMessageProvider.jsm"),
path.resolve("lib/CFRMessageProvider.jsm"),
path.resolve("lib/CFRPageActions.jsm"),
],
},
],
},
},
// Silences some overly-verbose logging of individual module builds
webpackMiddleware: { noInfo: true },
});
};
================================================
FILE: lib/ASRouter.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 { XPCOMUtils } = ChromeUtils.import(
"resource://gre/modules/XPCOMUtils.jsm"
);
XPCOMUtils.defineLazyGlobalGetters(this, ["fetch"]);
XPCOMUtils.defineLazyModuleGetters(this, {
AddonManager: "resource://gre/modules/AddonManager.jsm",
UITour: "resource:///modules/UITour.jsm",
FxAccounts: "resource://gre/modules/FxAccounts.jsm",
AppConstants: "resource://gre/modules/AppConstants.jsm",
OS: "resource://gre/modules/osfile.jsm",
BookmarkPanelHub: "resource://activity-stream/lib/BookmarkPanelHub.jsm",
SnippetsTestMessageProvider:
"resource://activity-stream/lib/SnippetsTestMessageProvider.jsm",
PanelTestProvider: "resource://activity-stream/lib/PanelTestProvider.jsm",
ToolbarBadgeHub: "resource://activity-stream/lib/ToolbarBadgeHub.jsm",
ToolbarPanelHub: "resource://activity-stream/lib/ToolbarPanelHub.jsm",
ASRouterTargeting: "resource://activity-stream/lib/ASRouterTargeting.jsm",
QueryCache: "resource://activity-stream/lib/ASRouterTargeting.jsm",
ASRouterPreferences: "resource://activity-stream/lib/ASRouterPreferences.jsm",
TARGETING_PREFERENCES:
"resource://activity-stream/lib/ASRouterPreferences.jsm",
ASRouterTriggerListeners:
"resource://activity-stream/lib/ASRouterTriggerListeners.jsm",
CFRMessageProvider: "resource://activity-stream/lib/CFRMessageProvider.jsm",
KintoHttpClient: "resource://services-common/kinto-http-client.js",
Downloader: "resource://services-settings/Attachments.jsm",
RemoteL10n: "resource://activity-stream/lib/RemoteL10n.jsm",
MigrationUtils: "resource:///modules/MigrationUtils.jsm",
});
XPCOMUtils.defineLazyServiceGetters(this, {
BrowserHandler: ["@mozilla.org/browser/clh;1", "nsIBrowserHandler"],
});
const {
ASRouterActions: ra,
actionTypes: at,
actionCreators: ac,
} = ChromeUtils.import("resource://activity-stream/common/Actions.jsm");
const { CFRMessageProvider } = ChromeUtils.import(
"resource://activity-stream/lib/CFRMessageProvider.jsm"
);
const { OnboardingMessageProvider } = ChromeUtils.import(
"resource://activity-stream/lib/OnboardingMessageProvider.jsm"
);
const { RemoteSettings } = ChromeUtils.import(
"resource://services-settings/remote-settings.js"
);
const { CFRPageActions } = ChromeUtils.import(
"resource://activity-stream/lib/CFRPageActions.jsm"
);
const { AttributionCode } = ChromeUtils.import(
"resource:///modules/AttributionCode.jsm"
);
const TRAILHEAD_CONFIG = {
DID_SEE_ABOUT_WELCOME_PREF: "trailhead.firstrun.didSeeAboutWelcome",
DYNAMIC_TRIPLET_BUNDLE_LENGTH: 3,
};
const INCOMING_MESSAGE_NAME = "ASRouter:child-to-parent";
const OUTGOING_MESSAGE_NAME = "ASRouter:parent-to-child";
const ONE_DAY_IN_MS = 24 * 60 * 60 * 1000;
// List of hosts for endpoints that serve router messages.
// Key is allowed host, value is a name for the endpoint host.
const DEFAULT_WHITELIST_HOSTS = {
"activity-stream-icons.services.mozilla.com": "production",
"snippets-admin.mozilla.org": "preview",
};
const SNIPPETS_ENDPOINT_WHITELIST =
"browser.newtab.activity-stream.asrouter.whitelistHosts";
// Max possible impressions cap for any message
const MAX_MESSAGE_LIFETIME_CAP = 100;
const LOCAL_MESSAGE_PROVIDERS = {
OnboardingMessageProvider,
CFRMessageProvider,
};
const STARTPAGE_VERSION = "6";
// Remote Settings
const RS_SERVER_PREF = "services.settings.server";
const RS_MAIN_BUCKET = "main";
const RS_COLLECTION_L10N = "ms-language-packs"; // "ms" stands for Messaging System
const RS_PROVIDERS_WITH_L10N = ["cfr", "cfr-fxa", "whats-new-panel"];
const RS_FLUENT_VERSION = "v1";
const RS_FLUENT_RECORD_PREFIX = `cfr-${RS_FLUENT_VERSION}`;
const RS_DOWNLOAD_MAX_RETRIES = 2;
// This is the list of providers for which we want to cache the targeting
// expression result and reuse between calls. Cache duration is defined in
// ASRouterTargeting where evaluation takes place.
const JEXL_PROVIDER_CACHE = new Set(["snippets"]);
// To observe the app locale change notification.
const TOPIC_INTL_LOCALE_CHANGED = "intl:app-locales-changed";
// To observe the pref that controls if ASRouter should use the remote Fluent files for l10n.
const USE_REMOTE_L10N_PREF =
"browser.newtabpage.activity-stream.asrouter.useRemoteL10n";
const MessageLoaderUtils = {
STARTPAGE_VERSION,
REMOTE_LOADER_CACHE_KEY: "RemoteLoaderCache",
_errors: [],
reportError(e) {
Cu.reportError(e);
this._errors.push({
timestamp: new Date(),
error: { message: e.toString(), stack: e.stack },
});
},
get errors() {
const errors = this._errors;
this._errors = [];
return errors;
},
/**
* _localLoader - Loads messages for a local provider (i.e. one that lives in mozilla central)
*
* @param {obj} provider An AS router provider
* @param {Array} provider.messages An array of messages
* @returns {Array} the array of messages
*/
_localLoader(provider) {
return provider.messages;
},
async _remoteLoaderCache(storage) {
let allCached;
try {
allCached =
(await storage.get(MessageLoaderUtils.REMOTE_LOADER_CACHE_KEY)) || {};
} catch (e) {
// istanbul ignore next
MessageLoaderUtils.reportError(e);
// istanbul ignore next
allCached = {};
}
return allCached;
},
/**
* _remoteLoader - Loads messages for a remote provider
*
* @param {obj} provider An AS router provider
* @param {string} provider.url An endpoint that returns an array of messages as JSON
* @param {obj} options.storage A storage object with get() and set() methods for caching.
* @returns {Promise} resolves with an array of messages, or an empty array if none could be fetched
*/
async _remoteLoader(provider, options) {
let remoteMessages = [];
if (provider.url) {
const allCached = await MessageLoaderUtils._remoteLoaderCache(
options.storage
);
const cached = allCached[provider.id];
let etag;
if (
cached &&
cached.url === provider.url &&
cached.version === STARTPAGE_VERSION
) {
const { lastFetched, messages } = cached;
if (
!MessageLoaderUtils.shouldProviderUpdate({
...provider,
lastUpdated: lastFetched,
})
) {
// Cached messages haven't expired, return early.
return messages;
}
etag = cached.etag;
remoteMessages = messages;
}
let headers = new Headers();
if (etag) {
headers.set("If-None-Match", etag);
}
let response;
try {
response = await fetch(provider.url, { headers, credentials: "omit" });
} catch (e) {
MessageLoaderUtils.reportError(e);
}
if (
response &&
response.ok &&
(response.status >= 200 && response.status < 400)
) {
let jsonResponse;
try {
jsonResponse = await response.json();
} catch (e) {
MessageLoaderUtils.reportError(e);
return remoteMessages;
}
if (jsonResponse && jsonResponse.messages) {
remoteMessages = jsonResponse.messages.map(msg => ({
...msg,
provider_url: provider.url,
}));
// Cache the results if this isn't a preview URL.
if (provider.updateCycleInMs > 0) {
etag = response.headers.get("ETag");
const cacheInfo = {
messages: remoteMessages,
etag,
lastFetched: Date.now(),
version: STARTPAGE_VERSION,
};
options.storage.set(MessageLoaderUtils.REMOTE_LOADER_CACHE_KEY, {
...allCached,
[provider.id]: cacheInfo,
});
}
} else {
MessageLoaderUtils.reportError(
`No messages returned from ${provider.url}.`
);
}
} else if (response) {
MessageLoaderUtils.reportError(
`Invalid response status ${response.status} from ${provider.url}.`
);
}
}
return remoteMessages;
},
/**
* _remoteSettingsLoader - Loads messages for a RemoteSettings provider
*
* Note:
* 1). Both "cfr" and "cfr-fxa" require the Fluent file for l10n, so there is
* another file downloading phase for those two providers after their messages
* are successfully fetched from Remote Settings. Currently, they share the same
* attachment of the record "${RS_FLUENT_RECORD_PREFIX}-${locale}" in the
* "ms-language-packs" collection. E.g. for "en-US" with version "v1",
* the Fluent file is attched to the record with ID "cfr-v1-en-US".
*
* 2). The Remote Settings downloader is able to detect the duplicate download
* requests for the same attachment and ignore the redundent requests automatically.
*
* @param {obj} provider An AS router provider
* @param {string} provider.id The id of the provider
* @param {string} provider.bucket The name of the Remote Settings bucket
* @param {func} options.dispatchToAS dispatch an action the main AS Store
* @returns {Promise} resolves with an array of messages, or an empty array if none could be fetched
*/
async _remoteSettingsLoader(provider, options) {
let messages = [];
if (provider.bucket) {
try {
messages = await MessageLoaderUtils._getRemoteSettingsMessages(
provider.bucket
);
if (!messages.length) {
MessageLoaderUtils._handleRemoteSettingsUndesiredEvent(
"ASR_RS_NO_MESSAGES",
provider.id,
options.dispatchToAS
);
} else if (RS_PROVIDERS_WITH_L10N.includes(provider.id)) {
const locale = Services.locale.appLocaleAsLangTag;
const recordId = `${RS_FLUENT_RECORD_PREFIX}-${locale}`;
const kinto = new KintoHttpClient(
Services.prefs.getStringPref(RS_SERVER_PREF)
);
const record = await kinto
.bucket(RS_MAIN_BUCKET)
.collection(RS_COLLECTION_L10N)
.getRecord(recordId);
if (record && record.data) {
const downloader = new Downloader(
RS_MAIN_BUCKET,
RS_COLLECTION_L10N
);
// Await here in order to capture the exceptions for reporting.
await downloader.download(record.data, {
retries: RS_DOWNLOAD_MAX_RETRIES,
});
RemoteL10n.reloadL10n();
} else {
MessageLoaderUtils._handleRemoteSettingsUndesiredEvent(
"ASR_RS_NO_MESSAGES",
RS_COLLECTION_L10N,
options.dispatchToAS
);
}
}
} catch (e) {
MessageLoaderUtils._handleRemoteSettingsUndesiredEvent(
"ASR_RS_ERROR",
provider.id,
options.dispatchToAS
);
MessageLoaderUtils.reportError(e);
}
}
return messages;
},
_getRemoteSettingsMessages(bucket) {
return RemoteSettings(bucket).get();
},
_handleRemoteSettingsUndesiredEvent(event, providerId, dispatchToAS) {
if (dispatchToAS) {
dispatchToAS(
ac.ASRouterUserEvent({
action: "asrouter_undesired_event",
event,
message_id: "n/a",
event_context: providerId,
})
);
}
},
/**
* _getMessageLoader - return the right loading function given the provider's type
*
* @param {obj} provider An AS Router provider
* @returns {func} A loading function
*/
_getMessageLoader(provider) {
switch (provider.type) {
case "remote":
return this._remoteLoader;
case "remote-settings":
return this._remoteSettingsLoader;
case "local":
default:
return this._localLoader;
}
},
/**
* shouldProviderUpdate - Given the current time, should a provider update its messages?
*
* @param {any} provider An AS Router provider
* @param {int} provider.updateCycleInMs The number of milliseconds we should wait between updates
* @param {Date} provider.lastUpdated If the provider has been updated, the time the last update occurred
* @param {Date} currentTime The time we should check against. (defaults to Date.now())
* @returns {bool} Should an update happen?
*/
shouldProviderUpdate(provider, currentTime = Date.now()) {
return (
!(provider.lastUpdated >= 0) ||
currentTime - provider.lastUpdated > provider.updateCycleInMs
);
},
/**
* loadMessagesForProvider - Load messages for a provider, given the provider's type.
*
* @param {obj} provider An AS Router provider
* @param {string} provider.type An AS Router provider type (defaults to "local")
* @param {obj} options.storage A storage object with get() and set() methods for caching.
* @param {func} options.dispatchToAS dispatch an action the main AS Store
* @returns {obj} Returns an object with .messages (an array of messages) and .lastUpdated (the time the messages were updated)
*/
async loadMessagesForProvider(provider, options) {
const loader = this._getMessageLoader(provider);
let messages = await loader(provider, options);
// istanbul ignore if
if (!messages) {
messages = [];
MessageLoaderUtils.reportError(
new Error(
`Tried to load messages for ${
provider.id
} but the result was not an Array.`
)
);
}
// Filter out messages we temporarily want to exclude
if (provider.exclude && provider.exclude.length) {
messages = messages.filter(
message => !provider.exclude.includes(message.id)
);
}
const lastUpdated = Date.now();
return {
messages: messages
.map(messageData => {
const message = {
weight: 100,
...messageData,
provider: provider.id,
};
// This is to support a personalization experiment
if (provider.personalized) {
const score = ASRouterPreferences.personalizedCfrScores[message.id];
if (score) {
message.score = score;
}
message.personalizedModelVersion =
provider.personalizedModelVersion;
}
return message;
})
.filter(message => message.weight > 0),
lastUpdated,
errors: MessageLoaderUtils.errors,
};
},
/**
* _loadAddonIconInURLBar - load addons-notification icon by displaying
* box containing addons icon in urlbar. See Bug 1513882
*
* @param {XULElement} Target browser element for showing addons icon
*/
_loadAddonIconInURLBar(browser) {
if (!browser) {
return;
}
const chromeDoc = browser.ownerDocument;
let notificationPopupBox = chromeDoc.getElementById(
"notification-popup-box"
);
if (!notificationPopupBox) {
return;
}
if (
notificationPopupBox.style.display === "none" ||
notificationPopupBox.style.display === ""
) {
notificationPopupBox.style.display = "block";
}
},
async installAddonFromURL(browser, url, telemetrySource = "amo") {
try {
MessageLoaderUtils._loadAddonIconInURLBar(browser);
const aUri = Services.io.newURI(url);
const systemPrincipal = Services.scriptSecurityManager.getSystemPrincipal();
// AddonManager installation source associated to the addons installed from activitystream's CFR
// and RTAMO (source is going to be "amo" if not configured explicitly in the message provider).
const telemetryInfo = { source: telemetrySource };
const install = await AddonManager.getInstallForURL(aUri.spec, {
telemetryInfo,
});
await AddonManager.installAddonFromWebpage(
"application/x-xpinstall",
browser,
systemPrincipal,
install
);
} catch (e) {
Cu.reportError(e);
}
},
/**
* cleanupCache - Removes cached data of removed providers.
*
* @param {Array} providers A list of activer AS Router providers
*/
async cleanupCache(providers, storage) {
const ids = providers.filter(p => p.type === "remote").map(p => p.id);
const cache = await MessageLoaderUtils._remoteLoaderCache(storage);
let dirty = false;
for (let id in cache) {
if (!ids.includes(id)) {
delete cache[id];
dirty = true;
}
}
if (dirty) {
await storage.set(MessageLoaderUtils.REMOTE_LOADER_CACHE_KEY, cache);
}
},
};
this.MessageLoaderUtils = MessageLoaderUtils;
/**
* @class _ASRouter - Keeps track of all messages, UI surfaces, and
* handles blocking, rotation, etc. Inspecting ASRouter.state will
* tell you what the current displayed message is in all UI surfaces.
*
* Note: This is written as a constructor rather than just a plain object
* so that it can be more easily unit tested.
*/
class _ASRouter {
constructor(localProviders = LOCAL_MESSAGE_PROVIDERS) {
this.initialized = false;
this.messageChannel = null;
this.dispatchToAS = null;
this._storage = null;
this._resetInitialization();
this._state = {
providers: [],
messageBlockList: [],
providerBlockList: [],
messageImpressions: {},
providerImpressions: {},
trailheadInitialized: false,
messages: [],
errors: [],
localeInUse: Services.locale.appLocaleAsLangTag,
};
this._triggerHandler = this._triggerHandler.bind(this);
this._localProviders = localProviders;
this.blockMessageById = this.blockMessageById.bind(this);
this.unblockMessageById = this.unblockMessageById.bind(this);
this.onMessage = this.onMessage.bind(this);
this.handleMessageRequest = this.handleMessageRequest.bind(this);
this.addImpression = this.addImpression.bind(this);
this._handleTargetingError = this._handleTargetingError.bind(this);
this.onPrefChange = this.onPrefChange.bind(this);
this.dispatch = this.dispatch.bind(this);
this._onLocaleChanged = this._onLocaleChanged.bind(this);
}
async onPrefChange(prefName) {
if (TARGETING_PREFERENCES.includes(prefName)) {
// Notify all tabs of messages that have become invalid after pref change
const invalidMessages = [];
const context = this._getMessagesContext();
for (const msg of this._getUnblockedMessages()) {
if (!msg.targeting) {
continue;
}
const isMatch = await ASRouterTargeting.isMatch(msg.targeting, context);
if (!isMatch) {
invalidMessages.push(msg.id);
}
}
this.messageChannel.sendAsyncMessage(OUTGOING_MESSAGE_NAME, {
type: at.AS_ROUTER_TARGETING_UPDATE,
data: invalidMessages,
});
} else {
// Update message providers and fetch new messages on pref change
this._loadLocalProviders();
this._updateMessageProviders();
await this.loadMessagesFromAllProviders();
}
}
// Replace all frequency time period aliases with their millisecond values
// This allows us to avoid accounting for special cases later on
normalizeItemFrequency({ frequency }) {
if (frequency && frequency.custom) {
for (const setting of frequency.custom) {
if (setting.period === "daily") {
setting.period = ONE_DAY_IN_MS;
}
}
}
}
// Fetch and decode the message provider pref JSON, and update the message providers
_updateMessageProviders() {
const previousProviders = this.state.providers;
const providers = [
// If we have added a `preview` provider, hold onto it
...previousProviders.filter(p => p.id === "preview"),
// The provider should be enabled and not have a user preference set to false
...ASRouterPreferences.providers.filter(
p =>
p.enabled &&
(ASRouterPreferences.getUserPreference(p.id) !== false &&
// Provider is enabled or if provider has multiple categories
// check that at least one category is enabled
(!p.categories ||
p.categories.some(
c => ASRouterPreferences.getUserPreference(c) !== false
)))
),
].map(_provider => {
// make a copy so we don't modify the source of the pref
const provider = { ..._provider };
if (provider.type === "local" && !provider.messages) {
// Get the messages from the local message provider
const localProvider = this._localProviders[provider.localProvider];
provider.messages = localProvider ? localProvider.getMessages() : [];
}
if (provider.type === "remote" && provider.url) {
provider.url = provider.url.replace(
/%STARTPAGE_VERSION%/g,
STARTPAGE_VERSION
);
provider.url = Services.urlFormatter.formatURL(provider.url);
}
this.normalizeItemFrequency(provider);
// Reset provider update timestamp to force message refresh
provider.lastUpdated = undefined;
return provider;
});
const providerIDs = providers.map(p => p.id);
// Clear old messages for providers that are no longer enabled
for (const prevProvider of previousProviders) {
if (!providerIDs.includes(prevProvider.id)) {
this.messageChannel.sendAsyncMessage(OUTGOING_MESSAGE_NAME, {
type: "CLEAR_PROVIDER",
data: { id: prevProvider.id },
});
}
}
this.setState(prevState => ({
providers,
// Clear any messages from removed providers
messages: [
...prevState.messages.filter(message =>
providerIDs.includes(message.provider)
),
],
}));
}
get state() {
return this._state;
}
set state(value) {
throw new Error(
"Do not modify this.state directy. Instead, call this.setState(newState)"
);
}
/**
* _resetInitialization - adds the following to the instance:
* .initialized {bool} Has AS Router been initialized?
* .waitForInitialized {Promise} A promise that resolves when initializion is complete
* ._finishInitializing {func} A function that, when called, resolves the .waitForInitialized
* promise and sets .initialized to true.
* @memberof _ASRouter
*/
_resetInitialization() {
this.initialized = false;
this.waitForInitialized = new Promise(resolve => {
this._finishInitializing = () => {
this.initialized = true;
resolve();
};
});
}
/**
* loadMessagesFromAllProviders - Loads messages from all providers if they require updates.
* Checks the .lastUpdated field on each provider to see if updates are needed
* @memberof _ASRouter
*/
async loadMessagesFromAllProviders() {
const needsUpdate = this.state.providers.filter(provider =>
MessageLoaderUtils.shouldProviderUpdate(provider)
);
// Don't do extra work if we don't need any updates
if (needsUpdate.length) {
let newState = { messages: [], providers: [] };
for (const provider of this.state.providers) {
if (needsUpdate.includes(provider)) {
let {
messages,
lastUpdated,
errors,
} = await MessageLoaderUtils.loadMessagesForProvider(provider, {
storage: this._storage,
dispatchToAS: this.dispatchToAS,
});
messages = messages.filter(
({ content }) =>
!content ||
!content.category ||
ASRouterPreferences.getUserPreference(content.category)
);
newState.providers.push({ ...provider, lastUpdated, errors });
newState.messages = [...newState.messages, ...messages];
} else {
// Skip updating this provider's messages if no update is required
let messages = this.state.messages.filter(
msg => msg.provider === provider.id
);
newState.providers.push(provider);
newState.messages = [...newState.messages, ...messages];
}
}
for (const message of newState.messages) {
this.normalizeItemFrequency(message);
}
// Some messages have triggers that require us to initalise trigger listeners
const unseenListeners = new Set(ASRouterTriggerListeners.keys());
for (const { trigger } of newState.messages) {
if (trigger && ASRouterTriggerListeners.has(trigger.id)) {
ASRouterTriggerListeners.get(trigger.id).init(
this._triggerHandler,
trigger.params,
trigger.patterns
);
unseenListeners.delete(trigger.id);
}
}
// We don't need these listeners, but they may have previously been
// initialised, so uninitialise them
for (const triggerID of unseenListeners) {
ASRouterTriggerListeners.get(triggerID).uninit();
}
// We don't want to cache preview endpoints, remove them after messages are fetched
await this.setState(this._removePreviewEndpoint(newState));
await this.cleanupImpressions();
}
}
async _maybeUpdateL10nAttachment() {
const { localeInUse } = this.state.localeInUse;
const newLocale = Services.locale.appLocaleAsLangTag;
if (newLocale !== localeInUse) {
const providers = [...this.state.providers];
let needsUpdate = false;
providers.forEach(provider => {
if (RS_PROVIDERS_WITH_L10N.includes(provider.id)) {
// Force to refresh the messages as well as the attachment.
provider.lastUpdated = undefined;
needsUpdate = true;
}
});
if (needsUpdate) {
await this.setState({
localeInUse: newLocale,
providers,
});
await this.loadMessagesFromAllProviders();
}
}
}
async _onLocaleChanged(subject, topic, data) {
await this._maybeUpdateL10nAttachment();
}
observe(aSubject, aTopic, aPrefName) {
switch (aPrefName) {
case USE_REMOTE_L10N_PREF:
CFRPageActions.reloadL10n();
break;
}
}
/**
* init - Initializes the MessageRouter.
* It is ready when it has been connected to a RemotePageManager instance.
*
* @param {RemotePageManager} channel a RemotePageManager instance
* @param {obj} storage an AS storage instance
* @param {func} dispatchToAS dispatch an action the main AS Store
* @memberof _ASRouter
*/
async init(channel, storage, dispatchToAS) {
this.messageChannel = channel;
this.messageChannel.addMessageListener(
INCOMING_MESSAGE_NAME,
this.onMessage
);
this._storage = storage;
this.WHITELIST_HOSTS = this._loadSnippetsWhitelistHosts();
this.dispatchToAS = dispatchToAS;
ASRouterPreferences.init();
ASRouterPreferences.addListener(this.onPrefChange);
BookmarkPanelHub.init(
this.handleMessageRequest,
this.addImpression,
this.dispatch
);
ToolbarBadgeHub.init(this.waitForInitialized, {
handleMessageRequest: this.handleMessageRequest,
addImpression: this.addImpression,
blockMessageById: this.blockMessageById,
unblockMessageById: this.unblockMessageById,
dispatch: this.dispatch,
});
ToolbarPanelHub.init(this.waitForInitialized, {
getMessages: this.handleMessageRequest,
dispatch: this.dispatch,
handleUserAction: this.handleUserAction,
});
this._loadLocalProviders();
const messageBlockList =
(await this._storage.get("messageBlockList")) || [];
const providerBlockList =
(await this._storage.get("providerBlockList")) || [];
const messageImpressions =
(await this._storage.get("messageImpressions")) || {};
const providerImpressions =
(await this._storage.get("providerImpressions")) || {};
const previousSessionEnd =
(await this._storage.get("previousSessionEnd")) || 0;
await this.setState({
messageBlockList,
providerBlockList,
messageImpressions,
providerImpressions,
previousSessionEnd,
});
this._updateMessageProviders();
await this.loadMessagesFromAllProviders();
await MessageLoaderUtils.cleanupCache(this.state.providers, storage);
// set necessary state in the rest of AS
this.dispatchToAS(
ac.BroadcastToContent({
type: at.AS_ROUTER_INITIALIZED,
data: ASRouterPreferences.specialConditions,
})
);
Services.obs.addObserver(this._onLocaleChanged, TOPIC_INTL_LOCALE_CHANGED);
Services.prefs.addObserver(USE_REMOTE_L10N_PREF, this);
// sets .initialized to true and resolves .waitForInitialized promise
this._finishInitializing();
}
uninit() {
this._storage.set("previousSessionEnd", Date.now());
this.messageChannel.sendAsyncMessage(OUTGOING_MESSAGE_NAME, {
type: "CLEAR_ALL",
});
this.messageChannel.removeMessageListener(
INCOMING_MESSAGE_NAME,
this.onMessage
);
this.messageChannel = null;
this.dispatchToAS = null;
ASRouterPreferences.removeListener(this.onPrefChange);
ASRouterPreferences.uninit();
BookmarkPanelHub.uninit();
ToolbarPanelHub.uninit();
ToolbarBadgeHub.uninit();
// Uninitialise all trigger listeners
for (const listener of ASRouterTriggerListeners.values()) {
listener.uninit();
}
Services.obs.removeObserver(
this._onLocaleChanged,
TOPIC_INTL_LOCALE_CHANGED
);
Services.prefs.removeObserver(USE_REMOTE_L10N_PREF, this);
// If we added any CFR recommendations, they need to be removed
CFRPageActions.clearRecommendations();
this._resetInitialization();
}
setState(callbackOrObj) {
const newState =
typeof callbackOrObj === "function"
? callbackOrObj(this.state)
: callbackOrObj;
this._state = { ...this.state, ...newState };
return new Promise(resolve => {
this._onStateChanged(this.state);
resolve();
});
}
getMessageById(id) {
return this.state.messages.find(message => message.id === id);
}
_onStateChanged(state) {
if (ASRouterPreferences.devtoolsEnabled) {
this._updateAdminState();
}
}
_loadLocalProviders() {
// If we're in ASR debug mode add the local test providers
if (ASRouterPreferences.devtoolsEnabled) {
this._localProviders = {
...this._localProviders,
SnippetsTestMessageProvider,
PanelTestProvider,
};
}
}
/**
* Used by ASRouter Admin returns all ASRouterTargeting.Environment
* and ASRouter._getMessagesContext parameters and values
*/
async getTargetingParameters(environment, localContext) {
const targetingParameters = {};
for (const param of Object.keys(environment)) {
targetingParameters[param] = await environment[param];
}
for (const param of Object.keys(localContext)) {
targetingParameters[param] = await localContext[param];
}
return targetingParameters;
}
async _updateAdminState(target) {
const channel = target || this.messageChannel;
channel.sendAsyncMessage(OUTGOING_MESSAGE_NAME, {
type: "ADMIN_SET_STATE",
data: {
...this.state,
providerPrefs: ASRouterPreferences.providers,
userPrefs: ASRouterPreferences.getAllUserPreferences(),
targetingParameters: await this.getTargetingParameters(
ASRouterTargeting.Environment,
this._getMessagesContext()
),
trailhead: ASRouterPreferences.trailhead,
errors: this.errors,
},
});
}
_handleTargetingError(type, error, message) {
Cu.reportError(error);
if (this.dispatchToAS) {
this.dispatchToAS(
ac.ASRouterUserEvent({
message_id: message.id,
action: "asrouter_undesired_event",
event: "TARGETING_EXPRESSION_ERROR",
event_context: type,
})
);
}
}
async setTrailHeadMessageSeen() {
if (!this.state.trailheadInitialized) {
Services.prefs.setBoolPref(
TRAILHEAD_CONFIG.DID_SEE_ABOUT_WELCOME_PREF,
true
);
await this.setState({
trailheadInitialized: true,
});
}
}
// Return an object containing targeting parameters used to select messages
_getMessagesContext() {
const { messageImpressions, previousSessionEnd } = this.state;
return {
get messageImpressions() {
return messageImpressions;
},
get previousSessionEnd() {
return previousSessionEnd;
},
};
}
async evaluateExpression(target, { expression, context }) {
const channel = target || this.messageChannel;
let evaluationStatus;
try {
evaluationStatus = {
result: await ASRouterTargeting.isMatch(expression, context),
success: true,
};
} catch (e) {
evaluationStatus = { result: e.message, success: false };
}
channel.sendAsyncMessage(OUTGOING_MESSAGE_NAME, {
type: "ADMIN_SET_STATE",
data: {
...this.state,
evaluationStatus,
},
});
}
_orderBundle(bundle) {
return bundle.sort((a, b) => a.order - b.order);
}
// Work out if a message can be shown based on its and its provider's frequency caps.
isBelowFrequencyCaps(message) {
const { providers, messageImpressions, providerImpressions } = this.state;
const provider = providers.find(p => p.id === message.provider);
const impressionsForMessage = messageImpressions[message.id];
const impressionsForProvider = providerImpressions[message.provider];
return (
this._isBelowItemFrequencyCap(
message,
impressionsForMessage,
MAX_MESSAGE_LIFETIME_CAP
) && this._isBelowItemFrequencyCap(provider, impressionsForProvider)
);
}
// Helper for isBelowFrecencyCaps - work out if the frequency cap for the given
// item has been exceeded or not
_isBelowItemFrequencyCap(item, impressions, maxLifetimeCap = Infinity) {
if (item && item.frequency && impressions && impressions.length) {
if (
item.frequency.lifetime &&
impressions.length >= Math.min(item.frequency.lifetime, maxLifetimeCap)
) {
return false;
}
if (item.frequency.custom) {
const now = Date.now();
for (const setting of item.frequency.custom) {
let { period } = setting;
const impressionsInPeriod = impressions.filter(t => now - t < period);
if (impressionsInPeriod.length >= setting.cap) {
return false;
}
}
}
}
return true;
}
async _getBundledMessages(originalMessage, target, trigger, force = false) {
let result = [];
let bundleLength;
let bundleTemplate;
let originalId;
if (originalMessage.includeBundle) {
// The original message is not part of the bundle, so don't include it
bundleLength = originalMessage.includeBundle.length;
bundleTemplate = originalMessage.includeBundle.template;
} else {
// The original message is part of the bundle
bundleLength = originalMessage.bundled;
bundleTemplate = originalMessage.template;
originalId = originalMessage.id;
// Add in a copy of the first message
result.push({
content: originalMessage.content,
id: originalMessage.id,
order: originalMessage.order || 0,
});
}
// First, find all messages of same template. These are potential matching targeting candidates
let bundledMessagesOfSameTemplate = this._getUnblockedMessages().filter(
msg =>
msg.bundled && msg.template === bundleTemplate && msg.id !== originalId
);
if (force) {
// Forcefully show the messages without targeting matching - this is for about:newtab#asrouter to show the messages
for (const message of bundledMessagesOfSameTemplate) {
result.push({ content: message.content, id: message.id });
// Stop once we have enough messages to fill a bundle
if (result.length === bundleLength) {
break;
}
}
} else {
// Find all messages that matches the targeting context
const allMessages = await this.handleMessageRequest({
messages: bundledMessagesOfSameTemplate,
triggerId: trigger && trigger.id,
triggerContext: trigger && trigger.context,
triggerParam: trigger && trigger.param,
ordered: true,
returnAll: true,
});
if (allMessages && allMessages.length) {
// Retrieve enough messages needed to fill a bundle
// Only copy the content of the message (that's what the UI cares about)
result = result.concat(
allMessages.slice(0, bundleLength).map(message => ({
content: message.content,
id: message.id,
order: message.order || 0,
// This is used to determine whether to block when action is triggered
// Only block for dynamic triplets experiment and when there are more messages available
blockOnClick:
ASRouterPreferences.trailhead.trailheadTriplet.startsWith(
"dynamic"
) &&
allMessages.length >
TRAILHEAD_CONFIG.DYNAMIC_TRIPLET_BUNDLE_LENGTH,
}))
);
}
}
// If we did not find enough messages to fill the bundle, do not send the bundle down
if (result.length < bundleLength) {
return null;
}
// The bundle may have some extra attributes, like a header, or a dismiss button, so attempt to get those strings now
// This is a temporary solution until we can use Fluent strings in the content process, in which case the content can
// handle finding these strings on its own. See bug 1488973
const extraTemplateStrings = await this._extraTemplateStrings(
originalMessage
);
return {
bundle: this._orderBundle(result),
...(extraTemplateStrings && { extraTemplateStrings }),
provider: originalMessage.provider,
template: originalMessage.template,
};
}
async _extraTemplateStrings(originalMessage) {
let extraTemplateStrings;
let localProvider = this._findProvider(originalMessage.provider);
if (localProvider && localProvider.getExtraAttributes) {
extraTemplateStrings = await localProvider.getExtraAttributes();
}
return extraTemplateStrings;
}
_findProvider(providerID) {
return this._localProviders[
this.state.providers.find(i => i.id === providerID).localProvider
];
}
_getUnblockedMessages() {
let { state } = this;
return state.messages.filter(
item =>
!state.messageBlockList.includes(item.id) &&
(!item.campaign || !state.messageBlockList.includes(item.campaign)) &&
!state.providerBlockList.includes(item.provider)
);
}
/**
* Route messages based on template to the correct module that can display them
*/
routeMessageToTarget(message, target, trigger, force = false) {
switch (message.template) {
case "whatsnew_panel_message":
if (force) {
ToolbarPanelHub.forceShowMessage(target, message);
}
break;
case "cfr_doorhanger":
if (force) {
CFRPageActions.forceRecommendation(target, message, this.dispatch);
} else {
CFRPageActions.addRecommendation(
target,
trigger.param && trigger.param.host,
message,
this.dispatch
);
}
break;
case "fxa_bookmark_panel":
if (force) {
BookmarkPanelHub._forceShowMessage(target, message);
}
break;
case "toolbar_badge":
case "update_action":
ToolbarBadgeHub.registerBadgeNotificationListener(message, { force });
break;
case "milestone_message":
CFRPageActions.showMilestone(target, message, this.dispatch, { force });
break;
default:
try {
target.sendAsyncMessage(OUTGOING_MESSAGE_NAME, {
type: "SET_MESSAGE",
data: message,
});
} catch (e) {}
break;
}
}
async _sendMessageToTarget(message, target, trigger, force = false) {
// No message is available, so send CLEAR_ALL.
if (!message) {
try {
target.sendAsyncMessage(OUTGOING_MESSAGE_NAME, { type: "CLEAR_ALL" });
} catch (e) {}
// For bundled messages, look for the rest of the bundle or else send CLEAR_ALL
} else if (message.bundled) {
const bundledMessages = await this._getBundledMessages(
message,
target,
trigger,
force
);
const action = bundledMessages
? { type: "SET_BUNDLED_MESSAGES", data: bundledMessages }
: { type: "CLEAR_ALL" };
try {
target.sendAsyncMessage(OUTGOING_MESSAGE_NAME, action);
} catch (e) {}
// For nested bundled messages, look for the desired bundle
} else if (message.includeBundle) {
const bundledMessages = await this._getBundledMessages(
message,
target,
message.includeBundle.trigger,
force
);
try {
target.sendAsyncMessage(OUTGOING_MESSAGE_NAME, {
type: "SET_MESSAGE",
data: {
...message,
trailheadTriplet:
ASRouterPreferences.trailhead.trailheadTriplet || "",
bundle: bundledMessages && bundledMessages.bundle,
},
});
} catch (e) {}
} else {
this.routeMessageToTarget(message, target, trigger, force);
}
}
async addImpression(message) {
const provider = this.state.providers.find(p => p.id === message.provider);
// We only need to store impressions for messages that have frequency, or
// that have providers that have frequency
if (message.frequency || (provider && provider.frequency)) {
const time = Date.now();
await this.setState(state => {
const messageImpressions = this._addImpressionForItem(
state,
message,
"messageImpressions",
time
);
const providerImpressions = this._addImpressionForItem(
state,
provider,
"providerImpressions",
time
);
return { messageImpressions, providerImpressions };
});
}
}
// Helper for addImpression - calculate the updated impressions object for the given
// item, then store it and return it
_addImpressionForItem(state, item, impressionsString, time) {
// The destructuring here is to avoid mutating existing objects in state as in redux
// (see https://redux.js.org/recipes/structuring-reducers/prerequisite-concepts#immutable-data-management)
const impressions = { ...state[impressionsString] };
if (item.frequency) {
impressions[item.id] = impressions[item.id]
? [...impressions[item.id]]
: [];
impressions[item.id].push(time);
this._storage.set(impressionsString, impressions);
}
return impressions;
}
/**
* getLongestPeriod
*
* @param {obj} item Either an ASRouter message or an ASRouter provider
* @returns {int|null} if the item has custom frequency caps, the longest period found in the list of caps.
if the item has no custom frequency caps, null
* @memberof _ASRouter
*/
getLongestPeriod(item) {
if (!item.frequency || !item.frequency.custom) {
return null;
}
return item.frequency.custom.sort((a, b) => b.period - a.period)[0].period;
}
/**
* cleanupImpressions - this function cleans up obsolete impressions whenever
* messages are refreshed or fetched. It will likely need to be more sophisticated in the future,
* but the current behaviour for when both message impressions and provider impressions are
* cleared is as follows (where `item` is either `message` or `provider`):
*
* 1. If the item id for a list of item impressions no longer exists in the ASRouter state, it
* will be cleared.
* 2. If the item has time-bound frequency caps but no lifetime cap, any item impressions older
* than the longest time period will be cleared.
*/
async cleanupImpressions() {
await this.setState(state => {
const messageImpressions = this._cleanupImpressionsForItems(
state,
state.messages,
"messageImpressions"
);
const providerImpressions = this._cleanupImpressionsForItems(
state,
state.providers,
"providerImpressions"
);
return { messageImpressions, providerImpressions };
});
}
// Helper for cleanupImpressions - calculate the updated impressions object for
// the given items, then store it and return it
_cleanupImpressionsForItems(state, items, impressionsString) {
const impressions = { ...state[impressionsString] };
let needsUpdate = false;
Object.keys(impressions).forEach(id => {
const [item] = items.filter(x => x.id === id);
// Don't keep impressions for items that no longer exist
if (!item || !item.frequency || !Array.isArray(impressions[id])) {
delete impressions[id];
needsUpdate = true;
return;
}
if (!impressions[id].length) {
return;
}
// If we don't want to store impressions older than the longest period
if (item.frequency.custom && !item.frequency.lifetime) {
const now = Date.now();
impressions[id] = impressions[id].filter(
t => now - t < this.getLongestPeriod(item)
);
needsUpdate = true;
}
});
if (needsUpdate) {
this._storage.set(impressionsString, impressions);
}
return impressions;
}
handleMessageRequest({
messages: candidates,
triggerId,
triggerParam,
triggerContext,
template,
provider,
ordered = false,
returnAll = false,
}) {
const messages =
candidates ||
this._getUnblockedMessages()
.filter(m => {
if (provider && m.provider !== provider) {
return false;
}
if (template && m.template !== template) {
return false;
}
if (triggerId && !m.trigger) {
return false;
}
if (triggerId && m.trigger.id !== triggerId) {
return false;
}
return true;
})
.filter(m => this.isBelowFrequencyCaps(m));
const shouldCache = messages.every(m =>
JEXL_PROVIDER_CACHE.has(m.provider)
);
const context = this._getMessagesContext();
// Find a message that matches the targeting context as well as the trigger context (if one is provided)
// If no trigger is provided, we should find a message WITHOUT a trigger property defined.
return ASRouterTargeting.findMatchingMessage({
messages,
trigger: triggerId && {
id: triggerId,
param: triggerParam,
context: triggerContext,
},
context,
onError: this._handleTargetingError,
ordered,
shouldCache,
returnAll,
});
}
async setMessageById(id, target, force = true, action = {}) {
const newMessage = this.getMessageById(id);
await this._sendMessageToTarget(newMessage, target, action.data, force);
}
async blockMessageById(idOrIds) {
const idsToBlock = Array.isArray(idOrIds) ? idOrIds : [idOrIds];
await this.setState(state => {
const messageBlockList = [...state.messageBlockList];
const messageImpressions = { ...state.messageImpressions };
idsToBlock.forEach(id => {
const message = state.messages.find(m => m.id === id);
const idToBlock = message && message.campaign ? message.campaign : id;
if (!messageBlockList.includes(idToBlock)) {
messageBlockList.push(idToBlock);
}
// When a message is blocked, its impressions should be cleared as well
delete messageImpressions[id];
});
this._storage.set("messageBlockList", messageBlockList);
this._storage.set("messageImpressions", messageImpressions);
return { messageBlockList, messageImpressions };
});
}
unblockMessageById(id) {
return this.setState(state => {
const messageBlockList = [...state.messageBlockList];
const message = state.messages.find(m => m.id === id);
const idToUnblock = message && message.campaign ? message.campaign : id;
messageBlockList.splice(messageBlockList.indexOf(idToUnblock), 1);
this._storage.set("messageBlockList", messageBlockList);
return { messageBlockList };
});
}
async blockProviderById(idOrIds) {
const idsToBlock = Array.isArray(idOrIds) ? idOrIds : [idOrIds];
await this.setState(state => {
const providerBlockList = [...state.providerBlockList, ...idsToBlock];
// When a provider is blocked, its impressions should be cleared as well
const providerImpressions = { ...state.providerImpressions };
idsToBlock.forEach(id => delete providerImpressions[id]);
this._storage.set("providerBlockList", providerBlockList);
return { providerBlockList, providerImpressions };
});
}
_validPreviewEndpoint(url) {
try {
const endpoint = new URL(url);
if (!this.WHITELIST_HOSTS[endpoint.host]) {
Cu.reportError(
`The preview URL host ${endpoint.host} is not in the whitelist.`
);
}
if (endpoint.protocol !== "https:") {
Cu.reportError("The URL protocol is not https.");
}
return (
endpoint.protocol === "https:" && this.WHITELIST_HOSTS[endpoint.host]
);
} catch (e) {
return false;
}
}
// Ensure we switch to the Onboarding message after RTAMO addon was installed
_updateOnboardingState() {
let addonInstallObs = (subject, topic) => {
Services.obs.removeObserver(
addonInstallObs,
"webextension-install-notify"
);
this.messageChannel.sendAsyncMessage(OUTGOING_MESSAGE_NAME, {
type: "CLEAR_INTERRUPT",
});
};
Services.obs.addObserver(addonInstallObs, "webextension-install-notify");
}
_loadSnippetsWhitelistHosts() {
let additionalHosts = [];
const whitelistPrefValue = Services.prefs.getStringPref(
SNIPPETS_ENDPOINT_WHITELIST,
""
);
try {
additionalHosts = JSON.parse(whitelistPrefValue);
} catch (e) {
if (whitelistPrefValue) {
Cu.reportError(
`Pref ${SNIPPETS_ENDPOINT_WHITELIST} value is not valid JSON`
);
}
}
if (!additionalHosts.length) {
return DEFAULT_WHITELIST_HOSTS;
}
// If there are additional hosts we want to whitelist, add them as
// `preview` so that the updateCycle is 0
return additionalHosts.reduce(
(whitelist_hosts, host) => {
whitelist_hosts[host] = "preview";
Services.console.logStringMessage(`Adding ${host} to whitelist hosts.`);
return whitelist_hosts;
},
{ ...DEFAULT_WHITELIST_HOSTS }
);
}
// To be passed to ASRouterTriggerListeners
async _triggerHandler(target, trigger) {
// Disable ASRouterTriggerListeners in kiosk mode.
if (BrowserHandler.kiosk) {
return;
}
await this.onMessage({
target,
data: { type: "TRIGGER", data: { trigger } },
});
}
_removePreviewEndpoint(state) {
state.providers = state.providers.filter(p => p.id !== "preview");
return state;
}
async _addPreviewEndpoint(url, portID) {
// When you view a preview snippet we want to hide all real content
const providers = [...this.state.providers];
if (
this._validPreviewEndpoint(url) &&
!providers.find(p => p.url === url)
) {
this.dispatchToAS(
ac.OnlyToOneContent({ type: at.SNIPPETS_PREVIEW_MODE }, portID)
);
providers.push({
id: "preview",
type: "remote",
url,
updateCycleInMs: 0,
});
await this.setState({ providers });
}
}
// Windows specific calls to write attribution data
// Used by `forceAttribution` to set required targeting attributes for
// RTAMO messages. This should only be called from within about:newtab#asrouter
/* istanbul ignore next */
async _writeAttributionFile(data) {
let appDir = Services.dirsvc.get("LocalAppData", Ci.nsIFile);
let file = appDir.clone();
file.append(Services.appinfo.vendor || "mozilla");
file.append(AppConstants.MOZ_APP_NAME);
await OS.File.makeDir(file.path, {
from: appDir.path,
ignoreExisting: true,
});
file.append("postSigningData");
await OS.File.writeAtomic(file.path, data);
}
/**
* forceAttribution - this function should only be called from within about:newtab#asrouter.
* It forces the browser attribution to be set to something specified in asrouter admin
* tools, and reloads the providers in order to get messages that are dependant on this
* attribution data (see Return to AMO flow in bug 1475354 for example). Note - OSX and Windows only
* @param {data} Object an object containing the attribtion data that came from asrouter admin page
*/
/* istanbul ignore next */
async forceAttribution(data) {
// Extract the parameters from data that will make up the referrer url
const { source, campaign, content } = data;
if (AppConstants.platform === "win") {
const attributionData = `source=${source}&campaign=${campaign}&content=${content}`;
this._writeAttributionFile(encodeURIComponent(attributionData));
} else if (AppConstants.platform === "macosx") {
let appPath = Services.dirsvc.get("GreD", Ci.nsIFile).parent.parent.path;
let attributionSvc = Cc["@mozilla.org/mac-attribution;1"].getService(
Ci.nsIMacAttributionService
);
let referrer = `https://www.mozilla.org/anything/?utm_campaign=${campaign}&utm_source=${source}&utm_content=${encodeURIComponent(
content
)}`;
// This sets the Attribution to be the referrer
attributionSvc.setReferrerUrl(appPath, referrer, true);
}
// Clear cache call is only possible in a testing environment
let env = Cc["@mozilla.org/process/environment;1"].getService(
Ci.nsIEnvironment
);
env.set("XPCSHELL_TEST_PROFILE_DIR", "testing");
// Clear and refresh Attribution, and then fetch the messages again to update
AttributionCode._clearCache();
await AttributionCode.getAttrDataAsync();
this._updateMessageProviders();
await this.loadMessagesFromAllProviders();
}
async handleUserAction({ data: action, target }) {
switch (action.type) {
case ra.SHOW_MIGRATION_WIZARD:
MigrationUtils.showMigrationWizard(target.browser.ownerGlobal, [
MigrationUtils.MIGRATION_ENTRYPOINT_NEWTAB,
]);
break;
case ra.OPEN_PRIVATE_BROWSER_WINDOW:
// Forcefully open about:privatebrowsing
target.browser.ownerGlobal.OpenBrowserWindow({ private: true });
break;
case ra.OPEN_URL:
target.browser.ownerGlobal.openLinkIn(
action.data.args,
action.data.where || "current",
{
private: false,
triggeringPrincipal: Services.scriptSecurityManager.createNullPrincipal(
{}
),
csp: null,
}
);
break;
case ra.OPEN_ABOUT_PAGE:
target.browser.ownerGlobal.openTrustedLinkIn(
`about:${action.data.args}`,
"tab"
);
break;
case ra.OPEN_PREFERENCES_PAGE:
target.browser.ownerGlobal.openPreferences(action.data.category);
break;
case ra.OPEN_APPLICATIONS_MENU:
UITour.showMenu(target.browser.ownerGlobal, action.data.args);
break;
case ra.HIGHLIGHT_FEATURE:
const highlight = await UITour.getTarget(
target.browser.ownerGlobal,
action.data.args
);
if (highlight) {
await UITour.showHighlight(
target.browser.ownerGlobal,
highlight,
"none",
{ autohide: true }
);
}
break;
case ra.INSTALL_ADDON_FROM_URL:
this._updateOnboardingState();
await MessageLoaderUtils.installAddonFromURL(
target.browser,
action.data.url,
action.data.telemetrySource
);
break;
case ra.PIN_CURRENT_TAB:
let tab = target.browser.ownerGlobal.gBrowser.selectedTab;
target.browser.ownerGlobal.gBrowser.pinTab(tab);
target.browser.ownerGlobal.ConfirmationHint.show(tab, "pinTab", {
showDescription: true,
});
break;
case ra.SHOW_FIREFOX_ACCOUNTS:
const url = await FxAccounts.config.promiseConnectAccountURI(
"snippets"
);
// We want to replace the current tab.
target.browser.ownerGlobal.openLinkIn(url, "current", {
private: false,
triggeringPrincipal: Services.scriptSecurityManager.createNullPrincipal(
{}
),
csp: null,
});
break;
case ra.OPEN_PROTECTION_PANEL:
let { gProtectionsHandler } = target.browser.ownerGlobal;
gProtectionsHandler.showProtectionsPopup({});
break;
case ra.OPEN_PROTECTION_REPORT:
target.browser.ownerGlobal.gProtectionsHandler.openProtections();
break;
case ra.DISABLE_STP_DOORHANGERS:
await this.blockMessageById([
"SOCIAL_TRACKING_PROTECTION",
"FINGERPRINTERS_PROTECTION",
"CRYPTOMINERS_PROTECTION",
]);
break;
}
}
/**
* sendAsyncMessageToPreloaded - Sends an action to each preloaded browser, if any
*
* @param {obj} action An action to be sent to content
*/
sendAsyncMessageToPreloaded(action) {
const preloadedBrowsers = this.getPreloadedBrowser();
if (preloadedBrowsers) {
for (let preloadedBrowser of preloadedBrowsers) {
try {
preloadedBrowser.sendAsyncMessage(OUTGOING_MESSAGE_NAME, action);
} catch (e) {
// The preloaded page is no longer available, so just ignore.
}
}
}
}
/**
* getPreloadedBrowser - Retrieve the port of any preloaded browsers
*
* @return {Array|null} An array of ports belonging to the preloaded browsers, or null
* if there aren't any preloaded browsers
*/
getPreloadedBrowser() {
let preloadedPorts = [];
for (let port of this.messageChannel.messagePorts) {
if (this.isPreloadedBrowser(port.browser)) {
preloadedPorts.push(port);
}
}
return preloadedPorts.length ? preloadedPorts : null;
}
/**
* isPreloadedBrowser - Returns true if the passed browser has been preloaded
* for faster rendering of new tabs.
*
* @param {} A to check.
* @return {boolean} True if the browser is preloaded.
* False if there aren't any preloaded browsers
*/
isPreloadedBrowser(browser) {
return browser.getAttribute("preloadedState") === "preloaded";
}
dispatch(action, target) {
this.onMessage({ data: action, target });
}
async sendNewTabMessage(target, options = {}) {
const { endpoint } = options;
let message;
// Load preview endpoint for snippets if one is sent
if (endpoint) {
await this._addPreviewEndpoint(endpoint.url, target.portID);
}
// Load all messages
await this.loadMessagesFromAllProviders();
if (endpoint) {
message = await this.handleMessageRequest({ provider: "preview" });
// We don't want to cache preview messages, remove them after we selected the message to show
if (message) {
await this.setState(state => ({
messages: state.messages.filter(m => m.id !== message.id),
}));
}
} else {
// On new tab, send cards if they match; othwerise send a snippet
message = await this.handleMessageRequest({
template: "extended_triplets",
});
// If no extended triplets message was returned, show snippets instead
if (!message) {
message = await this.handleMessageRequest({ provider: "snippets" });
}
}
await this._sendMessageToTarget(message, target);
}
async sendTriggerMessage(target, trigger) {
await this.loadMessagesFromAllProviders();
if (trigger.id === "firstRun") {
// On about welcome, set trailhead message seen on receiving firstrun trigger
await this.setTrailHeadMessageSeen();
}
const message = await this.handleMessageRequest({
triggerId: trigger.id,
triggerParam: trigger.param,
triggerContext: trigger.context,
});
await this._sendMessageToTarget(message, target, trigger);
}
/* eslint-disable complexity */
async onMessage({ data: action, target }) {
switch (action.type) {
case "USER_ACTION":
if (action.data.type in ra) {
await this.handleUserAction({ data: action.data, target });
}
break;
case "NEWTAB_MESSAGE_REQUEST":
await this.waitForInitialized;
await this.sendNewTabMessage(target, action.data);
break;
case "TRIGGER":
await this.waitForInitialized;
await this.sendTriggerMessage(
target,
action.data && action.data.trigger
);
break;
case "BLOCK_MESSAGE_BY_ID":
await this.blockMessageById(action.data.id);
// Block the message but don't dismiss it in case the action taken has
// another state that needs to be visible
if (action.data.preventDismiss) {
break;
}
const outgoingMessage = {
type: "CLEAR_MESSAGE",
data: { id: action.data.id },
};
if (action.data.preloadedOnly) {
this.sendAsyncMessageToPreloaded(outgoingMessage);
} else {
this.messageChannel.sendAsyncMessage(
OUTGOING_MESSAGE_NAME,
outgoingMessage
);
}
break;
case "DISMISS_MESSAGE_BY_ID":
this.messageChannel.sendAsyncMessage(OUTGOING_MESSAGE_NAME, {
type: "CLEAR_MESSAGE",
data: { id: action.data.id },
});
break;
case "BLOCK_PROVIDER_BY_ID":
await this.blockProviderById(action.data.id);
this.messageChannel.sendAsyncMessage(OUTGOING_MESSAGE_NAME, {
type: "CLEAR_PROVIDER",
data: { id: action.data.id },
});
break;
case "BLOCK_BUNDLE":
await this.blockMessageById(action.data.bundle.map(b => b.id));
this.messageChannel.sendAsyncMessage(OUTGOING_MESSAGE_NAME, {
type: "CLEAR_BUNDLE",
});
break;
case "UNBLOCK_MESSAGE_BY_ID":
this.unblockMessageById(action.data.id);
break;
case "UNBLOCK_PROVIDER_BY_ID":
await this.setState(state => {
const providerBlockList = [...state.providerBlockList];
providerBlockList.splice(
providerBlockList.indexOf(action.data.id),
1
);
this._storage.set("providerBlockList", providerBlockList);
return { providerBlockList };
});
break;
case "UNBLOCK_BUNDLE":
await this.setState(state => {
const messageBlockList = [...state.messageBlockList];
for (let message of action.data.bundle) {
messageBlockList.splice(messageBlockList.indexOf(message.id), 1);
}
this._storage.set("messageBlockList", messageBlockList);
return { messageBlockList };
});
break;
case "OVERRIDE_MESSAGE":
await this.setMessageById(action.data.id, target, true, action);
break;
case "ADMIN_CONNECT_STATE":
if (action.data && action.data.endpoint) {
this._addPreviewEndpoint(action.data.endpoint.url, target.portID);
await this.loadMessagesFromAllProviders();
} else {
await this._updateAdminState(target);
}
break;
case "IMPRESSION":
await this.addImpression(action.data);
break;
case "DOORHANGER_TELEMETRY":
case "TOOLBAR_BADGE_TELEMETRY":
case "TOOLBAR_PANEL_TELEMETRY":
if (this.dispatchToAS) {
this.dispatchToAS(ac.ASRouterUserEvent(action.data));
}
break;
case "EXPIRE_QUERY_CACHE":
QueryCache.expireAll();
break;
case "ENABLE_PROVIDER":
ASRouterPreferences.enableOrDisableProvider(action.data, true);
break;
case "DISABLE_PROVIDER":
ASRouterPreferences.enableOrDisableProvider(action.data, false);
break;
case "RESET_PROVIDER_PREF":
ASRouterPreferences.resetProviderPref();
break;
case "SET_PROVIDER_USER_PREF":
ASRouterPreferences.setUserPreference(
action.data.id,
action.data.value
);
break;
case "EVALUATE_JEXL_EXPRESSION":
this.evaluateExpression(target, action.data);
break;
case "FORCE_ATTRIBUTION":
this.forceAttribution(action.data);
break;
default:
Cu.reportError("Unknown message received");
break;
}
}
}
this._ASRouter = _ASRouter;
this.TRAILHEAD_CONFIG = TRAILHEAD_CONFIG;
/**
* ASRouter - singleton instance of _ASRouter that controls all messages
* in the new tab page.
*/
this.ASRouter = new _ASRouter();
const EXPORTED_SYMBOLS = [
"_ASRouter",
"ASRouter",
"MessageLoaderUtils",
"TRAILHEAD_CONFIG",
];
================================================
FILE: lib/ASRouterFeed.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/. */
const { actionTypes: at } = ChromeUtils.import(
"resource://activity-stream/common/Actions.jsm"
);
const { ASRouter } = ChromeUtils.import(
"resource://activity-stream/lib/ASRouter.jsm"
);
/**
* @class ASRouterFeed - Connects ASRouter singleton (see above) to Activity Stream's
* store so that it can use the RemotePageManager.
*/
class ASRouterFeed {
constructor(options = {}) {
this.router = options.router || ASRouter;
}
async enable() {
if (!this.router.initialized) {
await this.router.init(
this.store._messageChannel.channel,
this.store.dbStorage.getDbTable("snippets"),
this.store.dispatch
);
}
}
disable() {
if (this.router.initialized) {
this.router.uninit();
}
}
onAction(action) {
switch (action.type) {
case at.INIT:
this.enable();
break;
case at.UNINIT:
this.disable();
break;
}
}
}
this.ASRouterFeed = ASRouterFeed;
const EXPORTED_SYMBOLS = ["ASRouterFeed"];
================================================
FILE: lib/ASRouterPreferences.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 { XPCOMUtils } = ChromeUtils.import(
"resource://gre/modules/XPCOMUtils.jsm"
);
const PROVIDER_PREF_BRANCH =
"browser.newtabpage.activity-stream.asrouter.providers.";
const DEVTOOLS_PREF =
"browser.newtabpage.activity-stream.asrouter.devtoolsEnabled";
const FXA_USERNAME_PREF = "services.sync.username";
const FIRST_RUN_PREF = "trailhead.firstrun.branches";
const DEFAULT_FIRSTRUN_TRIPLET = "supercharge";
const DEFAULT_FIRSTRUN_INTERRUPT = "join";
function getTrailheadConfigFromPref(value) {
let [interrupt, triplet] = value.split("-");
return {
trailheadInterrupt: interrupt || DEFAULT_FIRSTRUN_INTERRUPT,
trailheadTriplet: triplet || DEFAULT_FIRSTRUN_TRIPLET,
};
}
XPCOMUtils.defineLazyPreferenceGetter(
this,
"trailheadPrefs",
FIRST_RUN_PREF,
"",
null,
getTrailheadConfigFromPref
);
const DEFAULT_STATE = {
_initialized: false,
_providers: null,
_providerPrefBranch: PROVIDER_PREF_BRANCH,
_devtoolsEnabled: null,
_devtoolsPref: DEVTOOLS_PREF,
};
const USER_PREFERENCES = {
snippets: "browser.newtabpage.activity-stream.feeds.snippets",
cfrAddons: "browser.newtabpage.activity-stream.asrouter.userprefs.cfr.addons",
cfrFeatures:
"browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features",
};
// Preferences that influence targeting attributes. When these change we need
// to re-evaluate if the message targeting still matches
const TARGETING_PREFERENCES = [FXA_USERNAME_PREF];
const TEST_PROVIDERS = [
{
id: "snippets_local_testing",
type: "local",
localProvider: "SnippetsTestMessageProvider",
enabled: true,
},
{
id: "panel_local_testing",
type: "local",
localProvider: "PanelTestProvider",
enabled: true,
},
];
class _ASRouterPreferences {
constructor() {
Object.assign(this, DEFAULT_STATE);
this._callbacks = new Set();
XPCOMUtils.defineLazyPreferenceGetter(
this,
"personalizedCfrScores",
"browser.messaging-system.personalized-cfr.scores",
"{}",
null,
this._transformPersonalizedCfrScores
);
XPCOMUtils.defineLazyPreferenceGetter(
this,
"personalizedCfrThreshold",
"browser.messaging-system.personalized-cfr.score-threshold",
5000
);
}
_transformPersonalizedCfrScores(value) {
let result = {};
try {
result = JSON.parse(value);
} catch (e) {
Cu.reportError(e);
}
return result;
}
_getProviderConfig() {
const prefList = Services.prefs.getChildList(this._providerPrefBranch);
return prefList.reduce((filtered, pref) => {
let value;
try {
value = JSON.parse(Services.prefs.getStringPref(pref, ""));
} catch (e) {
Cu.reportError(
`Could not parse ASRouter preference. Try resetting ${pref} in about:config.`
);
}
if (value) {
filtered.push(value);
}
return filtered;
}, []);
}
// istanbul ignore next
get trailhead() {
return trailheadPrefs;
}
get providers() {
if (!this._initialized || this._providers === null) {
const config = this._getProviderConfig();
const providers = config.map(provider => Object.freeze(provider));
if (this.devtoolsEnabled) {
providers.unshift(...TEST_PROVIDERS);
}
this._providers = Object.freeze(providers);
}
return this._providers;
}
enableOrDisableProvider(id, value) {
const providers = this._getProviderConfig();
const config = providers.find(p => p.id === id);
if (!config) {
Cu.reportError(
`Cannot set enabled state for '${id}' because the pref ${
this._providerPrefBranch
}${id} does not exist or is not correctly formatted.`
);
return;
}
Services.prefs.setStringPref(
this._providerPrefBranch + id,
JSON.stringify({ ...config, enabled: value })
);
}
resetProviderPref() {
for (const pref of Services.prefs.getChildList(this._providerPrefBranch)) {
Services.prefs.clearUserPref(pref);
}
for (const id of Object.keys(USER_PREFERENCES)) {
Services.prefs.clearUserPref(USER_PREFERENCES[id]);
}
}
get devtoolsEnabled() {
if (!this._initialized || this._devtoolsEnabled === null) {
this._devtoolsEnabled = Services.prefs.getBoolPref(
this._devtoolsPref,
false
);
}
return this._devtoolsEnabled;
}
observe(aSubject, aTopic, aPrefName) {
if (aPrefName && aPrefName.startsWith(this._providerPrefBranch)) {
this._providers = null;
} else if (aPrefName === this._devtoolsPref) {
this._providers = null;
this._devtoolsEnabled = null;
}
this._callbacks.forEach(cb => cb(aPrefName));
}
getUserPreference(providerId) {
if (!USER_PREFERENCES[providerId]) {
return null;
}
return Services.prefs.getBoolPref(USER_PREFERENCES[providerId], true);
}
getAllUserPreferences() {
const values = {};
for (const id of Object.keys(USER_PREFERENCES)) {
values[id] = this.getUserPreference(id);
}
return values;
}
setUserPreference(providerId, value) {
if (!USER_PREFERENCES[providerId]) {
return;
}
Services.prefs.setBoolPref(USER_PREFERENCES[providerId], value);
}
addListener(callback) {
this._callbacks.add(callback);
}
removeListener(callback) {
this._callbacks.delete(callback);
}
init() {
if (this._initialized) {
return;
}
Services.prefs.addObserver(this._providerPrefBranch, this);
Services.prefs.addObserver(this._devtoolsPref, this);
for (const id of Object.keys(USER_PREFERENCES)) {
Services.prefs.addObserver(USER_PREFERENCES[id], this);
}
for (const targetingPref of TARGETING_PREFERENCES) {
Services.prefs.addObserver(targetingPref, this);
}
this._initialized = true;
}
uninit() {
if (this._initialized) {
Services.prefs.removeObserver(this._providerPrefBranch, this);
Services.prefs.removeObserver(this._devtoolsPref, this);
for (const id of Object.keys(USER_PREFERENCES)) {
Services.prefs.removeObserver(USER_PREFERENCES[id], this);
}
for (const targetingPref of TARGETING_PREFERENCES) {
Services.prefs.removeObserver(targetingPref, this);
}
}
Object.assign(this, DEFAULT_STATE);
this._callbacks.clear();
}
}
this._ASRouterPreferences = _ASRouterPreferences;
this.ASRouterPreferences = new _ASRouterPreferences();
this.TEST_PROVIDERS = TEST_PROVIDERS;
this.TARGETING_PREFERENCES = TARGETING_PREFERENCES;
this.getTrailheadConfigFromPref = getTrailheadConfigFromPref;
const EXPORTED_SYMBOLS = [
"_ASRouterPreferences",
"ASRouterPreferences",
"TEST_PROVIDERS",
"TARGETING_PREFERENCES",
"getTrailheadConfigFromPref",
];
================================================
FILE: lib/ASRouterTargeting.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/. */
const SEARCH_REGION_PREF = "browser.search.region";
const FXA_ENABLED_PREF = "identity.fxaccounts.enabled";
const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
const { XPCOMUtils } = ChromeUtils.import(
"resource://gre/modules/XPCOMUtils.jsm"
);
XPCOMUtils.defineLazyModuleGetters(this, {
ASRouterPreferences: "resource://activity-stream/lib/ASRouterPreferences.jsm",
AddonManager: "resource://gre/modules/AddonManager.jsm",
NewTabUtils: "resource://gre/modules/NewTabUtils.jsm",
ProfileAge: "resource://gre/modules/ProfileAge.jsm",
ShellService: "resource:///modules/ShellService.jsm",
TelemetryEnvironment: "resource://gre/modules/TelemetryEnvironment.jsm",
AppConstants: "resource://gre/modules/AppConstants.jsm",
AttributionCode: "resource:///modules/AttributionCode.jsm",
FilterExpressions:
"resource://gre/modules/components-utils/FilterExpressions.jsm",
fxAccounts: "resource://gre/modules/FxAccounts.jsm",
});
XPCOMUtils.defineLazyPreferenceGetter(
this,
"cfrFeaturesUserPref",
"browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features",
true
);
XPCOMUtils.defineLazyPreferenceGetter(
this,
"cfrAddonsUserPref",
"browser.newtabpage.activity-stream.asrouter.userprefs.cfr.addons",
true
);
XPCOMUtils.defineLazyPreferenceGetter(
this,
"isWhatsNewPanelEnabled",
"browser.messaging-system.whatsNewPanel.enabled",
false
);
XPCOMUtils.defineLazyPreferenceGetter(
this,
"isFxABadgeEnabled",
"browser.messaging-system.fxatoolbarbadge.enabled",
true
);
XPCOMUtils.defineLazyPreferenceGetter(
this,
"hasAccessedFxAPanel",
"identity.fxaccounts.toolbar.accessed",
false
);
XPCOMUtils.defineLazyPreferenceGetter(
this,
"clientsDevicesDesktop",
"services.sync.clients.devices.desktop",
0
);
XPCOMUtils.defineLazyPreferenceGetter(
this,
"clientsDevicesMobile",
"services.sync.clients.devices.mobile",
0
);
XPCOMUtils.defineLazyPreferenceGetter(
this,
"syncNumClients",
"services.sync.numClients",
0
);
XPCOMUtils.defineLazyPreferenceGetter(
this,
"devtoolsSelfXSSCount",
"devtools.selfxss.count",
0
);
XPCOMUtils.defineLazyPreferenceGetter(
this,
"browserSearchRegion",
SEARCH_REGION_PREF,
""
);
XPCOMUtils.defineLazyPreferenceGetter(
this,
"isFxAEnabled",
FXA_ENABLED_PREF,
true
);
XPCOMUtils.defineLazyPreferenceGetter(
this,
"isXPIInstallEnabled",
"xpinstall.enabled",
true
);
XPCOMUtils.defineLazyPreferenceGetter(
this,
"snippetsUserPref",
"browser.newtabpage.activity-stream.feeds.snippets",
true
);
XPCOMUtils.defineLazyServiceGetter(
this,
"TrackingDBService",
"@mozilla.org/tracking-db-service;1",
"nsITrackingDBService"
);
const FXA_USERNAME_PREF = "services.sync.username";
const MOZ_JEXL_FILEPATH = "mozjexl";
const { activityStreamProvider: asProvider } = NewTabUtils;
const FRECENT_SITES_UPDATE_INTERVAL = 6 * 60 * 60 * 1000; // Six hours
const FRECENT_SITES_IGNORE_BLOCKED = false;
const FRECENT_SITES_NUM_ITEMS = 25;
const FRECENT_SITES_MIN_FRECENCY = 100;
const CACHE_EXPIRATION = 60 * 1000;
const jexlEvaluationCache = new Map();
/**
* CachedTargetingGetter
* @param property {string} Name of the method called on ActivityStreamProvider
* @param options {{}?} Options object passsed to ActivityStreamProvider method
* @param updateInterval {number?} Update interval for query. Defaults to FRECENT_SITES_UPDATE_INTERVAL
*/
function CachedTargetingGetter(
property,
options = null,
updateInterval = FRECENT_SITES_UPDATE_INTERVAL
) {
return {
_lastUpdated: 0,
_value: null,
// For testing
expire() {
this._lastUpdated = 0;
this._value = null;
},
async get() {
const now = Date.now();
if (now - this._lastUpdated >= updateInterval) {
this._value = await asProvider[property](options);
this._lastUpdated = now;
}
return this._value;
},
};
}
function CheckBrowserNeedsUpdate(
updateInterval = FRECENT_SITES_UPDATE_INTERVAL
) {
const UpdateChecker = Cc["@mozilla.org/updates/update-checker;1"];
const checker = {
_lastUpdated: 0,
_value: null,
// For testing. Avoid update check network call.
setUp(value) {
this._lastUpdated = Date.now();
this._value = value;
},
expire() {
this._lastUpdated = 0;
this._value = null;
},
get() {
return new Promise((resolve, reject) => {
const now = Date.now();
const updateServiceListener = {
onCheckComplete(request, updates) {
checker._value = !!updates.length;
resolve(checker._value);
},
onError(request, update) {
reject(request);
},
QueryInterface: ChromeUtils.generateQI(["nsIUpdateCheckListener"]),
};
if (UpdateChecker && now - this._lastUpdated >= updateInterval) {
const checkerInstance = UpdateChecker.createInstance(
Ci.nsIUpdateChecker
);
checkerInstance.checkForUpdates(updateServiceListener, true);
this._lastUpdated = now;
} else {
resolve(this._value);
}
});
},
};
return checker;
}
const QueryCache = {
expireAll() {
Object.keys(this.queries).forEach(query => {
this.queries[query].expire();
});
},
queries: {
TopFrecentSites: new CachedTargetingGetter("getTopFrecentSites", {
ignoreBlocked: FRECENT_SITES_IGNORE_BLOCKED,
numItems: FRECENT_SITES_NUM_ITEMS,
topsiteFrecency: FRECENT_SITES_MIN_FRECENCY,
onePerDomain: true,
includeFavicon: false,
}),
TotalBookmarksCount: new CachedTargetingGetter("getTotalBookmarksCount"),
CheckBrowserNeedsUpdate: new CheckBrowserNeedsUpdate(),
RecentBookmarks: new CachedTargetingGetter("getRecentBookmarks"),
},
};
/**
* sortMessagesByWeightedRank
*
* Each message has an associated weight, which is guaranteed to be strictly
* positive. Sort the messages so that higher weighted messages are more likely
* to come first.
*
* Specifically, sort them so that the probability of message x_1 with weight
* w_1 appearing before message x_2 with weight w_2 is (w_1 / (w_1 + w_2)).
*
* This is equivalent to requiring that x_1 appearing before x_2 is (w_1 / w_2)
* "times" as likely as x_2 appearing before x_1.
*
* See Bug 1484996, Comment 2 for a justification of the method.
*
* @param {Array} messages - A non-empty array of messages to sort, all with
* strictly positive weights
* @returns the sorted array
*/
function sortMessagesByWeightedRank(messages) {
return messages
.map(message => ({
message,
rank: Math.pow(Math.random(), 1 / message.weight),
}))
.sort((a, b) => b.rank - a.rank)
.map(({ message }) => message);
}
/**
* getSortedMessages - Given an array of Messages, applies sorting and filtering rules
* in expected order.
*
* @param {Array} messages
* @param {{}} options
* @param {boolean} options.ordered - Should .order be used instead of random weighted sorting?
* @returns {Array}
*/
function getSortedMessages(messages, options = {}) {
let { ordered } = { ordered: false, ...options };
let result = messages;
let hasScores;
if (!ordered) {
result = sortMessagesByWeightedRank(result);
}
result.sort((a, b) => {
// If we find at least one score, we need to apply filtering by threshold at the end.
if (!isNaN(a.score) || !isNaN(b.score)) {
hasScores = true;
}
// First sort by score if we're doing personalization:
if (a.score > b.score || (!isNaN(a.score) && isNaN(b.score))) {
return -1;
}
if (a.score < b.score || (isNaN(a.score) && !isNaN(b.score))) {
return 1;
}
// Next, sort by priority
if (a.priority > b.priority || (!isNaN(a.priority) && isNaN(b.priority))) {
return -1;
}
if (a.priority < b.priority || (isNaN(a.priority) && !isNaN(b.priority))) {
return 1;
}
// Sort messages with targeting expressions higher than those with none
if (a.targeting && !b.targeting) {
return -1;
}
if (!a.targeting && b.targeting) {
return 1;
}
// Next, sort by order *ascending* if ordered = true
if (ordered) {
if (a.order > b.order || (!isNaN(a.order) && isNaN(b.order))) {
return 1;
}
if (a.order < b.order || (isNaN(a.order) && !isNaN(b.order))) {
return -1;
}
}
return 0;
});
if (hasScores && !isNaN(ASRouterPreferences.personalizedCfrThreshold)) {
return result.filter(
message =>
isNaN(message.score) ||
message.score >= ASRouterPreferences.personalizedCfrThreshold
);
}
return result;
}
const TargetingGetters = {
get locale() {
return Services.locale.appLocaleAsLangTag;
},
get localeLanguageCode() {
return (
Services.locale.appLocaleAsLangTag &&
Services.locale.appLocaleAsLangTag.substr(0, 2)
);
},
get browserSettings() {
const { settings } = TelemetryEnvironment.currentEnvironment;
return {
// This way of getting attribution is deprecated - use atttributionData instead
attribution: settings.attribution,
update: settings.update,
};
},
get attributionData() {
// Attribution is determined at startup - so we can use the cached attribution at this point
return AttributionCode.getCachedAttributionData();
},
get currentDate() {
return new Date();
},
get profileAgeCreated() {
return ProfileAge().then(times => times.created);
},
get profileAgeReset() {
return ProfileAge().then(times => times.reset);
},
get usesFirefoxSync() {
return Services.prefs.prefHasUserValue(FXA_USERNAME_PREF);
},
get isFxAEnabled() {
return isFxAEnabled;
},
get trailheadInterrupt() {
return ASRouterPreferences.trailhead.trailheadInterrupt;
},
get trailheadTriplet() {
return ASRouterPreferences.trailhead.trailheadTriplet;
},
get sync() {
return {
desktopDevices: clientsDevicesDesktop,
mobileDevices: clientsDevicesMobile,
totalDevices: syncNumClients,
};
},
get xpinstallEnabled() {
// This is needed for all add-on recommendations, to know if we allow xpi installs in the first place
return isXPIInstallEnabled;
},
get addonsInfo() {
return AddonManager.getActiveAddons(["extension", "service"]).then(
({ addons, fullData }) => {
const info = {};
for (const addon of addons) {
info[addon.id] = {
version: addon.version,
type: addon.type,
isSystem: addon.isSystem,
isWebExtension: addon.isWebExtension,
};
if (fullData) {
Object.assign(info[addon.id], {
name: addon.name,
userDisabled: addon.userDisabled,
installDate: addon.installDate,
});
}
}
return { addons: info, isFullData: fullData };
}
);
},
get searchEngines() {
return new Promise(resolve => {
// Note: calling init ensures this code is only executed after Search has been initialized
Services.search
.getVisibleEngines()
.then(engines => {
resolve({
current: Services.search.defaultEngine.identifier,
installed: engines
.map(engine => engine.identifier)
.filter(engine => engine),
});
})
.catch(() => resolve({ installed: [], current: "" }));
});
},
get isDefaultBrowser() {
try {
return ShellService.isDefaultBrowser();
} catch (e) {}
return null;
},
get devToolsOpenedCount() {
return devtoolsSelfXSSCount;
},
get topFrecentSites() {
return QueryCache.queries.TopFrecentSites.get().then(sites =>
sites.map(site => ({
url: site.url,
host: new URL(site.url).hostname,
frecency: site.frecency,
lastVisitDate: site.lastVisitDate,
}))
);
},
get recentBookmarks() {
return QueryCache.queries.RecentBookmarks.get();
},
get pinnedSites() {
return NewTabUtils.pinnedLinks.links.map(site =>
site
? {
url: site.url,
host: new URL(site.url).hostname,
searchTopSite: site.searchTopSite,
}
: {}
);
},
get providerCohorts() {
return ASRouterPreferences.providers.reduce((prev, current) => {
prev[current.id] = current.cohort || "";
return prev;
}, {});
},
get totalBookmarksCount() {
return QueryCache.queries.TotalBookmarksCount.get();
},
get firefoxVersion() {
return parseInt(AppConstants.MOZ_APP_VERSION.match(/\d+/), 10);
},
get region() {
return browserSearchRegion;
},
get needsUpdate() {
return QueryCache.queries.CheckBrowserNeedsUpdate.get();
},
get hasPinnedTabs() {
for (let win of Services.wm.getEnumerator("navigator:browser")) {
if (win.closed || !win.ownerGlobal.gBrowser) {
continue;
}
if (win.ownerGlobal.gBrowser.visibleTabs.filter(t => t.pinned).length) {
return true;
}
}
return false;
},
get hasAccessedFxAPanel() {
return hasAccessedFxAPanel;
},
get isWhatsNewPanelEnabled() {
return isWhatsNewPanelEnabled;
},
get isFxABadgeEnabled() {
return isFxABadgeEnabled;
},
get userPrefs() {
return {
cfrFeatures: cfrFeaturesUserPref,
cfrAddons: cfrAddonsUserPref,
snippets: snippetsUserPref,
};
},
get totalBlockedCount() {
return TrackingDBService.sumAllEvents();
},
get blockedCountByType() {
const idToTextMap = new Map([
[Ci.nsITrackingDBService.TRACKERS_ID, "trackerCount"],
[Ci.nsITrackingDBService.TRACKING_COOKIES_ID, "cookieCount"],
[Ci.nsITrackingDBService.CRYPTOMINERS_ID, "cryptominerCount"],
[Ci.nsITrackingDBService.FINGERPRINTERS_ID, "fingerprinterCount"],
[Ci.nsITrackingDBService.SOCIAL_ID, "socialCount"],
]);
const dateTo = new Date();
const dateFrom = new Date(dateTo.getTime() - 42 * 24 * 60 * 60 * 1000);
return TrackingDBService.getEventsByDateRange(dateFrom, dateTo).then(
eventsByDate => {
let totalEvents = {};
for (let blockedType of idToTextMap.values()) {
totalEvents[blockedType] = 0;
}
return eventsByDate.reduce((acc, day) => {
const type = day.getResultByName("type");
const count = day.getResultByName("count");
acc[idToTextMap.get(type)] = acc[idToTextMap.get(type)] + count;
return acc;
}, totalEvents);
}
);
},
get attachedFxAOAuthClients() {
// Explicitly catch error objects e.g. NO_ACCOUNT triggered when
// setting FXA_USERNAME_PREF from tests
return this.usesFirefoxSync
? new Promise(resolve => {
fxAccounts
.listAttachedOAuthClients()
.then(clients => {
resolve(clients);
})
.catch(() => resolve([]));
})
: [];
},
get platformName() {
return AppConstants.platform;
},
get scores() {
return ASRouterPreferences.personalizedCfrScores;
},
get scoreThreshold() {
return ASRouterPreferences.personalizedCfrThreshold;
},
};
this.ASRouterTargeting = {
Environment: TargetingGetters,
ERROR_TYPES: {
MALFORMED_EXPRESSION: "MALFORMED_EXPRESSION",
OTHER_ERROR: "OTHER_ERROR",
},
// Combines the getter properties of two objects without evaluating them
combineContexts(contextA = {}, contextB = {}) {
const sameProperty = Object.keys(contextA).find(p =>
Object.keys(contextB).includes(p)
);
if (sameProperty) {
Cu.reportError(
`Property ${sameProperty} exists in both contexts and is overwritten.`
);
}
const context = {};
Object.defineProperties(
context,
Object.getOwnPropertyDescriptors(contextA)
);
Object.defineProperties(
context,
Object.getOwnPropertyDescriptors(contextB)
);
return context;
},
isMatch(filterExpression, customContext) {
return FilterExpressions.eval(
filterExpression,
this.combineContexts(this.Environment, customContext)
);
},
isTriggerMatch(trigger = {}, candidateMessageTrigger = {}) {
if (trigger.id !== candidateMessageTrigger.id) {
return false;
} else if (
!candidateMessageTrigger.params &&
!candidateMessageTrigger.patterns
) {
return true;
}
if (!trigger.param) {
return false;
}
return (
(candidateMessageTrigger.params &&
trigger.param.host &&
candidateMessageTrigger.params.includes(trigger.param.host)) ||
(candidateMessageTrigger.params &&
trigger.param.type &&
candidateMessageTrigger.params.filter(
t => (t & trigger.param.type) === t
).length) ||
(candidateMessageTrigger.patterns &&
trigger.param.url &&
new MatchPatternSet(candidateMessageTrigger.patterns).matches(
trigger.param.url
))
);
},
/**
* getCachedEvaluation - Return a cached jexl evaluation if available
*
* @param {string} targeting JEXL expression to lookup
* @returns {obj|null} Object with value result or null if not available
*/
getCachedEvaluation(targeting) {
if (jexlEvaluationCache.has(targeting)) {
const { timestamp, value } = jexlEvaluationCache.get(targeting);
if (Date.now() - timestamp <= CACHE_EXPIRATION) {
return { value };
}
jexlEvaluationCache.delete(targeting);
}
return null;
},
/**
* checkMessageTargeting - Checks is a message's targeting parameters are satisfied
*
* @param {*} message An AS router message
* @param {obj} context A FilterExpression context
* @param {func} onError A function to handle errors (takes two params; error, message)
* @param {boolean} shouldCache Should the JEXL evaluations be cached and reused.
* @returns
*/
async checkMessageTargeting(message, context, onError, shouldCache) {
// If no targeting is specified,
if (!message.targeting) {
return true;
}
let result;
try {
if (shouldCache) {
result = this.getCachedEvaluation(message.targeting);
if (result) {
return result.value;
}
}
result = await this.isMatch(message.targeting, context);
if (shouldCache) {
jexlEvaluationCache.set(message.targeting, {
timestamp: Date.now(),
value: result,
});
}
} catch (error) {
Cu.reportError(error);
if (onError) {
const type = error.fileName.includes(MOZ_JEXL_FILEPATH)
? this.ERROR_TYPES.MALFORMED_EXPRESSION
: this.ERROR_TYPES.OTHER_ERROR;
onError(type, error, message);
}
result = false;
}
return result;
},
_getCombinedContext(trigger, context) {
const triggerContext = trigger ? trigger.context : {};
return this.combineContexts(context, triggerContext);
},
_isMessageMatch(message, trigger, context, onError, shouldCache = false) {
return (
message &&
(trigger
? this.isTriggerMatch(trigger, message.trigger)
: !message.trigger) &&
// If a trigger expression was passed to this function, the message should match it.
// Otherwise, we should choose a message with no trigger property (i.e. a message that can show up at any time)
this.checkMessageTargeting(message, context, onError, shouldCache)
);
},
/**
* findMatchingMessage - Given an array of messages, returns one message
* whos targeting expression evaluates to true
*
* @param {Array} messages An array of AS router messages
* @param {trigger} string A trigger expression if a message for that trigger is desired
* @param {obj|null} context A FilterExpression context. Defaults to TargetingGetters above.
* @param {func} onError A function to handle errors (takes two params; error, message)
* @param {func} ordered An optional param when true sort message by order specified in message
* @param {boolean} shouldCache Should the JEXL evaluations be cached and reused.
* @param {boolean} returnAll Should we return all matching messages, not just the first one found.
* @returns {obj|Array} If returnAll is false, a single message. If returnAll is true, an array of messages.
*/
async findMatchingMessage({
messages,
trigger,
context,
onError,
ordered = false,
shouldCache = false,
returnAll = false,
}) {
const sortedMessages = getSortedMessages(messages, { ordered });
const combinedContext = this._getCombinedContext(trigger, context);
const matching = returnAll ? [] : null;
const isMatch = candidate =>
this._isMessageMatch(
candidate,
trigger,
combinedContext,
onError,
shouldCache
);
for (const candidate of sortedMessages) {
if (await isMatch(candidate)) {
// If not returnAll, we should return the first message we find that matches.
if (!returnAll) {
return candidate;
}
matching.push(candidate);
}
}
return matching;
},
};
// Export for testing
this.getSortedMessages = getSortedMessages;
this.QueryCache = QueryCache;
this.CachedTargetingGetter = CachedTargetingGetter;
this.EXPORTED_SYMBOLS = [
"ASRouterTargeting",
"QueryCache",
"CachedTargetingGetter",
"getSortedMessages",
];
================================================
FILE: lib/ASRouterTriggerListeners.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 { XPCOMUtils } = ChromeUtils.import(
"resource://gre/modules/XPCOMUtils.jsm"
);
XPCOMUtils.defineLazyModuleGetters(this, {
PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.jsm",
EveryWindow: "resource:///modules/EveryWindow.jsm",
});
const FEW_MINUTES = 15 * 60 * 1000; // 15 mins
const MATCH_PATTERN_OPTIONS = { ignorePath: true };
function isPrivateWindow(win) {
return (
!(win instanceof Ci.nsIDOMWindow) ||
win.closed ||
PrivateBrowsingUtils.isWindowPrivate(win)
);
}
/**
* Check current location against the list of whitelisted hosts
* Additionally verify for redirects and check original request URL against
* the whitelist.
*
* @returns {object} - {host, url} pair that matched the whitelist
*/
function checkURLMatch(aLocationURI, { hosts, matchPatternSet }, aRequest) {
// If checks pass we return a match
let match;
try {
match = { host: aLocationURI.host, url: aLocationURI.spec };
} catch (e) {
// nsIURI.host can throw for non-nsStandardURL nsIURIs
return false;
}
// Check current location against whitelisted hosts
if (hosts.has(match.host)) {
return match;
}
if (matchPatternSet) {
if (matchPatternSet.matches(match.url)) {
return match;
}
}
// Nothing else to check, return early
if (!aRequest) {
return false;
}
// The original URL at the start of the request
const originalLocation = aRequest.QueryInterface(Ci.nsIChannel).originalURI;
// We have been redirected
if (originalLocation.spec !== aLocationURI.spec) {
return (
hosts.has(originalLocation.host) && {
host: originalLocation.host,
url: originalLocation.spec,
}
);
}
return false;
}
function createMatchPatternSet(patterns, flags = MATCH_PATTERN_OPTIONS) {
try {
return new MatchPatternSet(new Set(patterns), flags);
} catch (e) {
Cu.reportError(e);
}
return new MatchPatternSet([]);
}
/**
* A Map from trigger IDs to singleton trigger listeners. Each listener must
* have idempotent `init` and `uninit` methods.
*/
this.ASRouterTriggerListeners = new Map([
[
"openArticleURL",
{
id: "openArticleURL",
_initialized: false,
_triggerHandler: null,
_hosts: new Set(),
_matchPatternSet: null,
readerModeEvent: "Reader:UpdateReaderButton",
init(triggerHandler, hosts, patterns) {
if (!this._initialized) {
this.receiveMessage = this.receiveMessage.bind(this);
Services.mm.addMessageListener(this.readerModeEvent, this);
this._triggerHandler = triggerHandler;
this._initialized = true;
}
if (patterns) {
this._matchPatternSet = createMatchPatternSet([
...(this._matchPatternSet ? this._matchPatternSet.patterns : []),
...patterns,
]);
}
if (hosts) {
hosts.forEach(h => this._hosts.add(h));
}
},
receiveMessage({ data, target }) {
if (data && data.isArticle) {
const match = checkURLMatch(target.currentURI, {
hosts: this._hosts,
matchPatternSet: this._matchPatternSet,
});
if (match) {
this._triggerHandler(target, { id: this.id, param: match });
}
}
},
uninit() {
if (this._initialized) {
Services.mm.removeMessageListener(this.readerModeEvent, this);
this._initialized = false;
this._triggerHandler = null;
this._hosts = new Set();
this._matchPatternSet = null;
}
},
},
],
[
"openBookmarkedURL",
{
id: "openBookmarkedURL",
_initialized: false,
_triggerHandler: null,
_hosts: new Set(),
bookmarkEvent: "bookmark-icon-updated",
init(triggerHandler) {
if (!this._initialized) {
Services.obs.addObserver(this, this.bookmarkEvent);
this._triggerHandler = triggerHandler;
this._initialized = true;
}
},
observe(subject, topic, data) {
if (topic === this.bookmarkEvent && data === "starred") {
const browser = Services.wm.getMostRecentBrowserWindow();
if (browser) {
this._triggerHandler(browser.gBrowser.selectedBrowser, {
id: this.id,
});
}
}
},
uninit() {
if (this._initialized) {
Services.obs.removeObserver(this, this.bookmarkEvent);
this._initialized = false;
this._triggerHandler = null;
this._hosts = new Set();
}
},
},
],
[
"frequentVisits",
{
id: "frequentVisits",
_initialized: false,
_triggerHandler: null,
_hosts: null,
_matchPatternSet: null,
_visits: null,
init(triggerHandler, hosts = [], patterns) {
if (!this._initialized) {
this.onTabSwitch = this.onTabSwitch.bind(this);
EveryWindow.registerCallback(
this.id,
win => {
if (!isPrivateWindow(win)) {
win.addEventListener("TabSelect", this.onTabSwitch);
win.gBrowser.addTabsProgressListener(this);
}
},
win => {
if (!isPrivateWindow(win)) {
win.removeEventListener("TabSelect", this.onTabSwitch);
win.gBrowser.removeTabsProgressListener(this);
}
}
);
this._visits = new Map();
this._initialized = true;
}
this._triggerHandler = triggerHandler;
if (patterns) {
this._matchPatternSet = createMatchPatternSet([
...(this._matchPatternSet ? this._matchPatternSet.patterns : []),
...patterns,
]);
}
if (this._hosts) {
hosts.forEach(h => this._hosts.add(h));
} else {
this._hosts = new Set(hosts); // Clone the hosts to avoid unexpected behaviour
}
},
/* _updateVisits - Record visit timestamps for websites that match `this._hosts` and only
* if it's been more than FEW_MINUTES since the last visit.
* @param {string} host - Location host of current selected tab
* @returns {boolean} - If the new visit has been recorded
*/
_updateVisits(host) {
const visits = this._visits.get(host);
if (visits && Date.now() - visits[0] > FEW_MINUTES) {
this._visits.set(host, [Date.now(), ...visits]);
return true;
}
if (!visits) {
this._visits.set(host, [Date.now()]);
return true;
}
return false;
},
onTabSwitch(event) {
if (!event.target.ownerGlobal.gBrowser) {
return;
}
const { gBrowser } = event.target.ownerGlobal;
const match = checkURLMatch(gBrowser.currentURI, {
hosts: this._hosts,
matchPatternSet: this._matchPatternSet,
});
if (match) {
this.triggerHandler(gBrowser.selectedBrowser, match);
}
},
triggerHandler(aBrowser, match) {
const updated = this._updateVisits(match.host);
// If the previous visit happend less than FEW_MINUTES ago
// no updates were made, no need to trigger the handler
if (!updated) {
return;
}
this._triggerHandler(aBrowser, {
id: this.id,
param: match,
context: {
// Remapped to {host, timestamp} because JEXL operators can only
// filter over collections (arrays of objects)
recentVisits: this._visits
.get(match.host)
.map(timestamp => ({ host: match.host, timestamp })),
},
});
},
onLocationChange(aBrowser, aWebProgress, aRequest, aLocationURI, aFlags) {
// Some websites trigger redirect events after they finish loading even
// though the location remains the same. This results in onLocationChange
// events to be fired twice.
const isSameDocument = !!(
aFlags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT
);
if (aWebProgress.isTopLevel && !isSameDocument) {
const match = checkURLMatch(
aLocationURI,
{ hosts: this._hosts, matchPatternSet: this._matchPatternSet },
aRequest
);
if (match) {
this.triggerHandler(aBrowser, match);
}
}
},
uninit() {
if (this._initialized) {
EveryWindow.unregisterCallback(this.id);
this._initialized = false;
this._triggerHandler = null;
this._hosts = null;
this._matchPatternSet = null;
this._visits = null;
}
},
},
],
/**
* Attach listeners to every browser window to detect location changes, and
* notify the trigger handler whenever we navigate to a URL with a hostname
* we're looking for.
*/
[
"openURL",
{
id: "openURL",
_initialized: false,
_triggerHandler: null,
_hosts: null,
_matchPatternSet: null,
/*
* If the listener is already initialised, `init` will replace the trigger
* handler and add any new hosts to `this._hosts`.
*/
init(triggerHandler, hosts = [], patterns) {
if (!this._initialized) {
this.onLocationChange = this.onLocationChange.bind(this);
EveryWindow.registerCallback(
this.id,
win => {
if (!isPrivateWindow(win)) {
win.addEventListener("TabSelect", this.onTabSwitch);
win.gBrowser.addTabsProgressListener(this);
}
},
win => {
if (!isPrivateWindow(win)) {
win.removeEventListener("TabSelect", this.onTabSwitch);
win.gBrowser.removeTabsProgressListener(this);
}
}
);
this._initialized = true;
}
this._triggerHandler = triggerHandler;
if (patterns) {
this._matchPatternSet = createMatchPatternSet([
...(this._matchPatternSet ? this._matchPatternSet.patterns : []),
...patterns,
]);
}
if (this._hosts) {
hosts.forEach(h => this._hosts.add(h));
} else {
this._hosts = new Set(hosts); // Clone the hosts to avoid unexpected behaviour
}
},
uninit() {
if (this._initialized) {
EveryWindow.unregisterCallback(this.id);
this._initialized = false;
this._triggerHandler = null;
this._hosts = null;
}
},
onLocationChange(aBrowser, aWebProgress, aRequest, aLocationURI, aFlags) {
// Some websites trigger redirect events after they finish loading even
// though the location remains the same. This results in onLocationChange
// events to be fired twice.
const isSameDocument = !!(
aFlags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT
);
if (aWebProgress.isTopLevel && !isSameDocument) {
const match = checkURLMatch(
aLocationURI,
{ hosts: this._hosts, matchPatternSet: this._matchPatternSet },
aRequest
);
if (match) {
this._triggerHandler(aBrowser, { id: this.id, param: match });
}
}
},
},
],
/**
* Add an observer notification to notify the trigger handler whenever the user saves a new login
* via the login capture doorhanger.
*/
[
"newSavedLogin",
{
_initialized: false,
_triggerHandler: null,
/**
* If the listener is already initialised, `init` will replace the trigger
* handler.
*/
init(triggerHandler) {
if (!this._initialized) {
Services.obs.addObserver(this, "LoginStats:NewSavedPassword");
this._initialized = true;
}
this._triggerHandler = triggerHandler;
},
uninit() {
if (this._initialized) {
Services.obs.removeObserver(this, "LoginStats:NewSavedPassword");
this._initialized = false;
this._triggerHandler = null;
}
},
observe(aSubject, aTopic, aData) {
if (aSubject.currentURI.asciiHost === "accounts.firefox.com") {
// Don't notify about saved logins on the FxA login origin since this
// trigger is used to promote login Sync and getting a recommendation
// to enable Sync during the sign up process is a bad UX.
return;
}
this._triggerHandler(aSubject, { id: "newSavedLogin" });
},
},
],
/**
* Attach listener to count location changes and notify the trigger handler
* on content blocked event
*/
[
"trackingProtection",
{
_initialized: false,
_triggerHandler: null,
_events: [],
_sessionPageLoad: 0,
onLocationChange: null,
init(triggerHandler, params, patterns) {
params.forEach(p => this._events.push(p));
if (!this._initialized) {
Services.obs.addObserver(this, "SiteProtection:ContentBlockingEvent");
Services.obs.addObserver(
this,
"SiteProtection:ContentBlockingMilestone"
);
this.onLocationChange = this._onLocationChange.bind(this);
EveryWindow.registerCallback(
this.id,
win => {
if (!isPrivateWindow(win)) {
win.gBrowser.addTabsProgressListener(this);
}
},
win => {
if (!isPrivateWindow(win)) {
win.gBrowser.removeTabsProgressListener(this);
}
}
);
this._initialized = true;
}
this._triggerHandler = triggerHandler;
},
uninit() {
if (this._initialized) {
Services.obs.removeObserver(
this,
"SiteProtection:ContentBlockingEvent"
);
Services.obs.removeObserver(
this,
"SiteProtection:ContentBlockingMilestone"
);
EveryWindow.unregisterCallback(this.id);
this.onLocationChange = null;
this._initialized = false;
}
this._triggerHandler = null;
this._events = [];
this._sessionPageLoad = 0;
},
observe(aSubject, aTopic, aData) {
switch (aTopic) {
case "SiteProtection:ContentBlockingEvent":
const { browser, host, event } = aSubject.wrappedJSObject;
if (this._events.filter(e => (e & event) === e).length) {
this._triggerHandler(browser, {
id: "trackingProtection",
param: {
host,
type: event,
},
context: {
pageLoad: this._sessionPageLoad,
},
});
}
break;
case "SiteProtection:ContentBlockingMilestone":
if (this._events.includes(aSubject.wrappedJSObject.event)) {
this._triggerHandler(
Services.wm.getMostRecentBrowserWindow().gBrowser
.selectedBrowser,
{
id: "trackingProtection",
context: {
pageLoad: this._sessionPageLoad,
},
param: {
host: aSubject.wrappedJSObject.event,
},
}
);
}
break;
}
},
_onLocationChange(
aBrowser,
aWebProgress,
aRequest,
aLocationURI,
aFlags
) {
// Some websites trigger redirect events after they finish loading even
// though the location remains the same. This results in onLocationChange
// events to be fired twice.
const isSameDocument = !!(
aFlags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT
);
if (
["http", "https"].includes(aLocationURI.scheme) &&
aWebProgress.isTopLevel &&
!isSameDocument
) {
this._sessionPageLoad += 1;
}
},
},
],
]);
const EXPORTED_SYMBOLS = ["ASRouterTriggerListeners"];
================================================
FILE: lib/AboutPreferences.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 { actionTypes: at } = ChromeUtils.import(
"resource://activity-stream/common/Actions.jsm"
);
const HTML_NS = "http://www.w3.org/1999/xhtml";
const PREFERENCES_LOADED_EVENT = "home-pane-loaded";
// These "section" objects are formatted in a way to be similar to the ones from
// SectionsManager to construct the preferences view.
const PREFS_BEFORE_SECTIONS = [
{
id: "search",
pref: {
feed: "showSearch",
titleString: "home-prefs-search-header",
},
icon: "chrome://browser/skin/search-glass.svg",
},
{
id: "topsites",
pref: {
feed: "feeds.topsites",
titleString: "home-prefs-topsites-header",
descString: "home-prefs-topsites-description",
},
icon: "topsites",
maxRows: 4,
rowsPref: "topSitesRows",
},
];
const PREFS_AFTER_SECTIONS = [
{
id: "snippets",
pref: {
feed: "feeds.snippets",
titleString: "home-prefs-snippets-header",
descString: "home-prefs-snippets-description",
},
icon: "info",
},
];
// This CSS is added to the whole about:preferences page
const CUSTOM_CSS = `
#homeContentsGroup checkbox[src] .checkbox-icon {
-moz-context-properties: fill;
fill: currentColor;
margin-inline-end: 8px;
margin-inline-start: 4px;
width: 16px;
}
#homeContentsGroup [data-subcategory] {
margin-top: 14px;
}
#homeContentsGroup [data-subcategory] .section-checkbox {
font-weight: 600;
}
#homeContentsGroup [data-subcategory] > vbox menulist {
margin-top: 0;
margin-bottom: 0;
}
`;
this.AboutPreferences = class AboutPreferences {
init() {
Services.obs.addObserver(this, PREFERENCES_LOADED_EVENT);
}
uninit() {
Services.obs.removeObserver(this, PREFERENCES_LOADED_EVENT);
}
onAction(action) {
switch (action.type) {
case at.INIT:
this.init();
break;
case at.UNINIT:
this.uninit();
break;
case at.SETTINGS_OPEN:
action._target.browser.ownerGlobal.openPreferences("paneHome");
break;
// This is used to open the web extension settings page for an extension
case at.OPEN_WEBEXT_SETTINGS:
action._target.browser.ownerGlobal.BrowserOpenAddonsMgr(
`addons://detail/${encodeURIComponent(action.data)}`
);
break;
}
}
handleDiscoverySettings(sections) {
// Deep copy object to not modify original Sections state in store
let sectionsCopy = JSON.parse(JSON.stringify(sections));
sectionsCopy.forEach(obj => {
if (obj.id === "topstories") {
obj.rowsPref = "";
}
});
return sectionsCopy;
}
observe(window) {
const discoveryStreamConfig = this.store.getState().DiscoveryStream.config;
let sections = this.store.getState().Sections;
if (discoveryStreamConfig.enabled) {
sections = this.handleDiscoverySettings(sections);
}
this.renderPreferences(window, [
...PREFS_BEFORE_SECTIONS,
...sections,
...PREFS_AFTER_SECTIONS,
]);
}
/**
* Render preferences to an about:preferences content window with the provided
* preferences structure.
*/
renderPreferences({ document, Preferences, gHomePane }, prefStructure) {
// Helper to create a new element and append it
const createAppend = (tag, parent, options) =>
parent.appendChild(document.createXULElement(tag, options));
// Helper to get fluentIDs sometimes encase in an object
const getString = message =>
typeof message !== "object" ? message : message.id;
// Helper to link a UI element to a preference for updating
const linkPref = (element, name, type) => {
const fullPref = `browser.newtabpage.activity-stream.${name}`;
element.setAttribute("preference", fullPref);
Preferences.add({ id: fullPref, type });
// Prevent changing the UI if the preference can't be changed
element.disabled = Preferences.get(fullPref).locked;
};
// Add in custom styling
document.insertBefore(
document.createProcessingInstruction(
"xml-stylesheet",
`href="data:text/css,${encodeURIComponent(CUSTOM_CSS)}" type="text/css"`
),
document.documentElement
);
// Insert a new group immediately after the homepage one
const homeGroup = document.getElementById("homepageGroup");
const contentsGroup = homeGroup.insertAdjacentElement(
"afterend",
homeGroup.cloneNode()
);
contentsGroup.id = "homeContentsGroup";
contentsGroup.setAttribute("data-subcategory", "contents");
const homeHeader = createAppend("label", contentsGroup).appendChild(
document.createElementNS(HTML_NS, "h2")
);
document.l10n.setAttributes(homeHeader, "home-prefs-content-header");
const homeDescription = createAppend("description", contentsGroup);
document.l10n.setAttributes(
homeDescription,
"home-prefs-content-description"
);
// Add preferences for each section
prefStructure.forEach(sectionData => {
const {
id,
pref: prefData,
icon = "webextension",
maxRows,
rowsPref,
shouldHidePref,
} = sectionData;
const { feed: name, titleString = {}, descString, nestedPrefs = [] } =
prefData || {};
// Don't show any sections that we don't want to expose in preferences UI
if (shouldHidePref) {
return;
}
// Use full icon spec for certain protocols or fall back to packaged icon
const iconUrl = !icon.search(/^(chrome|moz-extension|resource):/)
? icon
: `resource://activity-stream/data/content/assets/glyph-${icon}-16.svg`;
// Add the main preference for turning on/off a section
const sectionVbox = createAppend("vbox", contentsGroup);
sectionVbox.setAttribute("data-subcategory", id);
const checkbox = createAppend("checkbox", sectionVbox);
checkbox.classList.add("section-checkbox");
checkbox.setAttribute("src", iconUrl);
document.l10n.setAttributes(
checkbox,
getString(titleString),
titleString.values
);
linkPref(checkbox, name, "bool");
// Specially add a link for stories
if (id === "topstories") {
const sponsoredHbox = createAppend("hbox", sectionVbox);
sponsoredHbox.setAttribute("align", "center");
sponsoredHbox.appendChild(checkbox);
checkbox.classList.add("tail-with-learn-more");
const link = createAppend("label", sponsoredHbox, { is: "text-link" });
link.classList.add("learn-sponsored");
link.setAttribute("href", sectionData.pref.learnMore.link.href);
document.l10n.setAttributes(link, sectionData.pref.learnMore.link.id);
}
// Add more details for the section (e.g., description, more prefs)
const detailVbox = createAppend("vbox", sectionVbox);
detailVbox.classList.add("indent");
if (descString) {
const label = createAppend("label", detailVbox);
label.classList.add("indent");
document.l10n.setAttributes(label, getString(descString));
// Add a rows dropdown if we have a pref to control and a maximum
if (rowsPref && maxRows) {
const detailHbox = createAppend("hbox", detailVbox);
detailHbox.setAttribute("align", "center");
label.setAttribute("flex", 1);
detailHbox.appendChild(label);
// Add box so the search tooltip is positioned correctly
const tooltipBox = createAppend("hbox", detailHbox);
// Add appropriate number of localized entries to the dropdown
const menulist = createAppend("menulist", tooltipBox);
menulist.setAttribute("crop", "none");
const menupopup = createAppend("menupopup", menulist);
for (let num = 1; num <= maxRows; num++) {
const item = createAppend("menuitem", menupopup);
document.l10n.setAttributes(
item,
"home-prefs-sections-rows-option",
{ num }
);
item.setAttribute("value", num);
}
linkPref(menulist, rowsPref, "int");
}
}
const subChecks = [];
const fullName = `browser.newtabpage.activity-stream.${
sectionData.pref.feed
}`;
const pref = Preferences.get(fullName);
// Add a checkbox pref for any nested preferences
nestedPrefs.forEach(nested => {
const subcheck = createAppend("checkbox", detailVbox);
subcheck.classList.add("indent");
document.l10n.setAttributes(subcheck, nested.titleString);
linkPref(subcheck, nested.name, "bool");
subChecks.push(subcheck);
subcheck.disabled = !pref._value;
});
// Disable any nested checkboxes if the parent pref is not enabled.
pref.on("change", () => {
subChecks.forEach(subcheck => {
subcheck.disabled = !pref._value;
});
});
});
// Update the visibility of the Restore Defaults btn based on checked prefs
gHomePane.toggleRestoreDefaultsBtn();
}
};
this.PREFERENCES_LOADED_EVENT = PREFERENCES_LOADED_EVENT;
const EXPORTED_SYMBOLS = ["AboutPreferences", "PREFERENCES_LOADED_EVENT"];
================================================
FILE: lib/ActivityStream.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");
ChromeUtils.defineModuleGetter(
this,
"AppConstants",
"resource://gre/modules/AppConstants.jsm"
);
// NB: Eagerly load modules that will be loaded/constructed/initialized in the
// common case to avoid the overhead of wrapping and detecting lazy loading.
const { actionCreators: ac, actionTypes: at } = ChromeUtils.import(
"resource://activity-stream/common/Actions.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"AboutPreferences",
"resource://activity-stream/lib/AboutPreferences.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"DefaultPrefs",
"resource://activity-stream/lib/ActivityStreamPrefs.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"NewTabInit",
"resource://activity-stream/lib/NewTabInit.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"SectionsFeed",
"resource://activity-stream/lib/SectionsManager.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"PlacesFeed",
"resource://activity-stream/lib/PlacesFeed.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"PrefsFeed",
"resource://activity-stream/lib/PrefsFeed.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"Store",
"resource://activity-stream/lib/Store.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"SystemTickFeed",
"resource://activity-stream/lib/SystemTickFeed.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"TelemetryFeed",
"resource://activity-stream/lib/TelemetryFeed.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"FaviconFeed",
"resource://activity-stream/lib/FaviconFeed.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"TopSitesFeed",
"resource://activity-stream/lib/TopSitesFeed.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"TopStoriesFeed",
"resource://activity-stream/lib/TopStoriesFeed.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"HighlightsFeed",
"resource://activity-stream/lib/HighlightsFeed.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"ASRouterFeed",
"resource://activity-stream/lib/ASRouterFeed.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"DiscoveryStreamFeed",
"resource://activity-stream/lib/DiscoveryStreamFeed.jsm"
);
const DEFAULT_SITES = new Map([
// This first item is the global list fallback for any unexpected geos
[
"",
"https://www.youtube.com/,https://www.facebook.com/,https://www.wikipedia.org/,https://www.reddit.com/,https://www.amazon.com/,https://twitter.com/",
],
[
"US",
"https://www.youtube.com/,https://www.facebook.com/,https://www.amazon.com/,https://www.reddit.com/,https://www.wikipedia.org/,https://twitter.com/",
],
[
"CA",
"https://www.youtube.com/,https://www.facebook.com/,https://www.reddit.com/,https://www.wikipedia.org/,https://www.amazon.ca/,https://twitter.com/",
],
[
"DE",
"https://www.youtube.com/,https://www.facebook.com/,https://www.amazon.de/,https://www.ebay.de/,https://www.wikipedia.org/,https://www.reddit.com/",
],
[
"PL",
"https://www.youtube.com/,https://www.facebook.com/,https://allegro.pl/,https://www.wikipedia.org/,https://www.olx.pl/,https://www.wykop.pl/",
],
[
"RU",
"https://vk.com/,https://www.youtube.com/,https://ok.ru/,https://www.avito.ru/,https://www.aliexpress.com/,https://www.wikipedia.org/",
],
[
"GB",
"https://www.youtube.com/,https://www.facebook.com/,https://www.reddit.com/,https://www.amazon.co.uk/,https://www.bbc.co.uk/,https://www.ebay.co.uk/",
],
[
"FR",
"https://www.youtube.com/,https://www.facebook.com/,https://www.wikipedia.org/,https://www.amazon.fr/,https://www.leboncoin.fr/,https://twitter.com/",
],
]);
const GEO_PREF = "browser.search.region";
const SPOCS_GEOS = ["US"];
// Determine if spocs should be shown for a geo/locale
function showSpocs({ geo }) {
return SPOCS_GEOS.includes(geo);
}
// Configure default Activity Stream prefs with a plain `value` or a `getValue`
// that computes a value. A `value_local_dev` is used for development defaults.
const PREFS_CONFIG = new Map([
[
"default.sites",
{
title:
"Comma-separated list of default top sites to fill in behind visited sites",
getValue: ({ geo }) =>
DEFAULT_SITES.get(DEFAULT_SITES.has(geo) ? geo : ""),
},
],
[
"feeds.section.topstories.options",
{
title: "Configuration options for top stories feed",
// This is a dynamic pref as it depends on the feed being shown or not
getValue: args =>
JSON.stringify({
api_key_pref: "extensions.pocket.oAuthConsumerKey",
// Use the opposite value as what default value the feed would have used
hidden: !PREFS_CONFIG.get("feeds.section.topstories").getValue(args),
provider_icon: "pocket",
provider_name: "Pocket",
read_more_endpoint:
"https://getpocket.com/explore/trending?src=fx_new_tab",
stories_endpoint: `https://getpocket.cdn.mozilla.net/v3/firefox/global-recs?version=3&consumer_key=$apiKey&locale_lang=${
args.locale
}&feed_variant=${
showSpocs(args) ? "default_spocs_on" : "default_spocs_off"
}`,
stories_referrer: "https://getpocket.com/recommendations",
topics_endpoint: `https://getpocket.cdn.mozilla.net/v3/firefox/trending-topics?version=2&consumer_key=$apiKey&locale_lang=${
args.locale
}`,
model_keys: [
"nmf_model_animals",
"nmf_model_business",
"nmf_model_career",
"nmf_model_datascience",
"nmf_model_design",
"nmf_model_education",
"nmf_model_entertainment",
"nmf_model_environment",
"nmf_model_fashion",
"nmf_model_finance",
"nmf_model_food",
"nmf_model_health",
"nmf_model_home",
"nmf_model_life",
"nmf_model_marketing",
"nmf_model_politics",
"nmf_model_programming",
"nmf_model_science",
"nmf_model_shopping",
"nmf_model_sports",
"nmf_model_tech",
"nmf_model_travel",
"nb_model_animals",
"nb_model_books",
"nb_model_business",
"nb_model_career",
"nb_model_datascience",
"nb_model_design",
"nb_model_economics",
"nb_model_education",
"nb_model_entertainment",
"nb_model_environment",
"nb_model_fashion",
"nb_model_finance",
"nb_model_food",
"nb_model_game",
"nb_model_health",
"nb_model_history",
"nb_model_home",
"nb_model_life",
"nb_model_marketing",
"nb_model_military",
"nb_model_philosophy",
"nb_model_photography",
"nb_model_politics",
"nb_model_productivity",
"nb_model_programming",
"nb_model_psychology",
"nb_model_science",
"nb_model_shopping",
"nb_model_society",
"nb_model_space",
"nb_model_sports",
"nb_model_tech",
"nb_model_travel",
"nb_model_writing",
],
show_spocs: showSpocs(args),
personalized: true,
version: 1,
}),
},
],
[
"showSponsored",
{
title:
"Show sponsored cards in spoc experiment (show_spocs in topstories.options has to be set to true as well)",
value: true,
},
],
[
"pocketCta",
{
title: "Pocket cta and button for logged out users.",
value: JSON.stringify({
cta_button: "",
cta_text: "",
cta_url: "",
use_cta: false,
}),
},
],
[
"filterAdult",
{
title: "Remove adult pages from sites, highlights, etc.",
value: true,
},
],
[
"showSearch",
{
title: "Show the Search bar",
value: true,
},
],
[
"feeds.snippets",
{
title: "Show snippets on activity stream",
value: true,
},
],
[
"topSitesRows",
{
title: "Number of rows of Top Sites to display",
value: 1,
},
],
[
"telemetry",
{
title: "Enable system error and usage data collection",
value: true,
value_local_dev: false,
},
],
[
"telemetry.ut.events",
{
title: "Enable Unified Telemetry event data collection",
value: AppConstants.EARLY_BETA_OR_EARLIER,
value_local_dev: false,
},
],
[
"telemetry.structuredIngestion",
{
title: "Enable Structured Ingestion Telemetry data collection",
value: true,
value_local_dev: false,
},
],
[
"telemetry.structuredIngestion.endpoint",
{
title: "Structured Ingestion telemetry server endpoint",
value: "https://incoming.telemetry.mozilla.org/submit",
},
],
[
"section.highlights.includeVisited",
{
title:
"Boolean flag that decides whether or not to show visited pages in highlights.",
value: true,
},
],
[
"section.highlights.includeBookmarks",
{
title:
"Boolean flag that decides whether or not to show bookmarks in highlights.",
value: true,
},
],
[
"section.highlights.includePocket",
{
title:
"Boolean flag that decides whether or not to show saved Pocket stories in highlights.",
value: true,
},
],
[
"section.highlights.includeDownloads",
{
title:
"Boolean flag that decides whether or not to show saved recent Downloads in highlights.",
value: true,
},
],
[
"section.highlights.rows",
{
title: "Number of rows of Highlights to display",
value: 1,
},
],
[
"section.topstories.rows",
{
title: "Number of rows of Top Stories to display",
value: 1,
},
],
[
"sectionOrder",
{
title: "The rendering order for the sections",
value: "topsites,topstories,highlights",
},
],
[
"improvesearch.noDefaultSearchTile",
{
title: "Remove tiles that are the same as the default search",
value: true,
},
],
[
"improvesearch.topSiteSearchShortcuts.searchEngines",
{
title:
"An ordered, comma-delimited list of search shortcuts that we should try and pin",
// This pref is dynamic as the shortcuts vary depending on the region
getValue: ({ geo }) => {
if (!geo) {
return "";
}
const searchShortcuts = [];
if (geo === "CN") {
searchShortcuts.push("baidu");
} else if (["BY", "KZ", "RU", "TR"].includes(geo)) {
searchShortcuts.push("yandex");
} else {
searchShortcuts.push("google");
}
if (["DE", "FR", "GB", "IT", "JP", "US"].includes(geo)) {
searchShortcuts.push("amazon");
}
return searchShortcuts.join(",");
},
},
],
[
"improvesearch.topSiteSearchShortcuts.havePinned",
{
title:
"A comma-delimited list of search shortcuts that have previously been pinned",
value: "",
},
],
[
"asrouter.devtoolsEnabled",
{
title: "Are the asrouter devtools enabled?",
value: false,
},
],
[
"asrouter.userprefs.cfr.addons",
{
title: "Does the user allow CFR addon recommendations?",
value: true,
},
],
[
"asrouter.userprefs.cfr.features",
{
title: "Does the user allow CFR feature recommendations?",
value: true,
},
],
[
"asrouter.providers.onboarding",
{
title: "Configuration for onboarding provider",
value: JSON.stringify({
id: "onboarding",
type: "local",
localProvider: "OnboardingMessageProvider",
enabled: true,
// Block specific messages from this local provider
exclude: [],
}),
},
],
[
"asrouter.providers.cfr-fxa",
{
title: "Configuration for CFR FxA Messages provider",
value: JSON.stringify({
id: "cfr-fxa",
enabled: true,
type: "remote-settings",
bucket: "cfr-fxa",
frequency: { custom: [{ period: "daily", cap: 1 }] },
}),
},
],
// See browser/app/profile/firefox.js for other ASR preferences. They must be defined there to enable roll-outs.
[
"discoverystream.flight.blocks",
{
title: "Track flight blocks",
skipBroadcast: true,
value: "{}",
},
],
[
"discoverystream.config",
{
title: "Configuration for the new pocket new tab",
getValue: ({ geo, locale }) => {
// PLEASE NOTE:
// hardcoded_layout in `lib/DiscoveryStreamFeed.jsm` only works for en-* and DE and requires refactoring for other locales
const dsEnablementMatrix = {
US: ["en-CA", "en-GB", "en-US"],
CA: ["en-CA", "en-GB", "en-US"],
DE: ["de", "de-DE", "de-AT", "de-CH"],
};
// Verify that the current geo & locale combination is enabled
const isEnabled =
!!dsEnablementMatrix[geo] && dsEnablementMatrix[geo].includes(locale);
return JSON.stringify({
api_key_pref: "extensions.pocket.oAuthConsumerKey",
collapsible: true,
enabled: isEnabled,
show_spocs: showSpocs({ geo }),
hardcoded_layout: true,
personalized: true,
// This is currently an exmple layout used for dev purposes.
layout_endpoint:
"https://getpocket.cdn.mozilla.net/v3/newtab/layout?version=1&consumer_key=$apiKey&layout_variant=basic",
});
},
},
],
[
"discoverystream.endpoints",
{
title:
"Endpoint prefixes (comma-separated) that are allowed to be requested",
value: "https://getpocket.cdn.mozilla.net/,https://spocs.getpocket.com/",
},
],
[
"discoverystream.engagementLabelEnabled",
{
title:
"Allow the display of engagement labels for discovery stream components (eg: Trending, Popular, etc)",
value: false,
},
],
[
"discoverystream.spoc.impressions",
{
title: "Track spoc impressions",
skipBroadcast: true,
value: "{}",
},
],
[
"discoverystream.endpointSpocsClear",
{
title:
"Endpoint for when a user opts-out of sponsored content to delete the user's data from the ad server.",
value: "https://spocs.getpocket.com/user",
},
],
[
"discoverystream.rec.impressions",
{
title: "Track rec impressions",
skipBroadcast: true,
value: "{}",
},
],
]);
// Array of each feed's FEEDS_CONFIG factory and values to add to PREFS_CONFIG
const FEEDS_DATA = [
{
name: "aboutpreferences",
factory: () => new AboutPreferences(),
title: "about:preferences rendering",
value: true,
},
{
name: "newtabinit",
factory: () => new NewTabInit(),
title: "Sends a copy of the state to each new tab that is opened",
value: true,
},
{
name: "places",
factory: () => new PlacesFeed(),
title: "Listens for and relays various Places-related events",
value: true,
},
{
name: "prefs",
factory: () => new PrefsFeed(PREFS_CONFIG),
title: "Preferences",
value: true,
},
{
name: "sections",
factory: () => new SectionsFeed(),
title: "Manages sections",
value: true,
},
{
name: "section.highlights",
factory: () => new HighlightsFeed(),
title: "Fetches content recommendations from places db",
value: true,
},
{
name: "section.topstories",
factory: () =>
new TopStoriesFeed(PREFS_CONFIG.get("discoverystream.config")),
title:
"Fetches content recommendations from a configurable content provider",
// Dynamically determine if Pocket should be shown for a geo / locale
getValue: ({ geo, locale }) => {
const locales = {
US: ["en-CA", "en-GB", "en-US", "en-ZA"],
CA: ["en-CA", "en-GB", "en-US", "en-ZA"],
DE: ["de", "de-DE", "de-AT", "de-CH"],
}[geo];
return !!locales && locales.includes(locale);
},
},
{
name: "systemtick",
factory: () => new SystemTickFeed(),
title: "Produces system tick events to periodically check for data expiry",
value: true,
},
{
name: "telemetry",
factory: () => new TelemetryFeed(),
title: "Relays telemetry-related actions to PingCentre",
value: true,
},
{
name: "favicon",
factory: () => new FaviconFeed(),
title: "Fetches tippy top manifests from remote service",
value: true,
},
{
name: "topsites",
factory: () => new TopSitesFeed(),
title: "Queries places and gets metadata for Top Sites section",
value: true,
},
{
name: "asrouterfeed",
factory: () => new ASRouterFeed(),
title: "Handles AS Router messages, such as snippets and onboaridng",
value: true,
},
{
name: "discoverystreamfeed",
factory: () => new DiscoveryStreamFeed(),
title: "Handles new pocket ui for the new tab page",
value: true,
},
];
const FEEDS_CONFIG = new Map();
for (const config of FEEDS_DATA) {
const pref = `feeds.${config.name}`;
FEEDS_CONFIG.set(pref, config.factory);
PREFS_CONFIG.set(pref, config);
}
this.ActivityStream = class ActivityStream {
/**
* constructor - Initializes an instance of ActivityStream
*/
constructor() {
this.initialized = false;
this.store = new Store();
this.feeds = FEEDS_CONFIG;
this._defaultPrefs = new DefaultPrefs(PREFS_CONFIG);
}
init() {
try {
this._updateDynamicPrefs();
this._defaultPrefs.init();
// Look for outdated user pref values that might have been accidentally
// persisted when restoring the original pref value at the end of an
// experiment across versions with a different default value.
const DS_CONFIG =
"browser.newtabpage.activity-stream.discoverystream.config";
if (
Services.prefs.prefHasUserValue(DS_CONFIG) &&
[
// Firefox 66
`{"api_key_pref":"extensions.pocket.oAuthConsumerKey","enabled":false,"show_spocs":true,"layout_endpoint":"https://getpocket.com/v3/newtab/layout?version=1&consumer_key=$apiKey&layout_variant=basic"}`,
// Firefox 67
`{"api_key_pref":"extensions.pocket.oAuthConsumerKey","enabled":false,"show_spocs":true,"layout_endpoint":"https://getpocket.cdn.mozilla.net/v3/newtab/layout?version=1&consumer_key=$apiKey&layout_variant=basic"}`,
// Firefox 68
`{"api_key_pref":"extensions.pocket.oAuthConsumerKey","collapsible":true,"enabled":false,"show_spocs":true,"hardcoded_layout":true,"personalized":false,"layout_endpoint":"https://getpocket.cdn.mozilla.net/v3/newtab/layout?version=1&consumer_key=$apiKey&layout_variant=basic"}`,
].includes(Services.prefs.getStringPref(DS_CONFIG))
) {
Services.prefs.clearUserPref(DS_CONFIG);
}
// Hook up the store and let all feeds and pages initialize
this.store.init(
this.feeds,
ac.BroadcastToContent({
type: at.INIT,
data: {},
}),
{ type: at.UNINIT }
);
this.initialized = true;
} catch (e) {
// TelemetryFeed could be unavailable if the telemetry is disabled, or
// the telemetry feed is not yet initialized.
const telemetryFeed = this.store.feeds.get("feeds.telemetry");
if (telemetryFeed) {
telemetryFeed.handleUndesiredEvent({
data: { event: "ADDON_INIT_FAILED" },
});
}
throw e;
}
}
/**
* Check if an old pref has a custom value to migrate. Clears the pref so that
* it's the default after migrating (to avoid future need to migrate).
*
* @param oldPrefName {string} Pref to check and migrate
* @param cbIfNotDefault {function} Callback that gets the current pref value
*/
_migratePref(oldPrefName, cbIfNotDefault) {
// Nothing to do if the user doesn't have a custom value
if (!Services.prefs.prefHasUserValue(oldPrefName)) {
return;
}
// Figure out what kind of pref getter to use
let prefGetter;
switch (Services.prefs.getPrefType(oldPrefName)) {
case Services.prefs.PREF_BOOL:
prefGetter = "getBoolPref";
break;
case Services.prefs.PREF_INT:
prefGetter = "getIntPref";
break;
case Services.prefs.PREF_STRING:
prefGetter = "getStringPref";
break;
}
// Give the callback the current value then clear the pref
cbIfNotDefault(Services.prefs[prefGetter](oldPrefName));
Services.prefs.clearUserPref(oldPrefName);
}
uninit() {
if (this.geo === "") {
Services.prefs.removeObserver(GEO_PREF, this);
}
this.store.uninit();
this.initialized = false;
}
_updateDynamicPrefs() {
// Save the geo pref if we have it
if (Services.prefs.prefHasUserValue(GEO_PREF)) {
this.geo = Services.prefs.getStringPref(GEO_PREF);
} else if (this.geo !== "") {
// Watch for geo changes and use a dummy value for now
Services.prefs.addObserver(GEO_PREF, this);
this.geo = "";
}
this.locale = Services.locale.appLocaleAsLangTag;
// Update the pref config of those with dynamic values
for (const pref of PREFS_CONFIG.keys()) {
// Only need to process dynamic prefs
const prefConfig = PREFS_CONFIG.get(pref);
if (!prefConfig.getValue) {
continue;
}
// Have the dynamic pref just reuse using existing default, e.g., those
// set via Autoconfig or policy
try {
const existingDefault = this._defaultPrefs.get(pref);
if (existingDefault !== undefined && prefConfig.value === undefined) {
prefConfig.getValue = () => existingDefault;
}
} catch (ex) {
// We get NS_ERROR_UNEXPECTED for prefs that have a user value (causing
// default branch to believe there's a type) but no actual default value
}
// Compute the dynamic value (potentially generic based on dummy geo)
const newValue = prefConfig.getValue({
geo: this.geo,
locale: this.locale,
});
// If there's an existing value and it has changed, that means we need to
// overwrite the default with the new value.
if (prefConfig.value !== undefined && prefConfig.value !== newValue) {
this._defaultPrefs.set(pref, newValue);
}
prefConfig.value = newValue;
}
}
observe(subject, topic, data) {
switch (topic) {
case "nsPref:changed":
// We should only expect one geo change, so update and stop observing
if (data === GEO_PREF) {
this._updateDynamicPrefs();
Services.prefs.removeObserver(GEO_PREF, this);
}
break;
}
}
};
const EXPORTED_SYMBOLS = ["ActivityStream", "PREFS_CONFIG"];
================================================
FILE: lib/ActivityStreamMessageChannel.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 { AboutNewTab } = ChromeUtils.import(
"resource:///modules/AboutNewTab.jsm"
);
const { RemotePages } = ChromeUtils.import(
"resource://gre/modules/remotepagemanager/RemotePageManagerParent.jsm"
);
const {
actionCreators: ac,
actionTypes: at,
actionUtils: au,
} = ChromeUtils.import("resource://activity-stream/common/Actions.jsm");
const ABOUT_NEW_TAB_URL = "about:newtab";
const ABOUT_HOME_URL = "about:home";
const DEFAULT_OPTIONS = {
dispatch(action) {
throw new Error(
`\nMessageChannel: Received action ${
action.type
}, but no dispatcher was defined.\n`
);
},
pageURL: ABOUT_NEW_TAB_URL,
outgoingMessageName: "ActivityStream:MainToContent",
incomingMessageName: "ActivityStream:ContentToMain",
};
this.ActivityStreamMessageChannel = class ActivityStreamMessageChannel {
/**
* ActivityStreamMessageChannel - This module connects a Redux store to a RemotePageManager in Firefox.
* Call .createChannel to start the connection, and .destroyChannel to destroy it.
* You should use the BroadcastToContent, AlsoToOneContent, and AlsoToMain action creators
* in common/Actions.jsm to help you create actions that will be automatically routed
* to the correct location.
*
* @param {object} options
* @param {function} options.dispatch The dispatch method from a Redux store
* @param {string} options.pageURL The URL to which a RemotePageManager should be attached.
* Note that if it is about:newtab, the existing RemotePageManager
* for about:newtab will also be disabled
* @param {string} options.outgoingMessageName The name of the message sent to child processes
* @param {string} options.incomingMessageName The name of the message received from child processes
* @return {ActivityStreamMessageChannel}
*/
constructor(options = {}) {
Object.assign(this, DEFAULT_OPTIONS, options);
this.channel = null;
this.middleware = this.middleware.bind(this);
this.onMessage = this.onMessage.bind(this);
this.onNewTabLoad = this.onNewTabLoad.bind(this);
this.onNewTabUnload = this.onNewTabUnload.bind(this);
this.onNewTabInit = this.onNewTabInit.bind(this);
}
/**
* middleware - Redux middleware that looks for AlsoToOneContent and BroadcastToContent type
* actions, and sends them out.
*
* @param {object} store A redux store
* @return {function} Redux middleware
*/
middleware(store) {
return next => action => {
const skipMain = action.meta && action.meta.skipMain;
if (!this.channel && !skipMain) {
next(action);
return;
}
if (au.isSendToOneContent(action)) {
this.send(action);
} else if (au.isBroadcastToContent(action)) {
this.broadcast(action);
} else if (au.isSendToPreloaded(action)) {
this.sendToPreloaded(action);
}
if (!skipMain) {
next(action);
}
};
}
/**
* onActionFromContent - Handler for actions from a content processes
*
* @param {object} action A Redux action
* @param {string} targetId The portID of the port that sent the message
*/
onActionFromContent(action, targetId) {
this.dispatch(ac.AlsoToMain(action, this.validatePortID(targetId)));
}
/**
* broadcast - Sends an action to all ports
*
* @param {object} action A Redux action
*/
broadcast(action) {
this.channel.sendAsyncMessage(this.outgoingMessageName, action);
}
/**
* send - Sends an action to a specific port
*
* @param {obj} action A redux action; it should contain a portID in the meta.toTarget property
*/
send(action) {
const targetId = action.meta && action.meta.toTarget;
const target = this.getTargetById(targetId);
try {
target.sendAsyncMessage(this.outgoingMessageName, action);
} catch (e) {
// The target page is closed/closing by the user or test, so just ignore.
}
}
/**
* A valid portID is a combination of process id and port
* https://searchfox.org/mozilla-central/rev/196560b95f191b48ff7cba7c2ba9237bba6b5b6a/toolkit/components/remotepagemanager/RemotePageManagerChild.jsm#14
*/
validatePortID(id) {
if (typeof id !== "string" || !id.includes(":")) {
Cu.reportError("Invalid portID");
}
return id;
}
/**
* getIdByTarget - Retrieve the id of a message target, if it exists in this.targets
*
* @param {obj} targetObj A message target
* @return {string|null} The unique id of the target, if it exists.
*/
getTargetById(id) {
this.validatePortID(id);
for (let port of this.channel.messagePorts) {
if (port.portID === id) {
return port;
}
}
return null;
}
/**
* sendToPreloaded - Sends an action to each preloaded browser, if any
*
* @param {obj} action A redux action
*/
sendToPreloaded(action) {
const preloadedBrowsers = this.getPreloadedBrowser();
if (preloadedBrowsers && action.data) {
for (let preloadedBrowser of preloadedBrowsers) {
try {
preloadedBrowser.sendAsyncMessage(this.outgoingMessageName, action);
} catch (e) {
// The preloaded page is no longer available, so just ignore.
}
}
}
}
/**
* getPreloadedBrowser - Retrieve the port of any preloaded browsers
*
* @return {Array|null} An array of ports belonging to the preloaded browsers, or null
* if there aren't any preloaded browsers
*/
getPreloadedBrowser() {
let preloadedPorts = [];
for (let port of this.channel.messagePorts) {
if (this.isPreloadedBrowser(port.browser)) {
preloadedPorts.push(port);
}
}
return preloadedPorts.length ? preloadedPorts : null;
}
/**
* isPreloadedBrowser - Returns true if the passed browser has been preloaded
* for faster rendering of new tabs.
*
* @param {} A to check.
* @return {bool} True if the browser is preloaded.
* if there aren't any preloaded browsers
*/
isPreloadedBrowser(browser) {
return browser.getAttribute("preloadedState") === "preloaded";
}
/**
* createChannel - Create RemotePages channel to establishing message passing
* between the main process and child pages
*/
createChannel() {
// Receive AboutNewTab's Remote Pages instance, if it exists, on override
const channel =
this.pageURL === ABOUT_NEW_TAB_URL && AboutNewTab.override(true);
this.channel =
channel || new RemotePages([ABOUT_HOME_URL, ABOUT_NEW_TAB_URL]);
this.channel.addMessageListener("RemotePage:Init", this.onNewTabInit);
this.channel.addMessageListener("RemotePage:Load", this.onNewTabLoad);
this.channel.addMessageListener("RemotePage:Unload", this.onNewTabUnload);
this.channel.addMessageListener(this.incomingMessageName, this.onMessage);
}
simulateMessagesForExistingTabs() {
// Some pages might have already loaded, so we won't get the usual message
for (const target of this.channel.messagePorts) {
const simulatedMsg = {
target: Object.assign({ simulated: true }, target),
};
this.onNewTabInit(simulatedMsg);
if (target.loaded) {
this.onNewTabLoad(simulatedMsg);
}
}
}
/**
* destroyChannel - Destroys the RemotePages channel
*/
destroyChannel() {
this.channel.removeMessageListener("RemotePage:Init", this.onNewTabInit);
this.channel.removeMessageListener("RemotePage:Load", this.onNewTabLoad);
this.channel.removeMessageListener(
"RemotePage:Unload",
this.onNewTabUnload
);
this.channel.removeMessageListener(
this.incomingMessageName,
this.onMessage
);
if (this.pageURL === ABOUT_NEW_TAB_URL) {
AboutNewTab.reset(this.channel);
} else {
this.channel.destroy();
}
this.channel = null;
}
/**
* onNewTabInit - Handler for special RemotePage:Init message fired
* by RemotePages
*
* @param {obj} msg The messsage from a page that was just initialized
*/
onNewTabInit(msg) {
this.onActionFromContent(
{
type: at.NEW_TAB_INIT,
data: msg.target,
},
msg.target.portID
);
}
/**
* onNewTabLoad - Handler for special RemotePage:Load message fired by RemotePages
*
* @param {obj} msg The messsage from a page that was just loaded
*/
onNewTabLoad(msg) {
let { browser } = msg.target;
if (this.isPreloadedBrowser(browser)) {
// As a perceived performance optimization, if this loaded Activity Stream
// happens to be a preloaded browser, have it render its layers to the
// compositor now to increase the odds that by the time we switch to
// the tab, the layers are already ready to present to the user.
browser.renderLayers = true;
}
this.onActionFromContent({ type: at.NEW_TAB_LOAD }, msg.target.portID);
}
/**
* onNewTabUnloadLoad - Handler for special RemotePage:Unload message fired by RemotePages
*
* @param {obj} msg The messsage from a page that was just unloaded
*/
onNewTabUnload(msg) {
this.onActionFromContent({ type: at.NEW_TAB_UNLOAD }, msg.target.portID);
}
/**
* onMessage - Handles custom messages from content. It expects all messages to
* be formatted as Redux actions, and dispatches them to this.store
*
* @param {obj} msg A custom message from content
* @param {obj} msg.action A Redux action (e.g. {type: "HELLO_WORLD"})
* @param {obj} msg.target A message target
*/
onMessage(msg) {
const { portID } = msg.target;
if (!msg.data || !msg.data.type) {
Cu.reportError(
new Error(`Received an improperly formatted message from ${portID}`)
);
return;
}
let action = {};
Object.assign(action, msg.data);
// target is used to access a browser reference that came from the content
// and should only be used in feeds (not reducers)
action._target = msg.target;
this.onActionFromContent(action, portID);
}
};
this.DEFAULT_OPTIONS = DEFAULT_OPTIONS;
const EXPORTED_SYMBOLS = ["ActivityStreamMessageChannel", "DEFAULT_OPTIONS"];
================================================
FILE: lib/ActivityStreamPrefs.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 { AppConstants } = ChromeUtils.import(
"resource://gre/modules/AppConstants.jsm"
);
const { Preferences } = ChromeUtils.import(
"resource://gre/modules/Preferences.jsm"
);
const ACTIVITY_STREAM_PREF_BRANCH = "browser.newtabpage.activity-stream.";
this.Prefs = class Prefs extends Preferences {
/**
* Prefs - A wrapper around Preferences that always sets the branch to
* ACTIVITY_STREAM_PREF_BRANCH
*/
constructor(branch = ACTIVITY_STREAM_PREF_BRANCH) {
super({ branch });
this._branchObservers = new Map();
}
ignoreBranch(listener) {
const observer = this._branchObservers.get(listener);
this._prefBranch.removeObserver("", observer);
this._branchObservers.delete(listener);
}
observeBranch(listener) {
const observer = (subject, topic, pref) => {
listener.onPrefChanged(pref, this.get(pref));
};
this._prefBranch.addObserver("", observer);
this._branchObservers.set(listener, observer);
}
};
this.DefaultPrefs = class DefaultPrefs extends Preferences {
/**
* DefaultPrefs - A helper for setting and resetting default prefs for the add-on
*
* @param {Map} config A Map with {string} key of the pref name and {object}
* value with the following pref properties:
* {string} .title (optional) A description of the pref
* {bool|string|number} .value The default value for the pref
* @param {string} branch (optional) The pref branch (defaults to ACTIVITY_STREAM_PREF_BRANCH)
*/
constructor(config, branch = ACTIVITY_STREAM_PREF_BRANCH) {
super({
branch,
defaultBranch: true,
});
this._config = config;
}
/**
* init - Set default prefs for all prefs in the config
*/
init() {
// Local developer builds (with the default mozconfig) aren't OFFICIAL
const IS_UNOFFICIAL_BUILD = !AppConstants.MOZILLA_OFFICIAL;
for (const pref of this._config.keys()) {
try {
// Avoid replacing existing valid default pref values, e.g., those set
// via Autoconfig or policy
if (this.get(pref) !== undefined) {
continue;
}
} catch (ex) {
// We get NS_ERROR_UNEXPECTED for prefs that have a user value (causing
// default branch to believe there's a type) but no actual default value
}
const prefConfig = this._config.get(pref);
let value;
if (IS_UNOFFICIAL_BUILD && "value_local_dev" in prefConfig) {
value = prefConfig.value_local_dev;
} else {
value = prefConfig.value;
}
try {
this.set(pref, value);
} catch (ex) {
// Potentially the user somehow set an unexpected value type, so we fail
// to set a default of our expected type
}
}
}
};
const EXPORTED_SYMBOLS = ["DefaultPrefs", "Prefs"];
================================================
FILE: lib/ActivityStreamStorage.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/. */
ChromeUtils.defineModuleGetter(
this,
"IndexedDB",
"resource://gre/modules/IndexedDB.jsm"
);
this.ActivityStreamStorage = class ActivityStreamStorage {
/**
* @param storeNames Array of strings used to create all the required stores
*/
constructor({ storeNames, telemetry }) {
if (!storeNames) {
throw new Error("storeNames required");
}
this.dbName = "ActivityStream";
this.dbVersion = 3;
this.storeNames = storeNames;
this.telemetry = telemetry;
}
get db() {
return this._db || (this._db = this.createOrOpenDb());
}
/**
* Public method that binds the store required by the consumer and exposes
* the private db getters and setters.
*
* @param storeName String name of desired store
*/
getDbTable(storeName) {
if (this.storeNames.includes(storeName)) {
return {
get: this._get.bind(this, storeName),
getAll: this._getAll.bind(this, storeName),
set: this._set.bind(this, storeName),
};
}
throw new Error(`Store name ${storeName} does not exist.`);
}
async _getStore(storeName) {
return (await this.db).objectStore(storeName, "readwrite");
}
_get(storeName, key) {
return this._requestWrapper(async () =>
(await this._getStore(storeName)).get(key)
);
}
_getAll(storeName) {
return this._requestWrapper(async () =>
(await this._getStore(storeName)).getAll()
);
}
_set(storeName, key, value) {
return this._requestWrapper(async () =>
(await this._getStore(storeName)).put(value, key)
);
}
_openDatabase() {
return IndexedDB.open(this.dbName, { version: this.dbVersion }, db => {
// If provided with array of objectStore names we need to create all the
// individual stores
this.storeNames.forEach(store => {
if (!db.objectStoreNames.contains(store)) {
this._requestWrapper(() => db.createObjectStore(store));
}
});
});
}
/**
* createOrOpenDb - Open a db (with this.dbName) if it exists.
* If it does not exist, create it.
* If an error occurs, deleted the db and attempt to
* re-create it.
* @returns Promise that resolves with a db instance
*/
async createOrOpenDb() {
try {
const db = await this._openDatabase();
return db;
} catch (e) {
if (this.telemetry) {
this.telemetry.handleUndesiredEvent({
data: { event: "INDEXEDDB_OPEN_FAILED" },
});
}
await IndexedDB.deleteDatabase(this.dbName);
return this._openDatabase();
}
}
async _requestWrapper(request) {
let result = null;
try {
result = await request();
} catch (e) {
if (this.telemetry) {
this.telemetry.handleUndesiredEvent({
data: { event: "TRANSACTION_FAILED" },
});
}
throw e;
}
return result;
}
};
function getDefaultOptions(options) {
return { collapsed: !!options.collapsed };
}
const EXPORTED_SYMBOLS = ["ActivityStreamStorage", "getDefaultOptions"];
================================================
FILE: lib/BookmarkPanelHub.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";
ChromeUtils.defineModuleGetter(
this,
"FxAccounts",
"resource://gre/modules/FxAccounts.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"Services",
"resource://gre/modules/Services.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"PrivateBrowsingUtils",
"resource://gre/modules/PrivateBrowsingUtils.jsm"
);
class _BookmarkPanelHub {
constructor() {
this._id = "BookmarkPanelHub";
this._trigger = { id: "bookmark-panel" };
this._handleMessageRequest = null;
this._addImpression = null;
this._dispatch = null;
this._initialized = false;
this._response = null;
this._l10n = null;
this.messageRequest = this.messageRequest.bind(this);
this.toggleRecommendation = this.toggleRecommendation.bind(this);
this.sendUserEventTelemetry = this.sendUserEventTelemetry.bind(this);
this.collapseMessage = this.collapseMessage.bind(this);
}
/**
* @param {function} handleMessageRequest
* @param {function} addImpression
* @param {function} dispatch - Used for sending user telemetry information
*/
init(handleMessageRequest, addImpression, dispatch) {
this._handleMessageRequest = handleMessageRequest;
this._addImpression = addImpression;
this._dispatch = dispatch;
this._l10n = new DOMLocalization();
this._initialized = true;
}
uninit() {
this._l10n = null;
this._initialized = false;
this._handleMessageRequest = null;
this._addImpression = null;
this._dispatch = null;
this._response = null;
}
/**
* Checks if a similar cached requests exists before forwarding the request
* to ASRouter. Caches only 1 request, unique identifier is `request.url`.
* Caching ensures we don't duplicate requests and telemetry pings.
* Return value is important for the caller to know if a message will be
* shown.
*
* @returns {obj|null} response object or null if no messages matched
*/
async messageRequest(target, win) {
if (!this._initialized) {
return false;
}
if (
this._response &&
this._response.win === win &&
this._response.url === target.url &&
this._response.content
) {
this.showMessage(this._response.content, target, win);
return true;
}
// If we didn't match on a previously cached request then make sure
// the container is empty
this._removeContainer(target);
const response = await this._handleMessageRequest({
triggerId: this._trigger.id,
});
return this.onResponse(response, target, win);
}
/**
* If the response contains a message render it and send an impression.
* Otherwise we remove the message from the container.
*/
onResponse(response, target, win) {
this._response = {
...response,
collapsed: false,
target,
win,
url: target.url,
};
if (response && response.content) {
// Only insert localization files if we need to show a message
win.MozXULElement.insertFTLIfNeeded("browser/newtab/asrouter.ftl");
win.MozXULElement.insertFTLIfNeeded("browser/branding/sync-brand.ftl");
this.showMessage(response.content, target, win);
this.sendImpression();
this.sendUserEventTelemetry("IMPRESSION", win);
} else {
this.hideMessage(target);
}
target.infoButton.disabled = !response;
return !!response;
}
showMessage(message, target, win) {
if (this._response && this._response.collapsed) {
this.toggleRecommendation(false);
return;
}
const createElement = elem =>
target.document.createElementNS("http://www.w3.org/1999/xhtml", elem);
let recommendation = target.container.querySelector("#cfrMessageContainer");
if (!recommendation) {
recommendation = createElement("div");
const headerContainer = createElement("div");
headerContainer.classList.add("cfrMessageHeader");
recommendation.setAttribute("id", "cfrMessageContainer");
recommendation.addEventListener("click", async e => {
target.hidePopup();
const url = await FxAccounts.config.promiseConnectAccountURI(
"bookmark"
);
win.ownerGlobal.openLinkIn(url, "tabshifted", {
private: false,
triggeringPrincipal: Services.scriptSecurityManager.createNullPrincipal(
{}
),
csp: null,
});
this.sendUserEventTelemetry("CLICK", win);
});
recommendation.style.color = message.color;
recommendation.style.background = `-moz-linear-gradient(-45deg, ${
message.background_color_1
} 0%, ${message.background_color_2} 70%)`;
const close = createElement("button");
close.setAttribute("id", "cfrClose");
close.setAttribute("aria-label", "close");
close.style.color = message.color;
close.addEventListener("click", e => {
this.sendUserEventTelemetry("DISMISS", win);
this.collapseMessage();
target.close(e);
});
const title = createElement("h1");
title.setAttribute("id", "editBookmarkPanelRecommendationTitle");
const content = createElement("p");
content.setAttribute("id", "editBookmarkPanelRecommendationContent");
const cta = createElement("button");
cta.setAttribute("id", "editBookmarkPanelRecommendationCta");
// If `string_id` is present it means we are relying on fluent for translations
if (message.text.string_id) {
this._l10n.setAttributes(
close,
message.close_button.tooltiptext.string_id
);
this._l10n.setAttributes(title, message.title.string_id);
this._l10n.setAttributes(content, message.text.string_id);
this._l10n.setAttributes(cta, message.cta.string_id);
} else {
close.setAttribute("title", message.close_button.tooltiptext);
title.textContent = message.title;
content.textContent = message.text;
cta.textContent = message.cta;
}
headerContainer.appendChild(title);
headerContainer.appendChild(close);
recommendation.appendChild(headerContainer);
recommendation.appendChild(content);
recommendation.appendChild(cta);
target.container.appendChild(recommendation);
}
this.toggleRecommendation(true);
this._adjustPanelHeight(win, recommendation);
}
/**
* Adjust the size of the container for locales where the message is
* longer than the fixed 150px set for height
*/
async _adjustPanelHeight(window, messageContainer) {
const { document } = window;
// Contains the screenshot of the page we are bookmarking
const screenshotContainer = document.getElementById(
"editBookmarkPanelImage"
);
// Wait for strings to be added which can change element height
await document.l10n.translateElements([messageContainer]);
window.requestAnimationFrame(() => {
let { height } = messageContainer.getBoundingClientRect();
if (height > 150) {
messageContainer.classList.add("longMessagePadding");
// Get the new value with the added padding
height = messageContainer.getBoundingClientRect().height;
// Needs to be adjusted to match the message height
screenshotContainer.style.height = `${height}px`;
}
});
}
/**
* Restore the panel back to the original size so the slide in
* animation can run again
*/
_restorePanelHeight(window) {
const { document } = window;
// Contains the screenshot of the page we are bookmarking
document.getElementById("editBookmarkPanelImage").style.height = "";
}
toggleRecommendation(visible) {
if (!this._response) {
return;
}
const { target } = this._response;
if (visible === undefined) {
// When called from the info button of the bookmark panel
target.infoButton.checked = !target.infoButton.checked;
} else {
target.infoButton.checked = visible;
}
if (target.infoButton.checked) {
// If it was ever collapsed we need to cancel the state
this._response.collapsed = false;
target.container.removeAttribute("disabled");
} else {
target.container.setAttribute("disabled", "disabled");
}
}
collapseMessage() {
this._response.collapsed = true;
this.toggleRecommendation(false);
}
_removeContainer(target) {
if (target || (this._response && this._response.target)) {
const container = (
target || this._response.target
).container.querySelector("#cfrMessageContainer");
if (container) {
this._restorePanelHeight(this._response.win);
container.remove();
}
}
}
hideMessage(target) {
this._removeContainer(target);
this.toggleRecommendation(false);
this._response = null;
}
_forceShowMessage(target, message) {
const doc = target.browser.ownerGlobal.gBrowser.ownerDocument;
const win = target.browser.ownerGlobal.window;
const panelTarget = {
container: doc.getElementById("editBookmarkPanelRecommendation"),
infoButton: doc.getElementById("editBookmarkPanelInfoButton"),
document: doc,
close: e => {
e.stopPropagation();
this.toggleRecommendation(false);
},
};
// Remove any existing message
this.hideMessage(panelTarget);
// Reset the reference to the panel elements
this._response = { target: panelTarget, win };
// Required if we want to preview messages that include fluent strings
win.MozXULElement.insertFTLIfNeeded("browser/newtab/asrouter.ftl");
win.MozXULElement.insertFTLIfNeeded("browser/branding/sync-brand.ftl");
this.showMessage(message.content, panelTarget, win);
}
sendImpression() {
this._addImpression(this._response);
}
sendUserEventTelemetry(event, win) {
// Only send pings for non private browsing windows
if (
!PrivateBrowsingUtils.isBrowserPrivate(
win.ownerGlobal.gBrowser.selectedBrowser
)
) {
this._sendTelemetry({
message_id: this._response.id,
bucket_id: this._response.id,
event,
});
}
}
_sendTelemetry(ping) {
this._dispatch({
type: "DOORHANGER_TELEMETRY",
data: { action: "cfr_user_event", source: "CFR", ...ping },
});
}
}
this._BookmarkPanelHub = _BookmarkPanelHub;
/**
* BookmarkPanelHub - singleton instance of _BookmarkPanelHub that can initiate
* message requests and render messages.
*/
this.BookmarkPanelHub = new _BookmarkPanelHub();
const EXPORTED_SYMBOLS = ["BookmarkPanelHub", "_BookmarkPanelHub"];
================================================
FILE: lib/CFRMessageProvider.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 FACEBOOK_CONTAINER_PARAMS = {
existing_addons: [
"@contain-facebook",
"{bb1b80be-e6b3-40a1-9b6e-9d4073343f0b}",
"{a50d61ca-d27b-437a-8b52-5fd801a0a88b}",
],
open_urls: ["www.facebook.com", "facebook.com"],
sumo_path: "extensionrecommendations",
min_frecency: 10000,
};
const GOOGLE_TRANSLATE_PARAMS = {
existing_addons: [
"jid1-93WyvpgvxzGATw@jetpack",
"{087ef4e1-4286-4be6-9aa3-8d6c420ee1db}",
"{4170faaa-ee87-4a0e-b57a-1aec49282887}",
"jid1-TMndP6cdKgxLcQ@jetpack",
"s3google@translator",
"{9c63d15c-b4d9-43bd-b223-37f0a1f22e2a}",
"translator@zoli.bod",
"{8cda9ce6-7893-4f47-ac70-a65215cec288}",
"simple-translate@sienori",
"@translatenow",
"{a79fafce-8da6-4685-923f-7ba1015b8748})",
"{8a802b5a-eeab-11e2-a41d-b0096288709b}",
"jid0-fbHwsGfb6kJyq2hj65KnbGte3yT@jetpack",
"storetranslate.plugin@gmail.com",
"jid1-r2tWDbSkq8AZK1@jetpack",
"{b384b75c-c978-4c4d-b3cf-62a82d8f8f12}",
"jid1-f7dnBeTj8ElpWQ@jetpack",
"{dac8a935-4775-4918-9205-5c0600087dc4}",
"gtranslation2@slam.com",
"{e20e0de5-1667-4df4-bd69-705720e37391}",
"{09e26ae9-e9c1-477c-80a6-99934212f2fe}",
"mgxtranslator@magemagix.com",
"gtranslatewins@mozilla.org",
],
open_urls: ["translate.google.com"],
sumo_path: "extensionrecommendations",
min_frecency: 10000,
};
const YOUTUBE_ENHANCE_PARAMS = {
existing_addons: [
"enhancerforyoutube@maximerf.addons.mozilla.org",
"{dc8f61ab-5e98-4027-98ef-bb2ff6060d71}",
"{7b1bf0b6-a1b9-42b0-b75d-252036438bdc}",
"jid0-UVAeBCfd34Kk5usS8A1CBiobvM8@jetpack",
"iridium@particlecore.github.io",
"jid1-ss6kLNCbNz6u0g@jetpack",
"{1cf918d2-f4ea-4b4f-b34e-455283fef19f}",
],
open_urls: ["www.youtube.com", "youtube.com"],
sumo_path: "extensionrecommendations",
min_frecency: 10000,
};
const WIKIPEDIA_CONTEXT_MENU_SEARCH_PARAMS = {
existing_addons: [
"@wikipediacontextmenusearch",
"{ebf47fc8-01d8-4dba-aa04-2118402f4b20}",
"{5737a280-b359-4e26-95b0-adec5915a854}",
"olivier.debroqueville@gmail.com",
"{3923146e-98cb-472b-9c13-f6849d34d6b8}",
],
open_urls: ["www.wikipedia.org", "wikipedia.org"],
sumo_path: "extensionrecommendations",
min_frecency: 10000,
};
const REDDIT_ENHANCEMENT_PARAMS = {
existing_addons: ["jid1-xUfzOsOFlzSOXg@jetpack"],
open_urls: ["www.reddit.com", "reddit.com"],
sumo_path: "extensionrecommendations",
min_frecency: 10000,
};
const PINNED_TABS_TARGET_SITES = [
"docs.google.com",
"www.docs.google.com",
"calendar.google.com",
"messenger.com",
"www.messenger.com",
"web.whatsapp.com",
"mail.google.com",
"outlook.live.com",
"facebook.com",
"www.facebook.com",
"twitter.com",
"www.twitter.com",
"reddit.com",
"www.reddit.com",
"github.com",
"www.github.com",
"youtube.com",
"www.youtube.com",
"feedly.com",
"www.feedly.com",
"drive.google.com",
"amazon.com",
"www.amazon.com",
"messages.android.com",
"amazon.ca",
"www.amazon.ca",
"amazon.com.au",
"www.amazon.com.au",
"amazon.co.uk",
"www.amazon.co.uk",
"amazon.fr",
"www.amazon.fr",
"amazon.de",
"www.amazon.de",
];
const PINNED_TABS_TARGET_LOCALES = [
"en-US",
"en-CA",
"en-AU",
"en-GB",
"en-ZA",
"en-NZ",
"fr",
"de",
];
const CFR_MESSAGES = [
{
id: "FACEBOOK_CONTAINER_3",
template: "cfr_doorhanger",
content: {
layout: "addon_recommendation",
category: "cfrAddons",
bucket_id: "CFR_M1",
notification_text: {
string_id: "cfr-doorhanger-extension-notification2",
},
heading_text: { string_id: "cfr-doorhanger-extension-heading" },
info_icon: {
label: { string_id: "cfr-doorhanger-extension-sumo-link" },
sumo_path: FACEBOOK_CONTAINER_PARAMS.sumo_path,
},
addon: {
id: "954390",
title: "Facebook Container",
icon:
"resource://activity-stream/data/content/assets/cfr_fb_container.png",
rating: 4.6,
users: 299019,
author: "Mozilla",
amo_url: "https://addons.mozilla.org/firefox/addon/facebook-container/",
},
text:
"Stop Facebook from tracking your activity across the web. Use Facebook the way you normally do without annoying ads following you around.",
buttons: {
primary: {
label: { string_id: "cfr-doorhanger-extension-ok-button" },
action: {
type: "INSTALL_ADDON_FROM_URL",
data: { url: null },
},
},
secondary: [
{
label: { string_id: "cfr-doorhanger-extension-cancel-button" },
action: { type: "CANCEL" },
},
{
label: {
string_id: "cfr-doorhanger-extension-never-show-recommendation",
},
},
{
label: {
string_id: "cfr-doorhanger-extension-manage-settings-button",
},
action: {
type: "OPEN_PREFERENCES_PAGE",
data: { category: "general-cfraddons" },
},
},
],
},
},
frequency: { lifetime: 3 },
targeting: `
localeLanguageCode == "en" &&
(xpinstallEnabled == true) &&
(${JSON.stringify(
FACEBOOK_CONTAINER_PARAMS.existing_addons
)} intersect addonsInfo.addons|keys)|length == 0 &&
(${JSON.stringify(
FACEBOOK_CONTAINER_PARAMS.open_urls
)} intersect topFrecentSites[.frecency >= ${
FACEBOOK_CONTAINER_PARAMS.min_frecency
}]|mapToProperty('host'))|length > 0`,
trigger: { id: "openURL", params: FACEBOOK_CONTAINER_PARAMS.open_urls },
},
{
id: "GOOGLE_TRANSLATE_3",
template: "cfr_doorhanger",
content: {
layout: "addon_recommendation",
category: "cfrAddons",
bucket_id: "CFR_M1",
notification_text: {
string_id: "cfr-doorhanger-extension-notification2",
},
heading_text: { string_id: "cfr-doorhanger-extension-heading" },
info_icon: {
label: { string_id: "cfr-doorhanger-extension-sumo-link" },
sumo_path: GOOGLE_TRANSLATE_PARAMS.sumo_path,
},
addon: {
id: "445852",
title: "To Google Translate",
icon:
"resource://activity-stream/data/content/assets/cfr_google_translate.png",
rating: 4.1,
users: 313474,
author: "Juan Escobar",
amo_url:
"https://addons.mozilla.org/firefox/addon/to-google-translate/",
},
text:
"Instantly translate any webpage text. Simply highlight the text, right-click to open the context menu, and choose a text or aural translation.",
buttons: {
primary: {
label: { string_id: "cfr-doorhanger-extension-ok-button" },
action: {
type: "INSTALL_ADDON_FROM_URL",
data: { url: null },
},
},
secondary: [
{
label: { string_id: "cfr-doorhanger-extension-cancel-button" },
action: { type: "CANCEL" },
},
{
label: {
string_id: "cfr-doorhanger-extension-never-show-recommendation",
},
},
{
label: {
string_id: "cfr-doorhanger-extension-manage-settings-button",
},
action: {
type: "OPEN_PREFERENCES_PAGE",
data: { category: "general-cfraddons" },
},
},
],
},
},
frequency: { lifetime: 3 },
targeting: `
localeLanguageCode == "en" &&
(xpinstallEnabled == true) &&
(${JSON.stringify(
GOOGLE_TRANSLATE_PARAMS.existing_addons
)} intersect addonsInfo.addons|keys)|length == 0 &&
(${JSON.stringify(
GOOGLE_TRANSLATE_PARAMS.open_urls
)} intersect topFrecentSites[.frecency >= ${
GOOGLE_TRANSLATE_PARAMS.min_frecency
}]|mapToProperty('host'))|length > 0`,
trigger: { id: "openURL", params: GOOGLE_TRANSLATE_PARAMS.open_urls },
},
{
id: "YOUTUBE_ENHANCE_3",
template: "cfr_doorhanger",
content: {
layout: "addon_recommendation",
category: "cfrAddons",
bucket_id: "CFR_M1",
notification_text: {
string_id: "cfr-doorhanger-extension-notification2",
},
heading_text: { string_id: "cfr-doorhanger-extension-heading" },
info_icon: {
label: { string_id: "cfr-doorhanger-extension-sumo-link" },
sumo_path: YOUTUBE_ENHANCE_PARAMS.sumo_path,
},
addon: {
id: "700308",
title: "Enhancer for YouTube\u2122",
icon:
"resource://activity-stream/data/content/assets/cfr_enhancer_youtube.png",
rating: 4.8,
users: 357328,
author: "Maxime RF",
amo_url:
"https://addons.mozilla.org/firefox/addon/enhancer-for-youtube/",
},
text:
"Take control of your YouTube experience. Automatically block annoying ads, set playback speed and volume, remove annotations, and more.",
buttons: {
primary: {
label: { string_id: "cfr-doorhanger-extension-ok-button" },
action: {
type: "INSTALL_ADDON_FROM_URL",
data: { url: null },
},
},
secondary: [
{
label: { string_id: "cfr-doorhanger-extension-cancel-button" },
action: { type: "CANCEL" },
},
{
label: {
string_id: "cfr-doorhanger-extension-never-show-recommendation",
},
},
{
label: {
string_id: "cfr-doorhanger-extension-manage-settings-button",
},
action: {
type: "OPEN_PREFERENCES_PAGE",
data: { category: "general-cfraddons" },
},
},
],
},
},
frequency: { lifetime: 3 },
targeting: `
localeLanguageCode == "en" &&
(xpinstallEnabled == true) &&
(${JSON.stringify(
YOUTUBE_ENHANCE_PARAMS.existing_addons
)} intersect addonsInfo.addons|keys)|length == 0 &&
(${JSON.stringify(
YOUTUBE_ENHANCE_PARAMS.open_urls
)} intersect topFrecentSites[.frecency >= ${
YOUTUBE_ENHANCE_PARAMS.min_frecency
}]|mapToProperty('host'))|length > 0`,
trigger: { id: "openURL", params: YOUTUBE_ENHANCE_PARAMS.open_urls },
},
{
id: "WIKIPEDIA_CONTEXT_MENU_SEARCH_3",
template: "cfr_doorhanger",
exclude: true,
content: {
layout: "addon_recommendation",
category: "cfrAddons",
bucket_id: "CFR_M1",
notification_text: {
string_id: "cfr-doorhanger-extension-notification2",
},
heading_text: { string_id: "cfr-doorhanger-extension-heading" },
info_icon: {
label: { string_id: "cfr-doorhanger-extension-sumo-link" },
sumo_path: WIKIPEDIA_CONTEXT_MENU_SEARCH_PARAMS.sumo_path,
},
addon: {
id: "659026",
title: "Wikipedia Context Menu Search",
icon:
"resource://activity-stream/data/content/assets/cfr_wiki_search.png",
rating: 4.9,
users: 3095,
author: "Nick Diedrich",
amo_url:
"https://addons.mozilla.org/firefox/addon/wikipedia-context-menu-search/",
},
text:
"Get to a Wikipedia page fast, from anywhere on the web. Just highlight any webpage text and right-click to open the context menu to start a Wikipedia search.",
buttons: {
primary: {
label: { string_id: "cfr-doorhanger-extension-ok-button" },
action: {
type: "INSTALL_ADDON_FROM_URL",
data: { url: null },
},
},
secondary: [
{
label: { string_id: "cfr-doorhanger-extension-cancel-button" },
action: { type: "CANCEL" },
},
{
label: {
string_id: "cfr-doorhanger-extension-never-show-recommendation",
},
},
{
label: {
string_id: "cfr-doorhanger-extension-manage-settings-button",
},
action: {
type: "OPEN_PREFERENCES_PAGE",
data: { category: "general-cfraddons" },
},
},
],
},
},
frequency: { lifetime: 3 },
targeting: `
localeLanguageCode == "en" &&
(xpinstallEnabled == true) &&
(${JSON.stringify(
WIKIPEDIA_CONTEXT_MENU_SEARCH_PARAMS.existing_addons
)} intersect addonsInfo.addons|keys)|length == 0 &&
(${JSON.stringify(
WIKIPEDIA_CONTEXT_MENU_SEARCH_PARAMS.open_urls
)} intersect topFrecentSites[.frecency >= ${
WIKIPEDIA_CONTEXT_MENU_SEARCH_PARAMS.min_frecency
}]|mapToProperty('host'))|length > 0`,
trigger: {
id: "openURL",
params: WIKIPEDIA_CONTEXT_MENU_SEARCH_PARAMS.open_urls,
},
},
{
id: "REDDIT_ENHANCEMENT_3",
template: "cfr_doorhanger",
exclude: true,
content: {
layout: "addon_recommendation",
category: "cfrAddons",
bucket_id: "CFR_M1",
notification_text: {
string_id: "cfr-doorhanger-extension-notification2",
},
heading_text: { string_id: "cfr-doorhanger-extension-heading" },
info_icon: {
label: { string_id: "cfr-doorhanger-extension-sumo-link" },
sumo_path: REDDIT_ENHANCEMENT_PARAMS.sumo_path,
},
addon: {
id: "387429",
title: "Reddit Enhancement Suite",
icon:
"resource://activity-stream/data/content/assets/cfr_reddit_enhancement.png",
rating: 4.6,
users: 258129,
author: "honestbleeps",
amo_url:
"https://addons.mozilla.org/firefox/addon/reddit-enhancement-suite/",
},
text:
"New features include Inline Image Viewer, Never Ending Reddit (never click 'next page' again), Keyboard Navigation, Account Switcher, and User Tagger.",
buttons: {
primary: {
label: { string_id: "cfr-doorhanger-extension-ok-button" },
action: {
type: "INSTALL_ADDON_FROM_URL",
data: { url: null },
},
},
secondary: [
{
label: { string_id: "cfr-doorhanger-extension-cancel-button" },
action: { type: "CANCEL" },
},
{
label: {
string_id: "cfr-doorhanger-extension-never-show-recommendation",
},
},
{
label: {
string_id: "cfr-doorhanger-extension-manage-settings-button",
},
action: {
type: "OPEN_PREFERENCES_PAGE",
data: { category: "general-cfraddons" },
},
},
],
},
},
frequency: { lifetime: 3 },
targeting: `
localeLanguageCode == "en" &&
(xpinstallEnabled == true) &&
(${JSON.stringify(
REDDIT_ENHANCEMENT_PARAMS.existing_addons
)} intersect addonsInfo.addons|keys)|length == 0 &&
(${JSON.stringify(
REDDIT_ENHANCEMENT_PARAMS.open_urls
)} intersect topFrecentSites[.frecency >= ${
REDDIT_ENHANCEMENT_PARAMS.min_frecency
}]|mapToProperty('host'))|length > 0`,
trigger: { id: "openURL", params: REDDIT_ENHANCEMENT_PARAMS.open_urls },
},
{
id: "PIN_TAB",
template: "cfr_doorhanger",
content: {
layout: "message_and_animation",
category: "cfrFeatures",
bucket_id: "CFR_PIN_TAB",
notification_text: { string_id: "cfr-doorhanger-feature-notification" },
heading_text: { string_id: "cfr-doorhanger-pintab-heading" },
info_icon: {
label: { string_id: "cfr-doorhanger-extension-sumo-link" },
sumo_path: REDDIT_ENHANCEMENT_PARAMS.sumo_path,
},
text: { string_id: "cfr-doorhanger-pintab-description" },
descriptionDetails: {
steps: [
{ string_id: "cfr-doorhanger-pintab-step1" },
{ string_id: "cfr-doorhanger-pintab-step2" },
{ string_id: "cfr-doorhanger-pintab-step3" },
],
},
buttons: {
primary: {
label: { string_id: "cfr-doorhanger-pintab-ok-button" },
action: {
type: "PIN_CURRENT_TAB",
},
},
secondary: [
{
label: { string_id: "cfr-doorhanger-extension-cancel-button" },
action: { type: "CANCEL" },
},
{
label: {
string_id: "cfr-doorhanger-extension-never-show-recommendation",
},
},
{
label: {
string_id: "cfr-doorhanger-extension-manage-settings-button",
},
action: {
type: "OPEN_PREFERENCES_PAGE",
data: { category: "general-cfrfeatures" },
},
},
],
},
},
targeting: `locale in ${JSON.stringify(
PINNED_TABS_TARGET_LOCALES
)} && !hasPinnedTabs && recentVisits[.timestamp > (currentDate|date - 3600 * 1000 * 1)]|length >= 3`,
frequency: { lifetime: 3 },
trigger: { id: "frequentVisits", params: PINNED_TABS_TARGET_SITES },
},
{
id: "SAVE_LOGIN",
frequency: {
lifetime: 3,
},
targeting: "usesFirefoxSync == false",
template: "cfr_doorhanger",
last_modified: 1565907636313,
content: {
layout: "icon_and_message",
text: {
string_id: "cfr-doorhanger-sync-logins-body",
},
icon: "chrome://browser/content/aboutlogins/icons/intro-illustration.svg",
icon_class: "cfr-doorhanger-large-icon",
buttons: {
secondary: [
{
label: {
string_id: "cfr-doorhanger-extension-cancel-button",
},
action: {
type: "CANCEL",
},
},
{
label: {
string_id: "cfr-doorhanger-extension-never-show-recommendation",
},
},
{
label: {
string_id: "cfr-doorhanger-extension-manage-settings-button",
},
action: {
type: "OPEN_PREFERENCES_PAGE",
data: {
category: "general-cfrfeatures",
},
},
},
],
primary: {
label: {
string_id: "cfr-doorhanger-sync-logins-ok-button",
},
action: {
type: "OPEN_PREFERENCES_PAGE",
data: {
category: "sync",
},
},
},
},
bucket_id: "CFR_SAVE_LOGIN",
heading_text: {
string_id: "cfr-doorhanger-sync-logins-header",
},
info_icon: {
label: {
string_id: "cfr-doorhanger-extension-sumo-link",
},
sumo_path: "extensionrecommendations",
},
notification_text: {
string_id: "cfr-doorhanger-feature-notification",
},
category: "cfrFeatures",
},
trigger: {
id: "newSavedLogin",
},
},
{
id: "SOCIAL_TRACKING_PROTECTION",
template: "cfr_doorhanger",
priority: 1,
content: {
layout: "icon_and_message",
category: "cfrFeatures",
anchor_id: "tracking-protection-icon-box",
skip_address_bar_notifier: true,
bucket_id: "CFR_SOCIAL_TRACKING_PROTECTION",
heading_text: { string_id: "cfr-doorhanger-socialtracking-heading" },
notification_text: "",
info_icon: {
label: {
string_id: "cfr-doorhanger-extension-sumo-link",
},
sumo_path: "extensionrecommendations",
},
learn_more: "social-media-tracking-report",
text: { string_id: "cfr-doorhanger-socialtracking-description" },
icon: "chrome://browser/skin/notification-icons/block-social.svg",
icon_dark_theme:
"chrome://browser/skin/notification-icons/block-social-dark.svg",
buttons: {
primary: {
label: { string_id: "cfr-doorhanger-socialtracking-ok-button" },
action: { type: "OPEN_PROTECTION_PANEL" },
event: "PROTECTION",
},
secondary: [
{
label: { string_id: "cfr-doorhanger-socialtracking-close-button" },
event: "BLOCK",
},
{
label: {
string_id: "cfr-doorhanger-socialtracking-dont-show-again",
},
action: { type: "DISABLE_STP_DOORHANGERS" },
event: "BLOCK",
},
],
},
},
targeting: "pageLoad >= 4 && firefoxVersion >= 71",
frequency: {
lifetime: 2,
custom: [{ period: 2 * 86400 * 1000, cap: 1 }],
},
trigger: {
id: "trackingProtection",
params: [
Ci.nsIWebProgressListener.STATE_BLOCKED_SOCIALTRACKING_CONTENT,
Ci.nsIWebProgressListener.STATE_LOADED_SOCIALTRACKING_CONTENT |
Ci.nsIWebProgressListener.STATE_COOKIES_BLOCKED_TRACKER,
],
},
},
{
id: "FINGERPRINTERS_PROTECTION",
template: "cfr_doorhanger",
priority: 2,
content: {
layout: "icon_and_message",
category: "cfrFeatures",
anchor_id: "tracking-protection-icon-box",
skip_address_bar_notifier: true,
bucket_id: "CFR_SOCIAL_TRACKING_PROTECTION",
heading_text: { string_id: "cfr-doorhanger-fingerprinters-heading" },
notification_text: "",
info_icon: {
label: {
string_id: "cfr-doorhanger-extension-sumo-link",
},
sumo_path: "extensionrecommendations",
},
learn_more: "fingerprinters-report",
text: { string_id: "cfr-doorhanger-fingerprinters-description" },
icon: "chrome://browser/skin/notification-icons/block-fingerprinter.svg",
icon_dark_theme:
"chrome://browser/skin/notification-icons/block-fingerprinter-dark.svg",
buttons: {
primary: {
label: { string_id: "cfr-doorhanger-socialtracking-ok-button" },
action: { type: "OPEN_PROTECTION_PANEL" },
event: "PROTECTION",
},
secondary: [
{
label: { string_id: "cfr-doorhanger-socialtracking-close-button" },
event: "BLOCK",
},
{
label: {
string_id: "cfr-doorhanger-socialtracking-dont-show-again",
},
action: { type: "DISABLE_STP_DOORHANGERS" },
event: "BLOCK",
},
],
},
},
targeting: "pageLoad >= 4 && firefoxVersion >= 71",
frequency: {
lifetime: 2,
custom: [{ period: 2 * 86400 * 1000, cap: 1 }],
},
trigger: {
id: "trackingProtection",
params: [Ci.nsIWebProgressListener.STATE_BLOCKED_FINGERPRINTING_CONTENT],
},
},
{
id: "CRYPTOMINERS_PROTECTION",
template: "cfr_doorhanger",
priority: 3,
content: {
layout: "icon_and_message",
category: "cfrFeatures",
anchor_id: "tracking-protection-icon-box",
skip_address_bar_notifier: true,
bucket_id: "CFR_SOCIAL_TRACKING_PROTECTION",
heading_text: { string_id: "cfr-doorhanger-cryptominers-heading" },
notification_text: "",
info_icon: {
label: {
string_id: "cfr-doorhanger-extension-sumo-link",
},
sumo_path: "extensionrecommendations",
},
learn_more: "cryptominers-report",
text: { string_id: "cfr-doorhanger-cryptominers-description" },
icon: "chrome://browser/skin/notification-icons/block-cryptominer.svg",
icon_dark_theme:
"chrome://browser/skin/notification-icons/block-cryptominer-dark.svg",
buttons: {
primary: {
label: { string_id: "cfr-doorhanger-socialtracking-ok-button" },
action: { type: "OPEN_PROTECTION_PANEL" },
event: "PROTECTION",
},
secondary: [
{
label: { string_id: "cfr-doorhanger-socialtracking-close-button" },
event: "BLOCK",
},
{
label: {
string_id: "cfr-doorhanger-socialtracking-dont-show-again",
},
action: { type: "DISABLE_STP_DOORHANGERS" },
event: "BLOCK",
},
],
},
},
targeting: "pageLoad >= 4 && firefoxVersion >= 71",
frequency: {
lifetime: 2,
custom: [{ period: 2 * 86400 * 1000, cap: 1 }],
},
trigger: {
id: "trackingProtection",
params: [Ci.nsIWebProgressListener.STATE_BLOCKED_CRYPTOMINING_CONTENT],
},
},
{
id: "MILESTONE_MESSAGE",
template: "milestone_message",
content: {
layout: "short_message",
category: "cfrFeatures",
anchor_id: "tracking-protection-icon-box",
skip_address_bar_notifier: true,
bucket_id: "CFR_MILESTONE_MESSAGE",
heading_text: { string_id: "cfr-doorhanger-milestone-heading" },
notification_text: "",
text: "",
buttons: {
primary: {
label: { string_id: "cfr-doorhanger-milestone-ok-button" },
action: { type: "OPEN_PROTECTION_REPORT" },
event: "PROTECTION",
},
},
},
targeting: "pageLoad >= 4",
frequency: {
lifetime: 7, // Length of privacy.trackingprotection.cfr-milestone.milestones pref
},
trigger: {
id: "trackingProtection",
params: ["ContentBlockingMilestone"],
},
},
];
const CFRMessageProvider = {
getMessages() {
return CFR_MESSAGES.filter(msg => !msg.exclude);
},
};
this.CFRMessageProvider = CFRMessageProvider;
const EXPORTED_SYMBOLS = ["CFRMessageProvider"];
================================================
FILE: lib/CFRPageActions.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 { XPCOMUtils } = ChromeUtils.import(
"resource://gre/modules/XPCOMUtils.jsm"
);
const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
XPCOMUtils.defineLazyGlobalGetters(this, ["fetch"]);
XPCOMUtils.defineLazyModuleGetters(this, {
PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.jsm",
RemoteL10n: "resource://activity-stream/lib/RemoteL10n.jsm",
});
XPCOMUtils.defineLazyServiceGetter(
this,
"TrackingDBService",
"@mozilla.org/tracking-db-service;1",
"nsITrackingDBService"
);
XPCOMUtils.defineLazyPreferenceGetter(
this,
"milestones",
"browser.contentblocking.cfr-milestone.milestones",
"[]",
null,
JSON.parse
);
const POPUP_NOTIFICATION_ID = "contextual-feature-recommendation";
const ANIMATION_BUTTON_ID = "cfr-notification-footer-animation-button";
const ANIMATION_LABEL_ID = "cfr-notification-footer-animation-label";
const SUMO_BASE_URL = Services.urlFormatter.formatURLPref(
"app.support.baseURL"
);
const ADDONS_API_URL =
"https://services.addons.mozilla.org/api/v3/addons/addon";
const ANIMATIONS_ENABLED_PREF = "toolkit.cosmeticAnimations.enabled";
const DELAY_BEFORE_EXPAND_MS = 1000;
const CATEGORY_ICONS = {
cfrAddons: "webextensions-icon",
cfrFeatures: "recommendations-icon",
};
/**
* A WeakMap from browsers to {host, recommendation} pairs. Recommendations are
* defined in the ExtensionDoorhanger.schema.json.
*
* A recommendation is specific to a browser and host and is active until the
* given browser is closed or the user navigates (within that browser) away from
* the host.
*/
let RecommendationMap = new WeakMap();
/**
* A WeakMap from windows to their CFR PageAction.
*/
let PageActionMap = new WeakMap();
/**
* We need one PageAction for each window
*/
class PageAction {
constructor(win, dispatchToASRouter) {
this.window = win;
this.urlbar = win.document.getElementById("urlbar");
// `this.urlbar` is the larger container that holds both the urlbar input
// and the page action buttons. The focus event will be triggered by the
// `urlbar-input`.
this.urlbarinput = win.document.getElementById("urlbar-input");
this.container = win.document.getElementById(
"contextual-feature-recommendation"
);
this.button = win.document.getElementById("cfr-button");
this.label = win.document.getElementById("cfr-label");
// This should NOT be use directly to dispatch message-defined actions attached to buttons.
// Please use dispatchUserAction instead.
this._dispatchToASRouter = dispatchToASRouter;
this._popupStateChange = this._popupStateChange.bind(this);
this._collapse = this._collapse.bind(this);
this._showPopupOnClick = this._showPopupOnClick.bind(this);
this.dispatchUserAction = this.dispatchUserAction.bind(this);
// Saved timeout IDs for scheduled state changes, so they can be cancelled
this.stateTransitionTimeoutIDs = [];
XPCOMUtils.defineLazyGetter(this, "isDarkTheme", () => {
try {
return this.window.document.documentElement.hasAttribute(
"lwt-toolbar-field-brighttext"
);
} catch (e) {
return false;
}
});
}
addImpression(recommendation) {
this._dispatchImpression(recommendation);
// Only send an impression ping upon the first expansion.
// Note that when the user clicks on the "show" button on the asrouter admin
// page (both `bucket_id` and `id` will be set as null), we don't want to send
// the impression ping in that case.
if (!!recommendation.id && !!recommendation.content.bucket_id) {
this._sendTelemetry({
message_id: recommendation.id,
bucket_id: recommendation.content.bucket_id,
event: "IMPRESSION",
...(recommendation.personalizedModelVersion
? {
event_context: {
modelVersion: recommendation.personalizedModelVersion,
},
}
: {}),
});
}
}
reloadL10n() {
RemoteL10n.reloadL10n();
}
async showAddressBarNotifier(recommendation, shouldExpand = false) {
this.container.hidden = false;
let notificationText = await this.getStrings(
recommendation.content.notification_text
);
this.label.value = notificationText;
if (notificationText.attributes) {
this.button.setAttribute(
"tooltiptext",
notificationText.attributes.tooltiptext
);
// For a11y, we want the more descriptive text.
this.container.setAttribute(
"aria-label",
notificationText.attributes.tooltiptext
);
}
this.button.setAttribute(
"data-cfr-icon",
CATEGORY_ICONS[recommendation.content.category]
);
// Wait for layout to flush to avoid a synchronous reflow then calculate the
// label width. We can safely get the width even though the recommendation is
// collapsed; the label itself remains full width (with its overflow hidden)
let [{ width }] = await this.window.promiseDocumentFlushed(() =>
this.label.getClientRects()
);
this.urlbar.style.setProperty("--cfr-label-width", `${width}px`);
this.container.addEventListener("click", this._showPopupOnClick);
// Collapse the recommendation on url bar focus in order to free up more
// space to display and edit the url
this.urlbarinput.addEventListener("focus", this._collapse);
if (shouldExpand) {
this._clearScheduledStateChanges();
// After one second, expand
this._expand(DELAY_BEFORE_EXPAND_MS);
this.addImpression(recommendation);
}
if (notificationText.attributes) {
this.window.A11yUtils.announce({
raw: notificationText.attributes["a11y-announcement"],
source: this.container,
});
}
}
hideAddressBarNotifier() {
this.container.hidden = true;
this._clearScheduledStateChanges();
this.urlbar.removeAttribute("cfr-recommendation-state");
this.container.removeEventListener("click", this._showPopupOnClick);
this.urlbar.removeEventListener("focus", this._collapse);
if (this.currentNotification) {
this.window.PopupNotifications.remove(this.currentNotification);
this.currentNotification = null;
}
}
_expand(delay) {
if (delay > 0) {
this.stateTransitionTimeoutIDs.push(
this.window.setTimeout(() => {
this.urlbar.setAttribute("cfr-recommendation-state", "expanded");
}, delay)
);
} else {
// Non-delayed state change overrides any scheduled state changes
this._clearScheduledStateChanges();
this.urlbar.setAttribute("cfr-recommendation-state", "expanded");
}
}
_collapse(delay) {
if (delay > 0) {
this.stateTransitionTimeoutIDs.push(
this.window.setTimeout(() => {
if (
this.urlbar.getAttribute("cfr-recommendation-state") === "expanded"
) {
this.urlbar.setAttribute("cfr-recommendation-state", "collapsed");
}
}, delay)
);
} else {
// Non-delayed state change overrides any scheduled state changes
this._clearScheduledStateChanges();
if (this.urlbar.getAttribute("cfr-recommendation-state") === "expanded") {
this.urlbar.setAttribute("cfr-recommendation-state", "collapsed");
}
}
// TODO: FIXME: find a nicer way of cleaning this up. Maybe listening to "popuphidden"?
// Remove click listener on pause button;
if (this.onAnimationButtonClick) {
this.window.document
.getElementById(ANIMATION_BUTTON_ID)
.removeEventListener("click", this.onAnimationButtonClick);
delete this.onAnimationButtonClick;
}
}
_clearScheduledStateChanges() {
while (this.stateTransitionTimeoutIDs.length) {
// clearTimeout is safe even with invalid/expired IDs
this.window.clearTimeout(this.stateTransitionTimeoutIDs.pop());
}
}
// This is called when the popup closes as a result of interaction _outside_
// the popup, e.g. by hitting
_popupStateChange(state) {
if (state === "shown") {
if (this._autoFocus) {
this.window.document.commandDispatcher.advanceFocusIntoSubtree(
this.currentNotification.owner.panel
);
this._autoFocus = false;
}
} else if (["dismissed", "removed"].includes(state)) {
this._collapse();
if (this.currentNotification) {
this.window.PopupNotifications.remove(this.currentNotification);
this.currentNotification = null;
}
}
}
dispatchUserAction(action) {
this._dispatchToASRouter(
{ type: "USER_ACTION", data: action },
{ browser: this.window.gBrowser.selectedBrowser }
);
}
_dispatchImpression(message) {
this._dispatchToASRouter({ type: "IMPRESSION", data: message });
}
_sendTelemetry(ping) {
this._dispatchToASRouter({
type: "DOORHANGER_TELEMETRY",
data: { action: "cfr_user_event", source: "CFR", ...ping },
});
}
_blockMessage(messageID) {
this._dispatchToASRouter({
type: "BLOCK_MESSAGE_BY_ID",
data: { id: messageID },
});
}
/**
* getStrings - Handles getting the localized strings vs message overrides.
* If string_id is not defined it assumes you passed in an override
* message and it just returns it.
* If subAttribute is provided, the string for it is returned.
* @return A string. One of 1) passed in string 2) a String object with
* attributes property if there are attributes 3) the sub attribute.
*/
async getStrings(string, subAttribute = "") {
if (!string.string_id) {
if (subAttribute) {
if (string.attributes) {
return string.attributes[subAttribute];
}
Cu.reportError(
`String ${string.value} does not contain any attributes`
);
return subAttribute;
}
if (typeof string.value === "string") {
const stringWithAttributes = new String(string.value); // eslint-disable-line no-new-wrappers
stringWithAttributes.attributes = string.attributes;
return stringWithAttributes;
}
return string;
}
const [localeStrings] = await RemoteL10n.l10n.formatMessages([
{
id: string.string_id,
args: string.args,
},
]);
const mainString = new String(localeStrings.value); // eslint-disable-line no-new-wrappers
if (localeStrings.attributes) {
const attributes = localeStrings.attributes.reduce((acc, attribute) => {
acc[attribute.name] = attribute.value;
return acc;
}, {});
mainString.attributes = attributes;
}
return subAttribute ? mainString.attributes[subAttribute] : mainString;
}
async _setAddonAuthorAndRating(document, content) {
const author = this.window.document.getElementById(
"cfr-notification-author"
);
const footerFilledStars = this.window.document.getElementById(
"cfr-notification-footer-filled-stars"
);
const footerEmptyStars = this.window.document.getElementById(
"cfr-notification-footer-empty-stars"
);
const footerUsers = this.window.document.getElementById(
"cfr-notification-footer-users"
);
const footerSpacer = this.window.document.getElementById(
"cfr-notification-footer-spacer"
);
author.textContent = await this.getStrings({
string_id: "cfr-doorhanger-extension-author",
args: { name: content.addon.author },
});
const { rating } = content.addon;
if (rating) {
const MAX_RATING = 5;
const STARS_WIDTH = 17 * MAX_RATING;
const calcWidth = stars => `${(stars / MAX_RATING) * STARS_WIDTH}px`;
footerFilledStars.style.width = calcWidth(rating);
footerEmptyStars.style.width = calcWidth(MAX_RATING - rating);
const ratingString = await this.getStrings(
{
string_id: "cfr-doorhanger-extension-rating",
args: { total: rating },
},
"tooltiptext"
);
footerFilledStars.setAttribute("tooltiptext", ratingString);
footerEmptyStars.setAttribute("tooltiptext", ratingString);
} else {
footerFilledStars.style.width = "";
footerEmptyStars.style.width = "";
footerFilledStars.removeAttribute("tooltiptext");
footerEmptyStars.removeAttribute("tooltiptext");
}
const { users } = content.addon;
if (users) {
footerUsers.setAttribute(
"value",
await this.getStrings({
string_id: "cfr-doorhanger-extension-total-users",
args: { total: users },
})
);
footerUsers.removeAttribute("hidden");
} else {
// Prevent whitespace around empty label from affecting other spacing
footerUsers.setAttribute("hidden", true);
footerUsers.removeAttribute("value");
}
// Spacer pushes the link to the opposite end when there's other content
if (rating || users) {
footerSpacer.removeAttribute("hidden");
} else {
footerSpacer.setAttribute("hidden", true);
}
}
_createElementAndAppend({ type, id }, parent) {
let element = this.window.document.createXULElement(type);
if (id) {
element.setAttribute("id", id);
}
parent.appendChild(element);
return element;
}
async _renderPinTabAnimation() {
const ANIMATION_CONTAINER_ID =
"cfr-notification-footer-pintab-animation-container";
const footer = this.window.document.getElementById(
"cfr-notification-footer"
);
let animationContainer = this.window.document.getElementById(
ANIMATION_CONTAINER_ID
);
if (!animationContainer) {
animationContainer = this._createElementAndAppend(
{ type: "vbox", id: ANIMATION_CONTAINER_ID },
footer
);
let controlsContainer = this._createElementAndAppend(
{ type: "hbox", id: "cfr-notification-footer-animation-controls" },
animationContainer
);
// spacer
this._createElementAndAppend(
{ type: "vbox" },
controlsContainer
).setAttribute("flex", 1);
let animationButton = this._createElementAndAppend(
{ type: "hbox", id: ANIMATION_BUTTON_ID },
controlsContainer
);
// animation button label
this._createElementAndAppend(
{ type: "label", id: ANIMATION_LABEL_ID },
animationButton
);
}
animationContainer.toggleAttribute(
"animate",
Services.prefs.getBoolPref(ANIMATIONS_ENABLED_PREF, true)
);
animationContainer.removeAttribute("paused");
this.window.document.getElementById(
ANIMATION_LABEL_ID
).textContent = await this.getStrings({
string_id: "cfr-doorhanger-pintab-animation-pause",
});
if (!this.onAnimationButtonClick) {
let animationButton = this.window.document.getElementById(
ANIMATION_BUTTON_ID
);
this.onAnimationButtonClick = async () => {
let animationLabel = this.window.document.getElementById(
ANIMATION_LABEL_ID
);
if (animationContainer.toggleAttribute("paused")) {
animationLabel.textContent = await this.getStrings({
string_id: "cfr-doorhanger-pintab-animation-resume",
});
} else {
animationLabel.textContent = await this.getStrings({
string_id: "cfr-doorhanger-pintab-animation-pause",
});
}
};
animationButton.addEventListener("click", this.onAnimationButtonClick);
}
}
async _renderMilestonePopup(message, browser) {
let { content } = message;
let { primary } = content.buttons;
let dateFormat = new Services.intl.DateTimeFormat(
this.window.gBrowser.ownerGlobal.navigator.language,
{
month: "long",
year: "numeric",
}
).format;
let earliestDate = await TrackingDBService.getEarliestRecordedDate();
let monthName = dateFormat(new Date(earliestDate));
let panelTitle = "";
let headerLabel = this.window.document.getElementById(
"cfr-notification-header-label"
);
let reachedMilestone = null;
let totalSaved = await TrackingDBService.sumAllEvents();
for (let milestone of milestones) {
if (totalSaved >= milestone) {
reachedMilestone = milestone;
}
}
if (typeof message.content.heading_text === "string") {
// This is a test environment.
panelTitle = message.content.heading_text;
headerLabel.value = panelTitle;
} else {
RemoteL10n.l10n.setAttributes(
headerLabel,
content.heading_text.string_id,
{
blockedCount: reachedMilestone,
date: monthName,
}
);
await RemoteL10n.l10n.translateElements([headerLabel]);
}
// Use the message layout as a CSS selector to hide different parts of the
// notification template markup
this.window.document
.getElementById("contextual-feature-recommendation-notification")
.setAttribute("data-notification-category", content.layout);
this.window.document
.getElementById("contextual-feature-recommendation-notification")
.setAttribute("data-notification-bucket", content.bucket_id);
let notification = this.window.document.getElementById(
"notification-popup"
);
let primaryBtnString = await this.getStrings(primary.label);
let primaryActionCallback = () => {
this.dispatchUserAction(primary.action);
RecommendationMap.delete(browser);
// Invalidate the pref after the user interacts with the button.
// We don't need to show the illustration in the privacy panel.
Services.prefs.clearUserPref(
"browser.contentblocking.cfr-milestone.milestone-shown-time"
);
};
let mainAction = {
label: primaryBtnString,
accessKey: primaryBtnString.attributes.accesskey,
callback: primaryActionCallback,
};
let style = this.window.document.createElement("style");
style.textContent = `
.cfr-notification-milestone .panel-arrow {
fill: #0250BB !important;
}
`;
let arrow;
let manageClass = event => {
if (event === "dismissed" || event === "removed") {
notification.shadowRoot.removeChild(style);
arrow.classList.remove("cfr-notification-milestone");
} else if (event === "showing") {
notification.shadowRoot.appendChild(style);
arrow = notification.shadowRoot.querySelector(".panel-arrowcontainer");
arrow.classList.add("cfr-notification-milestone");
}
};
// Actually show the notification
this.currentNotification = this.window.PopupNotifications.show(
browser,
POPUP_NOTIFICATION_ID,
panelTitle,
"cfr",
mainAction,
null,
{
hideClose: true,
eventCallback: manageClass,
}
);
Services.prefs.setIntPref(
"browser.contentblocking.cfr-milestone.milestone-achieved",
reachedMilestone
);
Services.prefs.setStringPref(
"browser.contentblocking.cfr-milestone.milestone-shown-time",
Date.now().toString()
);
}
// eslint-disable-next-line max-statements
async _renderPopup(message, browser) {
const { id, content, modelVersion } = message;
const headerLabel = this.window.document.getElementById(
"cfr-notification-header-label"
);
const headerLink = this.window.document.getElementById(
"cfr-notification-header-link"
);
const headerImage = this.window.document.getElementById(
"cfr-notification-header-image"
);
const footerText = this.window.document.getElementById(
"cfr-notification-footer-text"
);
const footerLink = this.window.document.getElementById(
"cfr-notification-footer-learn-more-link"
);
const { primary, secondary } = content.buttons;
let primaryActionCallback;
let options = {};
let panelTitle;
headerLabel.value = await this.getStrings(content.heading_text);
headerLink.setAttribute(
"href",
SUMO_BASE_URL + content.info_icon.sumo_path
);
headerImage.setAttribute(
"tooltiptext",
await this.getStrings(content.info_icon.label, "tooltiptext")
);
headerLink.onclick = () =>
this._sendTelemetry({
message_id: id,
bucket_id: content.bucket_id,
event: "RATIONALE",
...(modelVersion ? { event_context: { modelVersion } } : {}),
});
// Use the message layout as a CSS selector to hide different parts of the
// notification template markup
this.window.document
.getElementById("contextual-feature-recommendation-notification")
.setAttribute("data-notification-category", content.layout);
this.window.document
.getElementById("contextual-feature-recommendation-notification")
.setAttribute("data-notification-bucket", content.bucket_id);
switch (content.layout) {
case "icon_and_message":
const author = this.window.document.getElementById(
"cfr-notification-author"
);
author.textContent = await this.getStrings(content.text);
primaryActionCallback = () => {
this._blockMessage(id);
this.dispatchUserAction(primary.action);
this.hideAddressBarNotifier();
this._sendTelemetry({
message_id: id,
bucket_id: content.bucket_id,
event: "ENABLE",
...(modelVersion ? { event_context: { modelVersion } } : {}),
});
RecommendationMap.delete(browser);
};
let getIcon = () => {
if (content.icon_dark_theme && this.isDarkTheme) {
return content.icon_dark_theme;
}
return content.icon;
};
let learnMoreURL = content.learn_more
? SUMO_BASE_URL + content.learn_more
: null;
panelTitle = await this.getStrings(content.heading_text);
options = {
popupIconURL: getIcon(),
popupIconClass: content.icon_class,
learnMoreURL,
};
break;
case "message_and_animation":
footerText.textContent = await this.getStrings(content.text);
const stepsContainerId = "cfr-notification-feature-steps";
let stepsContainer = this.window.document.getElementById(
stepsContainerId
);
primaryActionCallback = () => {
this._blockMessage(id);
this.dispatchUserAction(primary.action);
this.hideAddressBarNotifier();
this._sendTelemetry({
message_id: id,
bucket_id: content.bucket_id,
event: "PIN",
...(modelVersion ? { event_context: { modelVersion } } : {}),
});
RecommendationMap.delete(browser);
};
panelTitle = await this.getStrings(content.heading_text);
if (content.descriptionDetails) {
if (stepsContainer) {
// If it exists we need to empty it
stepsContainer.remove();
stepsContainer = stepsContainer.cloneNode(false);
} else {
stepsContainer = this.window.document.createXULElement("vbox");
stepsContainer.setAttribute("id", stepsContainerId);
}
footerText.parentNode.appendChild(stepsContainer);
for (let step of content.descriptionDetails.steps) {
// This li is a generic xul element with custom styling
const li = this.window.document.createXULElement("li");
RemoteL10n.l10n.setAttributes(li, step.string_id);
stepsContainer.appendChild(li);
}
await RemoteL10n.l10n.translateElements([...stepsContainer.children]);
}
await this._renderPinTabAnimation();
break;
default:
panelTitle = await this.getStrings(content.addon.title);
await this._setAddonAuthorAndRating(this.window.document, content);
// Main body content of the dropdown
footerText.textContent = await this.getStrings(content.text);
options = { popupIconURL: content.addon.icon };
footerLink.value = await this.getStrings({
string_id: "cfr-doorhanger-extension-learn-more-link",
});
footerLink.setAttribute("href", content.addon.amo_url);
footerLink.onclick = () =>
this._sendTelemetry({
message_id: id,
bucket_id: content.bucket_id,
event: "LEARN_MORE",
...(modelVersion ? { event_context: { modelVersion } } : {}),
});
primaryActionCallback = async () => {
// eslint-disable-next-line no-use-before-define
primary.action.data.url = await CFRPageActions._fetchLatestAddonVersion(
content.addon.id
);
this._blockMessage(id);
this.dispatchUserAction(primary.action);
this.hideAddressBarNotifier();
this._sendTelemetry({
message_id: id,
bucket_id: content.bucket_id,
event: "INSTALL",
...(modelVersion ? { event_context: { modelVersion } } : {}),
});
RecommendationMap.delete(browser);
};
}
const primaryBtnStrings = await this.getStrings(primary.label);
const mainAction = {
label: primaryBtnStrings,
accessKey: primaryBtnStrings.attributes.accesskey,
callback: primaryActionCallback,
};
let _renderSecondaryButtonAction = async (event, button) => {
let label = await this.getStrings(button.label);
let { attributes } = label;
return {
label,
accessKey: attributes.accesskey,
callback: () => {
if (button.action) {
this.dispatchUserAction(button.action);
} else {
this._blockMessage(id);
this.hideAddressBarNotifier();
RecommendationMap.delete(browser);
}
this._sendTelemetry({
message_id: id,
bucket_id: content.bucket_id,
event,
...(modelVersion ? { event_context: { modelVersion } } : {}),
});
},
};
};
// For each secondary action, define default telemetry event
const defaultSecondaryEvent = ["DISMISS", "BLOCK", "MANAGE"];
const secondaryActions = await Promise.all(
secondary.map((button, i) => {
return _renderSecondaryButtonAction(
button.event || defaultSecondaryEvent[i],
button
);
})
);
// If the recommendation button is focused, it was probably activated via
// the keyboard. Therefore, focus the first element in the notification when
// it appears.
// We don't use the autofocus option provided by PopupNotifications.show
// because it doesn't focus the first element; i.e. the user still has to
// press tab once. That's not good enough, especially for screen reader
// users. Instead, we handle this ourselves in _popupStateChange.
this._autoFocus = this.window.document.activeElement === this.container;
// Actually show the notification
this.currentNotification = this.window.PopupNotifications.show(
browser,
POPUP_NOTIFICATION_ID,
panelTitle,
"cfr",
mainAction,
secondaryActions,
{
...options,
hideClose: true,
eventCallback: this._popupStateChange,
}
);
}
/**
* Respond to a user click on the recommendation by showing a doorhanger/
* popup notification
*/
async _showPopupOnClick(event) {
const browser = this.window.gBrowser.selectedBrowser;
if (!RecommendationMap.has(browser)) {
// There's no recommendation for this browser, so the user shouldn't have
// been able to click
this.hideAddressBarNotifier();
return;
}
const message = RecommendationMap.get(browser);
// The recommendation should remain either collapsed or expanded while the
// doorhanger is showing
this._clearScheduledStateChanges(browser, message);
await this.showPopup();
}
async showPopup() {
const browser = this.window.gBrowser.selectedBrowser;
const message = RecommendationMap.get(browser);
const { id, content, modelVersion } = message;
// A hacky way of setting the popup anchor outside the usual url bar icon box
// See https://searchfox.org/mozilla-central/rev/847b64cc28b74b44c379f9bff4f415b97da1c6d7/toolkit/modules/PopupNotifications.jsm#42
browser.cfrpopupnotificationanchor =
this.window.document.getElementById(content.anchor_id) || this.container;
this._sendTelemetry({
message_id: id,
bucket_id: content.bucket_id,
event: "CLICK_DOORHANGER",
...(modelVersion ? { event_context: { modelVersion } } : {}),
});
await this._renderPopup(message, browser);
}
async showMilestonePopup() {
const browser = this.window.gBrowser.selectedBrowser;
const message = RecommendationMap.get(browser);
const { content } = message;
// A hacky way of setting the popup anchor outside the usual url bar icon box
// See https://searchfox.org/mozilla-central/rev/847b64cc28b74b44c379f9bff4f415b97da1c6d7/toolkit/modules/PopupNotifications.jsm#42
browser.cfrpopupnotificationanchor =
this.window.document.getElementById(content.anchor_id) || this.container;
await this._renderMilestonePopup(message, browser);
return true;
}
}
function isHostMatch(browser, host) {
return (
browser.documentURI.scheme.startsWith("http") &&
browser.documentURI.host === host
);
}
const CFRPageActions = {
// For testing purposes
RecommendationMap,
PageActionMap,
/**
* To be called from browser.js on a location change, passing in the browser
* that's been updated
*/
updatePageActions(browser) {
const win = browser.ownerGlobal;
const pageAction = PageActionMap.get(win);
if (!pageAction || browser !== win.gBrowser.selectedBrowser) {
return;
}
if (RecommendationMap.has(browser)) {
const recommendation = RecommendationMap.get(browser);
if (
!recommendation.content.skip_address_bar_notifier &&
(isHostMatch(browser, recommendation.host) ||
// If there is no host associated we assume we're back on a tab
// that had a CFR message so we should show it again
!recommendation.host)
) {
// The browser has a recommendation specified with this host, so show
// the page action
pageAction.showAddressBarNotifier(recommendation);
} else if (recommendation.retain) {
// Keep the recommendation first time the user navigates away just in
// case they will go back to the previous page
pageAction.hideAddressBarNotifier();
recommendation.retain = false;
} else {
// The user has navigated away from the specified host in the given
// browser, so the recommendation is no longer valid and should be removed
RecommendationMap.delete(browser);
pageAction.hideAddressBarNotifier();
}
} else {
// There's no recommendation specified for this browser, so hide the page action
pageAction.hideAddressBarNotifier();
}
},
/**
* Fetch the URL to the latest add-on xpi so the recommendation can download it.
* @param id The add-on ID
* @return A string for the URL that was fetched
*/
async _fetchLatestAddonVersion(id) {
let url = null;
try {
const response = await fetch(`${ADDONS_API_URL}/${id}/`, {
credentials: "omit",
});
if (response.status !== 204 && response.ok) {
const json = await response.json();
url = json.current_version.files[0].url;
}
} catch (e) {
Cu.reportError(
"Failed to get the latest add-on version for this recommendation"
);
}
return url;
},
/**
* Show Milestone notification.
* @param browser The browser for the recommendation
* @param recommendation The recommendation to show
* @param dispatchToASRouter A function to dispatch resulting actions to
* @return Did adding the recommendation succeed?
*/
async showMilestone(browser, message, dispatchToASRouter, options = {}) {
let win = null;
const { id, content, personalizedModelVersion } = message;
// If we are forcing via the Admin page, the browser comes in a different format
if (options.force) {
win = browser.browser.ownerGlobal;
RecommendationMap.set(browser.browser, {
id,
content,
retain: true,
modelVersion: personalizedModelVersion,
});
} else {
win = browser.ownerGlobal;
RecommendationMap.set(browser, {
id,
content,
retain: true,
modelVersion: personalizedModelVersion,
});
}
if (!PageActionMap.has(win)) {
PageActionMap.set(win, new PageAction(win, dispatchToASRouter));
}
await PageActionMap.get(win).showMilestonePopup();
PageActionMap.get(win).addImpression(message);
return true;
},
/**
* Force a recommendation to be shown. Should only happen via the Admin page.
* @param browser The browser for the recommendation
* @param recommendation The recommendation to show
* @param dispatchToASRouter A function to dispatch resulting actions to
* @return Did adding the recommendation succeed?
*/
async forceRecommendation(browser, recommendation, dispatchToASRouter) {
// If we are forcing via the Admin page, the browser comes in a different format
const win = browser.browser.ownerGlobal;
const { id, content, personalizedModelVersion } = recommendation;
RecommendationMap.set(browser.browser, {
id,
content,
retain: true,
modelVersion: personalizedModelVersion,
});
if (!PageActionMap.has(win)) {
PageActionMap.set(win, new PageAction(win, dispatchToASRouter));
}
if (content.skip_address_bar_notifier) {
await PageActionMap.get(win).showPopup();
PageActionMap.get(win).addImpression(recommendation);
} else {
await PageActionMap.get(win).showAddressBarNotifier(recommendation, true);
}
return true;
},
/**
* Add a recommendation specific to the given browser and host.
* @param browser The browser for the recommendation
* @param host The host for the recommendation
* @param recommendation The recommendation to show
* @param dispatchToASRouter A function to dispatch resulting actions to
* @return Did adding the recommendation succeed?
*/
async addRecommendation(browser, host, recommendation, dispatchToASRouter) {
const win = browser.ownerGlobal;
if (PrivateBrowsingUtils.isWindowPrivate(win)) {
return false;
}
if (
browser !== win.gBrowser.selectedBrowser ||
// We can have recommendations without URL restrictions
(host && !isHostMatch(browser, host))
) {
return false;
}
if (RecommendationMap.has(browser)) {
// Don't replace an existing message
return false;
}
const { id, content, personalizedModelVersion } = recommendation;
RecommendationMap.set(browser, {
id,
host,
content,
retain: true,
modelVersion: personalizedModelVersion,
});
if (!PageActionMap.has(win)) {
PageActionMap.set(win, new PageAction(win, dispatchToASRouter));
}
if (content.skip_address_bar_notifier) {
await PageActionMap.get(win).showPopup();
PageActionMap.get(win).addImpression(recommendation);
} else {
await PageActionMap.get(win).showAddressBarNotifier(recommendation, true);
}
return true;
},
/**
* Clear all recommendations and hide all PageActions
*/
clearRecommendations() {
// WeakMaps aren't iterable so we have to test all existing windows
for (const win of Services.wm.getEnumerator("navigator:browser")) {
if (win.closed || !PageActionMap.has(win)) {
continue;
}
PageActionMap.get(win).hideAddressBarNotifier();
}
// WeakMaps don't have a `clear` method
PageActionMap = new WeakMap();
RecommendationMap = new WeakMap();
this.PageActionMap = PageActionMap;
this.RecommendationMap = RecommendationMap;
},
/**
* Reload the l10n Fluent files for all PageActions
*/
reloadL10n() {
for (const win of Services.wm.getEnumerator("navigator:browser")) {
if (win.closed || !PageActionMap.has(win)) {
continue;
}
PageActionMap.get(win).reloadL10n();
}
},
};
this.PageAction = PageAction;
this.CFRPageActions = CFRPageActions;
const EXPORTED_SYMBOLS = ["CFRPageActions", "PageAction"];
================================================
FILE: lib/DiscoveryStreamFeed.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 { XPCOMUtils } = ChromeUtils.import(
"resource://gre/modules/XPCOMUtils.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"NewTabUtils",
"resource://gre/modules/NewTabUtils.jsm"
);
const { setTimeout, clearTimeout } = ChromeUtils.import(
"resource://gre/modules/Timer.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"Services",
"resource://gre/modules/Services.jsm"
);
XPCOMUtils.defineLazyGlobalGetters(this, ["fetch"]);
ChromeUtils.defineModuleGetter(
this,
"perfService",
"resource://activity-stream/common/PerfService.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"UserDomainAffinityProvider",
"resource://activity-stream/lib/UserDomainAffinityProvider.jsm"
);
const { actionTypes: at, actionCreators: ac } = ChromeUtils.import(
"resource://activity-stream/common/Actions.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"PersistentCache",
"resource://activity-stream/lib/PersistentCache.jsm"
);
XPCOMUtils.defineLazyServiceGetters(this, {
gUUIDGenerator: ["@mozilla.org/uuid-generator;1", "nsIUUIDGenerator"],
});
const CACHE_KEY = "discovery_stream";
const LAYOUT_UPDATE_TIME = 30 * 60 * 1000; // 30 minutes
const STARTUP_CACHE_EXPIRE_TIME = 7 * 24 * 60 * 60 * 1000; // 1 week
const COMPONENT_FEEDS_UPDATE_TIME = 30 * 60 * 1000; // 30 minutes
const SPOCS_FEEDS_UPDATE_TIME = 30 * 60 * 1000; // 30 minutes
const DEFAULT_RECS_EXPIRE_TIME = 60 * 60 * 1000; // 1 hour
const MIN_DOMAIN_AFFINITIES_UPDATE_TIME = 12 * 60 * 60 * 1000; // 12 hours
const MAX_LIFETIME_CAP = 500; // Guard against misconfiguration on the server
const DEFAULT_MAX_HISTORY_QUERY_RESULTS = 1000;
const FETCH_TIMEOUT = 45 * 1000;
const PREF_CONFIG = "discoverystream.config";
const PREF_ENDPOINTS = "discoverystream.endpoints";
const PREF_IMPRESSION_ID = "browser.newtabpage.activity-stream.impressionId";
const PREF_ENABLED = "discoverystream.enabled";
const PREF_HARDCODED_BASIC_LAYOUT = "discoverystream.hardcoded-basic-layout";
const PREF_SPOCS_ENDPOINT = "discoverystream.spocs-endpoint";
const PREF_LANG_LAYOUT_CONFIG = "discoverystream.lang-layout-config";
const PREF_TOPSTORIES = "feeds.section.topstories";
const PREF_SPOCS_CLEAR_ENDPOINT = "discoverystream.endpointSpocsClear";
const PREF_SHOW_SPONSORED = "showSponsored";
const PREF_SPOC_IMPRESSIONS = "discoverystream.spoc.impressions";
const PREF_FLIGHT_BLOCKS = "discoverystream.flight.blocks";
const PREF_REC_IMPRESSIONS = "discoverystream.rec.impressions";
let getHardcodedLayout;
this.DiscoveryStreamFeed = class DiscoveryStreamFeed {
constructor() {
// Internal state for checking if we've intialized all our data
this.loaded = false;
// Persistent cache for remote endpoint data.
this.cache = new PersistentCache(CACHE_KEY, true);
this.locale = Services.locale.appLocaleAsLangTag;
this._impressionId = this.getOrCreateImpressionId();
// Internal in-memory cache for parsing json prefs.
this._prefCache = {};
}
getOrCreateImpressionId() {
let impressionId = Services.prefs.getCharPref(PREF_IMPRESSION_ID, "");
if (!impressionId) {
impressionId = String(gUUIDGenerator.generateUUID());
Services.prefs.setCharPref(PREF_IMPRESSION_ID, impressionId);
}
return impressionId;
}
/**
* Send SPOCS Fill telemetry.
* @param {object} filteredItems An object keyed on filter reasons, and the value
* is a list of SPOCS.
* reasons: blocked_by_user, frequency_cap, below_min_score, flight_duplicate
* @param {boolean} fullRecalc A boolean indicating if it's a full recalculation.
* Calling `loadSpocs` will be treated as a full recalculation.
* Whereas responding the action "DISCOVERY_STREAM_SPOC_IMPRESSION"
* is not a full recalculation.
*/
_sendSpocsFill(filteredItems, fullRecalc) {
const full_recalc = fullRecalc ? 1 : 0;
const spocsFill = [];
for (const [reason, items] of Object.entries(filteredItems)) {
items.forEach(item => {
// Only send SPOCS (i.e. it has a flight_id)
if (item.flight_id) {
spocsFill.push({ reason, full_recalc, id: item.id, displayed: 0 });
}
});
}
if (spocsFill.length) {
this.store.dispatch(
ac.DiscoveryStreamSpocsFill({ spoc_fills: spocsFill })
);
}
}
finalLayoutEndpoint(url, apiKey) {
if (url.includes("$apiKey") && !apiKey) {
throw new Error(
`Layout Endpoint - An API key was specified but none configured: ${url}`
);
}
return url.replace("$apiKey", apiKey);
}
get config() {
if (this._prefCache.config) {
return this._prefCache.config;
}
try {
this._prefCache.config = JSON.parse(
this.store.getState().Prefs.values[PREF_CONFIG]
);
const layoutUrl = this._prefCache.config.layout_endpoint;
const apiKeyPref = this._prefCache.config.api_key_pref;
if (layoutUrl && apiKeyPref) {
const apiKey = Services.prefs.getCharPref(apiKeyPref, "");
this._prefCache.config.layout_endpoint = this.finalLayoutEndpoint(
layoutUrl,
apiKey
);
}
} catch (e) {
// istanbul ignore next
this._prefCache.config = {};
// istanbul ignore next
Cu.reportError(
`Could not parse preference. Try resetting ${PREF_CONFIG} in about:config. ${e}`
);
}
this._prefCache.config.enabled =
this._prefCache.config.enabled &&
this.store.getState().Prefs.values[PREF_ENABLED];
return this._prefCache.config;
}
resetConfigDefauts() {
this.store.dispatch({
type: at.CLEAR_PREF,
data: {
name: PREF_CONFIG,
},
});
}
get showSpocs() {
// Combine user-set sponsored opt-out with Mozilla-set config
return (
this.store.getState().Prefs.values[PREF_SHOW_SPONSORED] &&
this.config.show_spocs
);
}
get personalized() {
return this.config.personalized;
}
setupPrefs() {
// Send the initial state of the pref on our reducer
this.store.dispatch(
ac.BroadcastToContent({
type: at.DISCOVERY_STREAM_CONFIG_SETUP,
data: this.config,
})
);
}
uninitPrefs() {
// Reset in-memory cache
this._prefCache = {};
}
async fetchFromEndpoint(rawEndpoint, options = {}) {
if (!rawEndpoint) {
Cu.reportError("Tried to fetch endpoint but none was configured.");
return null;
}
const apiKeyPref = this._prefCache.config.api_key_pref;
const apiKey = Services.prefs.getCharPref(apiKeyPref, "");
// The server somtimes returns this value already replaced, but we try this for two reasons:
// 1. Layout endpoints are not from the server.
// 2. Hardcoded layouts don't have this already done for us.
const endpoint = rawEndpoint
.replace("$apiKey", apiKey)
.replace("$locale", this.locale);
try {
// Make sure the requested endpoint is allowed
const allowed = this.store
.getState()
.Prefs.values[PREF_ENDPOINTS].split(",");
if (!allowed.some(prefix => endpoint.startsWith(prefix))) {
throw new Error(`Not one of allowed prefixes (${allowed})`);
}
const controller = new AbortController();
const { signal } = controller;
const fetchPromise = fetch(endpoint, {
...options,
credentials: "omit",
signal,
});
// istanbul ignore next
const timeoutId = setTimeout(() => {
controller.abort();
}, FETCH_TIMEOUT);
const response = await fetchPromise;
if (!response.ok) {
throw new Error(`Unexpected status (${response.status})`);
}
clearTimeout(timeoutId);
return response.json();
} catch (error) {
Cu.reportError(`Failed to fetch ${endpoint}: ${error.message}`);
}
return null;
}
/**
* Returns true if data in the cache for a particular key has expired or is missing.
* @param {object} cachedData data returned from cache.get()
* @param {string} key a cache key
* @param {string?} url for "feed" only, the URL of the feed.
* @param {boolean} is this check done at initial browser load
*/
isExpired({ cachedData, key, url, isStartup }) {
const { layout, spocs, feeds } = cachedData;
const updateTimePerComponent = {
layout: LAYOUT_UPDATE_TIME,
spocs: SPOCS_FEEDS_UPDATE_TIME,
feed: COMPONENT_FEEDS_UPDATE_TIME,
};
const EXPIRATION_TIME = isStartup
? STARTUP_CACHE_EXPIRE_TIME
: updateTimePerComponent[key];
switch (key) {
case "layout":
// This never needs to expire, as it's not expected to change.
if (this.config.hardcoded_layout) {
return false;
}
return !layout || !(Date.now() - layout.lastUpdated < EXPIRATION_TIME);
case "spocs":
return !spocs || !(Date.now() - spocs.lastUpdated < EXPIRATION_TIME);
case "feed":
return (
!feeds ||
!feeds[url] ||
!(Date.now() - feeds[url].lastUpdated < EXPIRATION_TIME)
);
default:
// istanbul ignore next
throw new Error(`${key} is not a valid key`);
}
}
async _checkExpirationPerComponent() {
const cachedData = (await this.cache.get()) || {};
const { feeds } = cachedData;
return {
layout: this.isExpired({ cachedData, key: "layout" }),
spocs: this.isExpired({ cachedData, key: "spocs" }),
feeds:
!feeds ||
Object.keys(feeds).some(url =>
this.isExpired({ cachedData, key: "feed", url })
),
};
}
/**
* Returns true if any data for the cached endpoints has expired or is missing.
*/
async checkIfAnyCacheExpired() {
const expirationPerComponent = await this._checkExpirationPerComponent();
return (
expirationPerComponent.layout ||
expirationPerComponent.spocs ||
expirationPerComponent.feeds
);
}
async fetchLayout(isStartup) {
const cachedData = (await this.cache.get()) || {};
let { layout } = cachedData;
if (this.isExpired({ cachedData, key: "layout", isStartup })) {
const start = perfService.absNow();
const layoutResponse = await this.fetchFromEndpoint(
this.config.layout_endpoint
);
if (layoutResponse && layoutResponse.layout) {
this.layoutRequestTime = Math.round(perfService.absNow() - start);
layout = {
lastUpdated: Date.now(),
spocs: layoutResponse.spocs,
layout: layoutResponse.layout,
status: "success",
};
await this.cache.set("layout", layout);
} else {
Cu.reportError("No response for response.layout prop");
}
}
return layout;
}
updatePlacements(sendUpdate, layout) {
const placements = [];
const placementsMap = {};
for (const row of layout.filter(r => r.components && r.components.length)) {
for (const component of row.components) {
if (component.placement) {
// Throw away any dupes for the request.
if (!placementsMap[component.placement.name]) {
placementsMap[component.placement.name] = component.placement;
placements.push(component.placement);
}
}
}
}
if (placements.length) {
sendUpdate({
type: at.DISCOVERY_STREAM_SPOCS_PLACEMENTS,
data: { placements },
});
}
}
async loadLayout(sendUpdate, isStartup) {
let layoutResp = {};
let url = "";
if (!this.config.hardcoded_layout) {
layoutResp = await this.fetchLayout(isStartup);
}
if (!layoutResp || !layoutResp.layout) {
const langLayoutConfig =
this.store.getState().Prefs.values[PREF_LANG_LAYOUT_CONFIG] || "";
const isBasic =
this.config.hardcoded_basic_layout ||
this.store.getState().Prefs.values[PREF_HARDCODED_BASIC_LAYOUT] ||
!langLayoutConfig
.split(",")
.find(lang => this.locale.startsWith(lang.trim()));
// Set a hardcoded layout if one is needed.
// Changing values in this layout in memory object is unnecessary.
layoutResp = getHardcodedLayout(isBasic);
}
sendUpdate({
type: at.DISCOVERY_STREAM_LAYOUT_UPDATE,
data: layoutResp,
});
if (layoutResp.spocs) {
url =
this.store.getState().Prefs.values[PREF_SPOCS_ENDPOINT] ||
this.config.spocs_endpoint ||
layoutResp.spocs.url;
if (
url &&
url !== this.store.getState().DiscoveryStream.spocs.spocs_endpoint
) {
sendUpdate({
type: at.DISCOVERY_STREAM_SPOCS_ENDPOINT,
data: {
url,
spocs_per_domain: layoutResp.spocs.spocs_per_domain,
},
});
this.updatePlacements(sendUpdate, layoutResp.layout);
}
}
}
/**
* buildFeedPromise - Adds the promise result to newFeeds and
* pushes a promise to newsFeedsPromises.
* @param {Object} Has both newFeedsPromises (Array) and newFeeds (Object)
* @param {Boolean} isStartup We have different cache handling for startup.
* @returns {Function} We return a function so we can contain
* the scope for isStartup and the promises object.
* Combines feed results and promises for each component with a feed.
*/
buildFeedPromise({ newFeedsPromises, newFeeds }, isStartup, sendUpdate) {
return component => {
const { url } = component.feed;
if (!newFeeds[url]) {
// We initially stub this out so we don't fetch dupes,
// we then fill in with the proper object inside the promise.
newFeeds[url] = {};
const feedPromise = this.getComponentFeed(url, isStartup);
feedPromise
.then(feed => {
newFeeds[url] = this.filterRecommendations(feed);
sendUpdate({
type: at.DISCOVERY_STREAM_FEED_UPDATE,
data: {
feed: newFeeds[url],
url,
},
});
// We grab affinities off the first feed for the moment.
// Ideally this would be returned from the server on the layout,
// or from another endpoint.
if (!this.affinities) {
const { settings } = feed.data;
this.affinities = {
timeSegments: settings.timeSegments,
parameterSets: settings.domainAffinityParameterSets,
maxHistoryQueryResults:
settings.maxHistoryQueryResults ||
DEFAULT_MAX_HISTORY_QUERY_RESULTS,
version: settings.version,
};
}
})
.catch(
/* istanbul ignore next */ error => {
Cu.reportError(
`Error trying to load component feed ${url}: ${error}`
);
}
);
newFeedsPromises.push(feedPromise);
}
};
}
filterRecommendations(feed) {
if (
feed &&
feed.data &&
feed.data.recommendations &&
feed.data.recommendations.length
) {
const { data: recommendations } = this.filterBlocked(
feed.data.recommendations
);
return {
...feed,
data: {
...feed.data,
recommendations,
},
};
}
return feed;
}
/**
* reduceFeedComponents - Filters out components with no feeds, and combines
* all feeds on this component with the feeds from other components.
* @param {Boolean} isStartup We have different cache handling for startup.
* @returns {Function} We return a function so we can contain the scope for isStartup.
* Reduces feeds into promises and feed data.
*/
reduceFeedComponents(isStartup, sendUpdate) {
return (accumulator, row) => {
row.components
.filter(component => component && component.feed)
.forEach(this.buildFeedPromise(accumulator, isStartup, sendUpdate));
return accumulator;
};
}
/**
* buildFeedPromises - Filters out rows with no components,
* and gets us a promise for each unique feed.
* @param {Object} layout This is the Discovery Stream layout object.
* @param {Boolean} isStartup We have different cache handling for startup.
* @returns {Object} An object with newFeedsPromises (Array) and newFeeds (Object),
* we can Promise.all newFeedsPromises to get completed data in newFeeds.
*/
buildFeedPromises(layout, isStartup, sendUpdate) {
const initialData = {
newFeedsPromises: [],
newFeeds: {},
};
return layout
.filter(row => row && row.components)
.reduce(this.reduceFeedComponents(isStartup, sendUpdate), initialData);
}
async loadComponentFeeds(sendUpdate, isStartup) {
const { DiscoveryStream } = this.store.getState();
if (!DiscoveryStream || !DiscoveryStream.layout) {
return;
}
// Reset the flag that indicates whether or not at least one API request
// was issued to fetch the component feed in `getComponentFeed()`.
this.componentFeedFetched = false;
const start = perfService.absNow();
const { newFeedsPromises, newFeeds } = this.buildFeedPromises(
DiscoveryStream.layout,
isStartup,
sendUpdate
);
// Each promise has a catch already built in, so no need to catch here.
await Promise.all(newFeedsPromises);
if (this.componentFeedFetched) {
this.cleanUpTopRecImpressionPref(newFeeds);
this.componentFeedRequestTime = Math.round(perfService.absNow() - start);
}
await this.cache.set("feeds", newFeeds);
sendUpdate({
type: at.DISCOVERY_STREAM_FEEDS_UPDATE,
});
}
placementsForEach(callback) {
const { placements } = this.store.getState().DiscoveryStream.spocs;
// Backwards comp for before we had placements, assume just a single spocs placement.
if (!placements || !placements.length) {
[{ name: "spocs" }].forEach(callback);
} else {
placements.forEach(callback);
}
}
async loadSpocs(sendUpdate, isStartup) {
const cachedData = (await this.cache.get()) || {};
let spocsState;
const { placements } = this.store.getState().DiscoveryStream.spocs;
if (this.showSpocs) {
spocsState = cachedData.spocs;
if (this.isExpired({ cachedData, key: "spocs", isStartup })) {
const endpoint = this.store.getState().DiscoveryStream.spocs
.spocs_endpoint;
const start = perfService.absNow();
const headers = new Headers();
headers.append("content-type", "application/json");
const apiKeyPref = this._prefCache.config.api_key_pref;
const apiKey = Services.prefs.getCharPref(apiKeyPref, "");
const spocsResponse = await this.fetchFromEndpoint(endpoint, {
method: "POST",
headers,
body: JSON.stringify({
pocket_id: this._impressionId,
version: 1,
consumer_key: apiKey,
...(placements.length ? { placements } : {}),
}),
});
if (spocsResponse) {
this.spocsRequestTime = Math.round(perfService.absNow() - start);
spocsState = {
lastUpdated: Date.now(),
spocs: {
...spocsResponse,
},
};
this.cleanUpFlightImpressionPref(spocsState.spocs);
await this.cache.set("spocs", spocsState);
} else {
Cu.reportError("No response for spocs_endpoint prop");
}
}
}
// Use good data if we have it, otherwise nothing.
// We can have no data if spocs set to off.
// We can have no data if request fails and there is no good cache.
// We want to send an update spocs or not, so client can render something.
spocsState =
spocsState && spocsState.spocs
? spocsState
: {
lastUpdated: Date.now(),
spocs: {},
};
let frequencyCapped = [];
let blockedItems = [];
let belowMinScore = [];
let flightDupes = [];
this.placementsForEach(placement => {
const freshSpocs = spocsState.spocs[placement.name];
if (!freshSpocs || !freshSpocs.length) {
return;
}
// Migrate flight_id
const { data: migratedSpocs } = this.migrateFlightId(freshSpocs);
const { data: capResult, filtered: caps } = this.frequencyCapSpocs(
migratedSpocs
);
frequencyCapped = [...frequencyCapped, ...caps];
const { data: blockedResults, filtered: blocks } = this.filterBlocked(
capResult
);
blockedItems = [...blockedItems, ...blocks];
let { data: transformResult, filtered: transformFilter } = this.transform(
blockedResults
);
let {
below_min_score: minScoreFilter,
flight_duplicate: dupes,
} = transformFilter;
belowMinScore = [...belowMinScore, ...minScoreFilter];
flightDupes = [...flightDupes, ...dupes];
spocsState.spocs = {
...spocsState.spocs,
[placement.name]: transformResult,
};
});
sendUpdate({
type: at.DISCOVERY_STREAM_SPOCS_UPDATE,
data: {
lastUpdated: spocsState.lastUpdated,
spocs: spocsState.spocs,
},
});
// TODO make sure this works in other places we use it.
// TODO make sure to also validate all of these that they still contain the right ites in the array.
this._sendSpocsFill(
{
frequency_cap: frequencyCapped,
blocked_by_user: blockedItems,
below_min_score: belowMinScore,
flight_duplicate: flightDupes,
},
true
);
}
async clearSpocs() {
const endpoint = this.store.getState().Prefs.values[
PREF_SPOCS_CLEAR_ENDPOINT
];
if (!endpoint) {
return;
}
const headers = new Headers();
headers.append("content-type", "application/json");
await this.fetchFromEndpoint(endpoint, {
method: "DELETE",
headers,
body: JSON.stringify({
pocket_id: this._impressionId,
}),
});
}
async loadAffinityScoresCache() {
const cachedData = (await this.cache.get()) || {};
const { affinities } = cachedData;
if (this.personalized && affinities && affinities.scores) {
this.affinityProvider = new UserDomainAffinityProvider(
affinities.timeSegments,
affinities.parameterSets,
affinities.maxHistoryQueryResults,
affinities.version,
affinities.scores
);
this.domainAffinitiesLastUpdated = affinities._timestamp;
}
}
updateDomainAffinityScores() {
if (
!this.personalized ||
!this.affinities ||
!this.affinities.parameterSets ||
Date.now() - this.domainAffinitiesLastUpdated <
MIN_DOMAIN_AFFINITIES_UPDATE_TIME
) {
return;
}
this.affinityProvider = new UserDomainAffinityProvider(
this.affinities.timeSegments,
this.affinities.parameterSets,
this.affinities.maxHistoryQueryResults,
this.affinities.version,
undefined
);
const affinities = this.affinityProvider.getAffinities();
this.domainAffinitiesLastUpdated = Date.now();
affinities._timestamp = this.domainAffinitiesLastUpdated;
this.cache.set("affinities", affinities);
}
observe(subject, topic, data) {
switch (topic) {
case "idle-daily":
this.updateDomainAffinityScores();
break;
}
}
scoreItems(items) {
const filtered = [];
const data = items
.map(item => this.scoreItem(item))
// Remove spocs that are scored too low.
.filter(s => {
if (s.score >= s.min_score) {
return true;
}
filtered.push(s);
return false;
})
// Sort by highest scores.
.sort((a, b) => b.score - a.score);
return { data, filtered };
}
scoreItem(item) {
item.score = item.item_score;
item.min_score = item.min_score || 0;
if (item.score !== 0 && !item.score) {
item.score = 1;
}
if (this.personalized && this.affinityProvider) {
const scoreResult = this.affinityProvider.calculateItemRelevanceScore(
item
);
if (scoreResult === 0 || scoreResult) {
item.score = scoreResult;
}
}
return item;
}
filterBlocked(data) {
const filtered = [];
if (data && data.length) {
let flights = this.readDataPref(PREF_FLIGHT_BLOCKS);
const filteredItems = data.filter(item => {
const blocked =
NewTabUtils.blockedLinks.isBlocked({ url: item.url }) ||
flights[item.flight_id];
if (blocked) {
filtered.push(item);
}
return !blocked;
});
return {
data: filteredItems,
filtered,
};
}
return { data, filtered };
}
transform(spocs) {
if (spocs && spocs.length) {
const spocsPerDomain =
this.store.getState().DiscoveryStream.spocs.spocs_per_domain || 1;
const flightMap = {};
const flightDuplicates = [];
// This order of operations is intended.
// scoreItems must be first because it creates this.score.
const { data: items, filtered: belowMinScoreItems } = this.scoreItems(
spocs
);
// This removes flight dupes.
// We do this only after scoring and sorting because that way
// we can keep the first item we see, and end up keeping the highest scored.
const newSpocs = items.filter(s => {
if (!flightMap[s.flight_id]) {
flightMap[s.flight_id] = 1;
return true;
} else if (flightMap[s.flight_id] < spocsPerDomain) {
flightMap[s.flight_id]++;
return true;
}
flightDuplicates.push(s);
return false;
});
return {
data: newSpocs,
filtered: {
below_min_score: belowMinScoreItems,
flight_duplicate: flightDuplicates,
},
};
}
return {
data: spocs,
filtered: {
below_min_score: [],
flight_duplicate: [],
},
};
}
// For backwards compatibility, older spoc endpoint don't have flight_id,
// but instead had campaign_id we can use
//
// @param {Object} data An object that might have a SPOCS array.
// @returns {Object} An object with a property `data` as the result.
migrateFlightId(spocs) {
if (spocs && spocs.length) {
return {
data: spocs.map(s => {
return {
...s,
...(s.flight_id || s.campaign_id
? {
flight_id: s.flight_id || s.campaign_id,
}
: {}),
...(s.caps
? {
caps: {
...s.caps,
flight: s.caps.flight || s.caps.campaign,
},
}
: {}),
};
}),
};
}
return { data: spocs };
}
// Filter spocs based on frequency caps
//
// @param {Object} data An object that might have a SPOCS array.
// @returns {Object} An object with a property `data` as the result, and a property
// `filterItems` as the frequency capped items.
frequencyCapSpocs(spocs) {
if (spocs && spocs.length) {
const impressions = this.readDataPref(PREF_SPOC_IMPRESSIONS);
const caps = [];
const result = spocs.filter(s => {
const isBelow = this.isBelowFrequencyCap(impressions, s);
if (!isBelow) {
caps.push(s);
}
return isBelow;
});
// send caps to redux if any.
if (caps.length) {
this.store.dispatch({
type: at.DISCOVERY_STREAM_SPOCS_CAPS,
data: caps,
});
}
return { data: result, filtered: caps };
}
return { data: spocs, filtered: [] };
}
// Frequency caps are based on flight, which may include multiple spocs.
// We currently support two types of frequency caps:
// - lifetime: Indicates how many times spocs from a flight can be shown in total
// - period: Indicates how many times spocs from a flight can be shown within a period
//
// So, for example, the feed configuration below defines that for flight 1 no more
// than 5 spocs can be shown in total, and no more than 2 per hour.
// "flight_id": 1,
// "caps": {
// "lifetime": 5,
// "flight": {
// "count": 2,
// "period": 3600
// }
// }
isBelowFrequencyCap(impressions, spoc) {
const flightImpressions = impressions[spoc.flight_id];
if (!flightImpressions) {
return true;
}
const lifetime = spoc.caps && spoc.caps.lifetime;
const lifeTimeCap = Math.min(
lifetime || MAX_LIFETIME_CAP,
MAX_LIFETIME_CAP
);
const lifeTimeCapExceeded = flightImpressions.length >= lifeTimeCap;
if (lifeTimeCapExceeded) {
return false;
}
const flightCap = spoc.caps && spoc.caps.flight;
if (flightCap) {
const flightCapExceeded =
flightImpressions.filter(i => Date.now() - i < flightCap.period * 1000)
.length >= flightCap.count;
return !flightCapExceeded;
}
return true;
}
async retryFeed(feed) {
const { url } = feed;
const result = await this.getComponentFeed(url);
const newFeed = this.filterRecommendations(result);
this.store.dispatch(
ac.BroadcastToContent({
type: at.DISCOVERY_STREAM_FEED_UPDATE,
data: {
feed: newFeed,
url,
},
})
);
}
async getComponentFeed(feedUrl, isStartup) {
const cachedData = (await this.cache.get()) || {};
const { feeds } = cachedData;
let feed = feeds ? feeds[feedUrl] : null;
if (this.isExpired({ cachedData, key: "feed", url: feedUrl, isStartup })) {
const feedResponse = await this.fetchFromEndpoint(feedUrl);
if (feedResponse) {
const { data: scoredItems } = this.scoreItems(
feedResponse.recommendations
);
const { recsExpireTime } = feedResponse.settings;
const recommendations = this.rotate(scoredItems, recsExpireTime);
this.componentFeedFetched = true;
feed = {
lastUpdated: Date.now(),
data: {
settings: feedResponse.settings,
recommendations,
status: "success",
},
};
} else {
Cu.reportError("No response for feed");
}
}
// If we have no feed at this point, both fetch and cache failed for some reason.
return (
feed || {
data: {
status: "failed",
},
}
);
}
/**
* Called at startup to update cached data in the background.
*/
async _maybeUpdateCachedData() {
const expirationPerComponent = await this._checkExpirationPerComponent();
// Pass in `store.dispatch` to send the updates only to main
if (expirationPerComponent.layout) {
await this.loadLayout(this.store.dispatch);
}
if (expirationPerComponent.spocs) {
await this.loadSpocs(this.store.dispatch);
}
if (expirationPerComponent.feeds) {
await this.loadComponentFeeds(this.store.dispatch);
}
}
/**
* @typedef {Object} RefreshAllOptions
* @property {boolean} updateOpenTabs - Sends updates to open tabs immediately if true,
* updates in background if false
* @property {boolean} isStartup - When the function is called at browser startup
*
* Refreshes layout, component feeds, and spocs in order if caches have expired.
* @param {RefreshAllOptions} options
*/
async refreshAll(options = {}) {
const { updateOpenTabs, isStartup } = options;
const dispatch = updateOpenTabs
? action => this.store.dispatch(ac.BroadcastToContent(action))
: this.store.dispatch;
this.loadAffinityScoresCache();
await this.loadLayout(dispatch, isStartup);
await Promise.all([
this.loadSpocs(dispatch, isStartup).catch(error =>
Cu.reportError(`Error trying to load spocs feeds: ${error}`)
),
this.loadComponentFeeds(dispatch, isStartup).catch(error =>
Cu.reportError(`Error trying to load component feeds: ${error}`)
),
]);
if (isStartup) {
await this._maybeUpdateCachedData();
}
}
// We have to rotate stories on the client so that
// active stories are at the front of the list, followed by stories that have expired
// impressions i.e. have been displayed for longer than recsExpireTime.
rotate(recommendations, recsExpireTime) {
const maxImpressionAge = Math.max(
recsExpireTime * 1000 || DEFAULT_RECS_EXPIRE_TIME,
DEFAULT_RECS_EXPIRE_TIME
);
const impressions = this.readDataPref(PREF_REC_IMPRESSIONS);
const expired = [];
const active = [];
for (const item of recommendations) {
if (
impressions[item.id] &&
Date.now() - impressions[item.id] >= maxImpressionAge
) {
expired.push(item);
} else {
active.push(item);
}
}
return active.concat(expired);
}
/**
* Reports the cache age in second for Discovery Stream.
*/
async reportCacheAge() {
const cachedData = (await this.cache.get()) || {};
const { layout, spocs, feeds } = cachedData;
let cacheAge = Date.now();
let updated = false;
if (layout && layout.lastUpdated && layout.lastUpdated < cacheAge) {
updated = true;
cacheAge = layout.lastUpdated;
}
if (spocs && spocs.lastUpdated && spocs.lastUpdated < cacheAge) {
updated = true;
cacheAge = spocs.lastUpdated;
}
if (feeds) {
Object.keys(feeds).forEach(url => {
const feed = feeds[url];
if (feed.lastUpdated && feed.lastUpdated < cacheAge) {
updated = true;
cacheAge = feed.lastUpdated;
}
});
}
if (updated) {
this.store.dispatch(
ac.PerfEvent({
event: "DS_CACHE_AGE_IN_SEC",
value: Math.round((Date.now() - cacheAge) / 1000),
})
);
}
}
/**
* Reports various time durations when the feed is requested from endpoint for
* the first time. This could happen on the browser start-up, or the pref changes
* of discovery stream.
*
* Metrics to be reported:
* - Request time for layout endpoint
* - Request time for feed endpoint
* - Request time for spoc endpoint
* - Total request time for data completeness
*/
reportRequestTime() {
if (this.layoutRequestTime) {
this.store.dispatch(
ac.PerfEvent({
event: "LAYOUT_REQUEST_TIME",
value: this.layoutRequestTime,
})
);
}
if (this.spocsRequestTime) {
this.store.dispatch(
ac.PerfEvent({
event: "SPOCS_REQUEST_TIME",
value: this.spocsRequestTime,
})
);
}
if (this.componentFeedRequestTime) {
this.store.dispatch(
ac.PerfEvent({
event: "COMPONENT_FEED_REQUEST_TIME",
value: this.componentFeedRequestTime,
})
);
}
if (this.totalRequestTime) {
this.store.dispatch(
ac.PerfEvent({
event: "DS_FEED_TOTAL_REQUEST_TIME",
value: this.totalRequestTime,
})
);
}
}
async enable() {
// Note that cache age needs to be reported prior to refreshAll.
await this.reportCacheAge();
const start = perfService.absNow();
await this.refreshAll({ updateOpenTabs: true, isStartup: true });
Services.obs.addObserver(this, "idle-daily");
this.loaded = true;
this.totalRequestTime = Math.round(perfService.absNow() - start);
this.reportRequestTime();
}
async reset() {
this.resetDataPrefs();
await this.resetCache();
if (this.loaded) {
Services.obs.removeObserver(this, "idle-daily");
}
this.resetState();
}
async resetCache() {
await this.cache.set("layout", {});
await this.cache.set("feeds", {});
await this.cache.set("spocs", {});
await this.cache.set("affinities", {});
}
resetDataPrefs() {
this.writeDataPref(PREF_SPOC_IMPRESSIONS, {});
this.writeDataPref(PREF_REC_IMPRESSIONS, {});
this.writeDataPref(PREF_FLIGHT_BLOCKS, {});
}
resetState() {
// Reset reducer
this.store.dispatch(
ac.BroadcastToContent({ type: at.DISCOVERY_STREAM_LAYOUT_RESET })
);
this.loaded = false;
this.layoutRequestTime = undefined;
this.spocsRequestTime = undefined;
this.componentFeedRequestTime = undefined;
this.totalRequestTime = undefined;
}
async onPrefChange() {
// We always want to clear the cache/state if the pref has changed
await this.reset();
if (this.config.enabled) {
// Load data from all endpoints
await this.enable();
}
}
recordFlightImpression(flightId) {
let impressions = this.readDataPref(PREF_SPOC_IMPRESSIONS);
const timeStamps = impressions[flightId] || [];
timeStamps.push(Date.now());
impressions = { ...impressions, [flightId]: timeStamps };
this.writeDataPref(PREF_SPOC_IMPRESSIONS, impressions);
}
recordTopRecImpressions(recId) {
let impressions = this.readDataPref(PREF_REC_IMPRESSIONS);
if (!impressions[recId]) {
impressions = { ...impressions, [recId]: Date.now() };
this.writeDataPref(PREF_REC_IMPRESSIONS, impressions);
}
}
recordBlockFlightId(flightId) {
const flights = this.readDataPref(PREF_FLIGHT_BLOCKS);
if (!flights[flightId]) {
flights[flightId] = 1;
this.writeDataPref(PREF_FLIGHT_BLOCKS, flights);
}
}
cleanUpFlightImpressionPref(data) {
let flightIds = [];
this.placementsForEach(placement => {
const newSpocs = data[placement.name];
if (!newSpocs) {
return;
}
flightIds = [...flightIds, ...newSpocs.map(s => `${s.flight_id}`)];
});
if (flightIds && flightIds.length) {
this.cleanUpImpressionPref(
id => !flightIds.includes(id),
PREF_SPOC_IMPRESSIONS
);
}
}
// Clean up rec impression pref by removing all stories that are no
// longer part of the response.
cleanUpTopRecImpressionPref(newFeeds) {
// Need to build a single list of stories.
const activeStories = Object.keys(newFeeds)
.filter(currentValue => newFeeds[currentValue].data)
.reduce((accumulator, currentValue) => {
const { recommendations } = newFeeds[currentValue].data;
return accumulator.concat(recommendations.map(i => `${i.id}`));
}, []);
this.cleanUpImpressionPref(
id => !activeStories.includes(id),
PREF_REC_IMPRESSIONS
);
}
writeDataPref(pref, impressions) {
this.store.dispatch(ac.SetPref(pref, JSON.stringify(impressions)));
}
readDataPref(pref) {
const prefVal = this.store.getState().Prefs.values[pref];
return prefVal ? JSON.parse(prefVal) : {};
}
cleanUpImpressionPref(isExpired, pref) {
const impressions = this.readDataPref(pref);
let changed = false;
Object.keys(impressions).forEach(id => {
if (isExpired(id)) {
changed = true;
delete impressions[id];
}
});
if (changed) {
this.writeDataPref(pref, impressions);
}
}
async onAction(action) {
switch (action.type) {
case at.INIT:
// During the initialization of Firefox:
// 1. Set-up listeners and initialize the redux state for config;
this.setupPrefs();
// 2. If config.enabled is true, start loading data.
if (this.config.enabled) {
await this.enable();
}
break;
case at.SYSTEM_TICK:
// Only refresh if we loaded once in .enable()
if (
this.config.enabled &&
this.loaded &&
(await this.checkIfAnyCacheExpired())
) {
await this.refreshAll({ updateOpenTabs: false });
}
break;
case at.DISCOVERY_STREAM_CONFIG_SET_VALUE:
// Use the original string pref to then set a value instead of
// this.config which has some modifications
this.store.dispatch(
ac.SetPref(
PREF_CONFIG,
JSON.stringify({
...JSON.parse(this.store.getState().Prefs.values[PREF_CONFIG]),
[action.data.name]: action.data.value,
})
)
);
break;
case at.DISCOVERY_STREAM_CONFIG_RESET_DEFAULTS:
this.resetConfigDefauts();
break;
case at.DISCOVERY_STREAM_RETRY_FEED:
this.retryFeed(action.data.feed);
break;
case at.DISCOVERY_STREAM_CONFIG_CHANGE:
// When the config pref changes, load or unload data as needed.
await this.onPrefChange();
break;
case at.DISCOVERY_STREAM_IMPRESSION_STATS:
if (
action.data.tiles &&
action.data.tiles[0] &&
action.data.tiles[0].id
) {
this.recordTopRecImpressions(action.data.tiles[0].id);
}
break;
case at.DISCOVERY_STREAM_SPOC_IMPRESSION:
if (this.showSpocs) {
this.recordFlightImpression(action.data.flightId);
// Apply frequency capping to SPOCs in the redux store, only update the
// store if the SPOCs are changed.
const spocsState = this.store.getState().DiscoveryStream.spocs;
let frequencyCapped = [];
this.placementsForEach(placement => {
const freshSpocs = spocsState.data[placement.name];
if (!freshSpocs) {
return;
}
const { data: newSpocs, filtered } = this.frequencyCapSpocs(
freshSpocs
);
frequencyCapped = [...frequencyCapped, ...filtered];
spocsState.data = {
...spocsState.data,
[placement.name]: newSpocs,
};
});
if (frequencyCapped.length) {
this.store.dispatch(
ac.AlsoToPreloaded({
type: at.DISCOVERY_STREAM_SPOCS_UPDATE,
data: {
lastUpdated: spocsState.lastUpdated,
spocs: spocsState.data,
},
})
);
this._sendSpocsFill({ frequency_cap: frequencyCapped }, false);
}
}
break;
// This is fired from the browser, it has no concept of spocs, flight or pocket.
// We match the blocked url with our available spoc urls to see if there is a match.
// I suspect we *could* instead do this in BLOCK_URL but I'm not sure.
case at.PLACES_LINK_BLOCKED:
if (this.showSpocs) {
const spocsState = this.store.getState().DiscoveryStream.spocs;
let spocsList = [];
this.placementsForEach(placement => {
const spocs = spocsState.data[placement.name];
if (spocs && spocs.length) {
spocsList = [...spocsList, ...spocs];
}
});
const filtered = spocsList.filter(s => s.url === action.data.url);
if (filtered.length) {
this._sendSpocsFill({ blocked_by_user: filtered }, false);
// If we're blocking a spoc, we want a slightly different treatment for open tabs.
// AlsoToPreloaded updates the source data and preloaded tabs with a new spoc.
// BroadcastToContent updates open tabs with a non spoc instead of a new spoc.
this.store.dispatch(
ac.AlsoToPreloaded({
type: at.DISCOVERY_STREAM_LINK_BLOCKED,
data: action.data,
})
);
this.store.dispatch(
ac.BroadcastToContent({
type: at.DISCOVERY_STREAM_SPOC_BLOCKED,
data: action.data,
})
);
break;
}
}
this.store.dispatch(
ac.BroadcastToContent({
type: at.DISCOVERY_STREAM_LINK_BLOCKED,
data: action.data,
})
);
break;
case at.UNINIT:
// When this feed is shutting down:
this.uninitPrefs();
break;
case at.BLOCK_URL: {
// If we block a story that also has a flight_id
// we want to record that as blocked too.
// This is because a single flight might have slightly different urls.
const { flight_id } = action.data;
if (flight_id) {
this.recordBlockFlightId(flight_id);
}
break;
}
case at.PREF_CHANGED:
switch (action.data.name) {
case PREF_CONFIG:
case PREF_ENABLED:
case PREF_HARDCODED_BASIC_LAYOUT:
case PREF_SPOCS_ENDPOINT:
case PREF_LANG_LAYOUT_CONFIG:
// Clear the cached config and broadcast the newly computed value
this._prefCache.config = null;
this.store.dispatch(
ac.BroadcastToContent({
type: at.DISCOVERY_STREAM_CONFIG_CHANGE,
data: this.config,
})
);
break;
case PREF_TOPSTORIES:
if (!action.data.value) {
// Ensure we delete any remote data potentially related to spocs.
this.clearSpocs();
}
break;
// Check if spocs was disabled. Remove them if they were.
case PREF_SHOW_SPONSORED:
if (!action.data.value) {
// Ensure we delete any remote data potentially related to spocs.
this.clearSpocs();
}
await this.loadSpocs(update =>
this.store.dispatch(ac.BroadcastToContent(update))
);
break;
}
break;
}
}
};
// This function generates a hardcoded layout each call.
// This is because modifying the original object would
// persist across pref changes and system_tick updates.
getHardcodedLayout = basic => {
if (basic) {
// Hardcoded version of layout_variant `basic`
return {
lastUpdate: Date.now(),
spocs: {
url: "https://spocs.getpocket.com/spocs",
spocs_per_domain: 1,
},
layout: [
{
width: 12,
components: [
{
type: "TopSites",
header: {
title: {
id: "newtab-section-header-topsites",
},
},
properties: {},
},
{
type: "Message",
header: {
title: {
id: "newtab-section-header-pocket",
values: { provider: "pocket" },
},
subtitle: "",
link_text: {
id: "newtab-pocket-whats-pocket",
values: { provider: "pocket" },
},
link_url: "https://getpocket.com/firefox/new_tab_learn_more",
icon:
"resource://activity-stream/data/content/assets/glyph-pocket-16.svg",
},
properties: {},
styles: {
".ds-message": "margin-bottom: -20px",
},
},
{
type: "CardGrid",
properties: {
items: 3,
},
header: {
title: "",
},
feed: {
embed_reference: null,
url:
"https://getpocket.cdn.mozilla.net/v3/firefox/global-recs?version=3&consumer_key=$apiKey&locale_lang=$locale",
},
spocs: {
probability: 1,
positions: [
{
index: 2,
},
],
},
},
{
type: "Navigation",
properties: {
alignment: "left-align",
links: [
{
name: "Must Reads",
url:
"https://getpocket.com/explore/must-reads?src=fx_new_tab",
},
{
name: "Productivity",
url:
"https://getpocket.com/explore/productivity?src=fx_new_tab",
},
{
name: "Health",
url: "https://getpocket.com/explore/health?src=fx_new_tab",
},
{
name: "Finance",
url: "https://getpocket.com/explore/finance?src=fx_new_tab",
},
{
name: "Technology",
url:
"https://getpocket.com/explore/technology?src=fx_new_tab",
},
{
name: "More Recommendations ›",
url:
"https://getpocket.com/explore/trending?src=fx_new_tab",
},
],
},
},
],
},
],
};
}
// Hardcoded version of layout_variant `3-col-7-row-octr`
return {
lastUpdate: Date.now(),
spocs: {
url: "https://spocs.getpocket.com/spocs",
spocs_per_domain: 1,
},
layout: [
{
width: 12,
components: [
{
type: "TopSites",
header: {
title: {
id: "newtab-section-header-topsites",
},
},
},
],
},
{
width: 12,
components: [
{
type: "Message",
header: {
title: {
id: "newtab-section-header-pocket",
values: { provider: "pocket" },
},
subtitle: "",
link_text: {
id: "newtab-pocket-whats-pocket",
values: { provider: "pocket" },
},
link_url: "https://getpocket.com/firefox/new_tab_learn_more",
icon:
"resource://activity-stream/data/content/assets/glyph-pocket-16.svg",
},
properties: {},
styles: {
".ds-message": "margin-bottom: -20px",
},
},
],
},
{
width: 12,
components: [
{
type: "CardGrid",
properties: {
items: 21,
},
header: {
title: "",
},
feed: {
embed_reference: null,
url:
"https://getpocket.cdn.mozilla.net/v3/firefox/global-recs?version=3&consumer_key=$apiKey&locale_lang=$locale&count=30",
},
spocs: {
probability: 1,
positions: [
{
index: 2,
},
{
index: 4,
},
{
index: 11,
},
{
index: 20,
},
],
},
},
{
type: "Navigation",
properties: {
alignment: "left-align",
links: [
{
name: "Must Reads",
url:
"https://getpocket.com/explore/must-reads?src=fx_new_tab",
},
{
name: "Productivity",
url:
"https://getpocket.com/explore/productivity?src=fx_new_tab",
},
{
name: "Health",
url: "https://getpocket.com/explore/health?src=fx_new_tab",
},
{
name: "Finance",
url: "https://getpocket.com/explore/finance?src=fx_new_tab",
},
{
name: "Technology",
url:
"https://getpocket.com/explore/technology?src=fx_new_tab",
},
{
name: "More Recommendations ›",
url: "https://getpocket.com/explore/trending?src=fx_new_tab",
},
],
},
header: {
title: {
id: "newtab-pocket-read-more",
},
},
styles: {
".ds-navigation": "margin-top: -10px;",
},
},
],
},
],
};
};
const EXPORTED_SYMBOLS = ["DiscoveryStreamFeed"];
================================================
FILE: lib/DownloadsManager.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/. */
const { XPCOMUtils } = ChromeUtils.import(
"resource://gre/modules/XPCOMUtils.jsm"
);
XPCOMUtils.defineLazyGlobalGetters(this, ["URL"]);
const { actionTypes: at } = ChromeUtils.import(
"resource://activity-stream/common/Actions.jsm"
);
XPCOMUtils.defineLazyModuleGetters(this, {
BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.jsm",
DownloadsCommon: "resource:///modules/DownloadsCommon.jsm",
DownloadsViewUI: "resource:///modules/DownloadsViewUI.jsm",
FileUtils: "resource://gre/modules/FileUtils.jsm",
NewTabUtils: "resource://gre/modules/NewTabUtils.jsm",
});
const DOWNLOAD_CHANGED_DELAY_TIME = 1000; // time in ms to delay timer for downloads changed events
this.DownloadsManager = class DownloadsManager {
constructor(store) {
this._downloadData = null;
this._store = null;
this._downloadItems = new Map();
this._downloadTimer = null;
}
setTimeout(callback, delay) {
let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
timer.initWithCallback(callback, delay, Ci.nsITimer.TYPE_ONE_SHOT);
return timer;
}
formatDownload(download) {
return {
hostname: new URL(download.source.url).hostname,
url: download.source.url,
path: download.target.path,
title: DownloadsViewUI.getDisplayName(download),
description:
DownloadsViewUI.getSizeWithUnits(download) ||
DownloadsCommon.strings.sizeUnknown,
referrer: download.source.referrerInfo
? download.source.referrerInfo.originalReferrer.spec
: null,
date_added: download.endTime,
};
}
init(store) {
this._store = store;
this._downloadData = DownloadsCommon.getData(
null /* null for non-private downloads */,
true,
false,
true
);
this._downloadData.addView(this);
}
onDownloadAdded(download) {
if (!this._downloadItems.has(download.source.url)) {
this._downloadItems.set(download.source.url, download);
// On startup, all existing downloads fire this notification, so debounce them
if (this._downloadTimer) {
this._downloadTimer.delay = DOWNLOAD_CHANGED_DELAY_TIME;
} else {
this._downloadTimer = this.setTimeout(() => {
this._downloadTimer = null;
this._store.dispatch({ type: at.DOWNLOAD_CHANGED });
}, DOWNLOAD_CHANGED_DELAY_TIME);
}
}
}
onDownloadRemoved(download) {
if (this._downloadItems.has(download.source.url)) {
this._downloadItems.delete(download.source.url);
this._store.dispatch({ type: at.DOWNLOAD_CHANGED });
}
}
async getDownloads(
threshold,
{
numItems = this._downloadItems.size,
onlySucceeded = false,
onlyExists = false,
}
) {
if (!threshold) {
return [];
}
let results = [];
// Only get downloads within the time threshold specified and sort by recency
const downloadThreshold = Date.now() - threshold;
let downloads = [...this._downloadItems.values()]
.filter(download => download.endTime > downloadThreshold)
.sort((download1, download2) => download1.endTime < download2.endTime);
for (const download of downloads) {
// Ignore blocked links, but allow long (data:) uris to avoid high CPU
if (
download.source.url.length < 10000 &&
NewTabUtils.blockedLinks.isBlocked(download.source)
) {
continue;
}
// Only include downloads where the file still exists
if (onlyExists) {
// Refresh download to ensure the 'exists' attribute is up to date
await download.refresh();
if (!download.target.exists) {
continue;
}
}
// Only include downloads that were completed successfully
if (onlySucceeded) {
if (!download.succeeded) {
continue;
}
}
const formattedDownloadForHighlights = this.formatDownload(download);
results.push(formattedDownloadForHighlights);
if (results.length === numItems) {
break;
}
}
return results;
}
uninit() {
if (this._downloadData) {
this._downloadData.removeView(this);
this._downloadData = null;
}
if (this._downloadTimer) {
this._downloadTimer.cancel();
this._downloadTimer = null;
}
}
onAction(action) {
let doDownloadAction = callback => {
let download = this._downloadItems.get(action.data.url);
if (download) {
callback(download);
}
};
switch (action.type) {
case at.COPY_DOWNLOAD_LINK:
doDownloadAction(download => {
DownloadsCommon.copyDownloadLink(download);
});
break;
case at.REMOVE_DOWNLOAD_FILE:
doDownloadAction(download => {
DownloadsCommon.deleteDownload(download).catch(Cu.reportError);
});
break;
case at.SHOW_DOWNLOAD_FILE:
doDownloadAction(download => {
DownloadsCommon.showDownloadedFile(
new FileUtils.File(download.target.path)
);
});
break;
case at.OPEN_DOWNLOAD_FILE:
doDownloadAction(download => {
DownloadsCommon.openDownloadedFile(
new FileUtils.File(download.target.path),
null,
BrowserWindowTracker.getTopWindow()
);
});
break;
case at.UNINIT:
this.uninit();
break;
}
}
};
this.EXPORTED_SYMBOLS = ["DownloadsManager"];
================================================
FILE: lib/FaviconFeed.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 { getDomain } = ChromeUtils.import(
"resource://activity-stream/lib/TippyTopProvider.jsm"
);
const { RemoteSettings } = ChromeUtils.import(
"resource://services-settings/remote-settings.js"
);
ChromeUtils.defineModuleGetter(
this,
"PlacesUtils",
"resource://gre/modules/PlacesUtils.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"Services",
"resource://gre/modules/Services.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"NewTabUtils",
"resource://gre/modules/NewTabUtils.jsm"
);
const MIN_FAVICON_SIZE = 96;
/**
* Get favicon info (uri and size) for a uri from Places.
*
* @param uri {nsIURI} Page to check for favicon data
* @returns A promise of an object (possibly null) containing the data
*/
function getFaviconInfo(uri) {
// Use 0 to get the biggest width available
const preferredWidth = 0;
return new Promise(resolve =>
PlacesUtils.favicons.getFaviconDataForPage(
uri,
// Package up the icon data in an object if we have it; otherwise null
(iconUri, faviconLength, favicon, mimeType, faviconSize) =>
resolve(iconUri ? { iconUri, faviconSize } : null),
preferredWidth
)
);
}
/**
* Fetches visit paths for a given URL from its most recent visit in Places.
*
* Note that this includes the URL itself as well as all the following
* permenent&temporary redirected URLs if any.
*
* @param {String} a URL string
*
* @returns {Array} Returns an array containing objects as
* {int} visit_id: ID of the visit in moz_historyvisits.
* {String} url: URL of the redirected URL.
*/
async function fetchVisitPaths(url) {
const query = `
WITH RECURSIVE path(visit_id)
AS (
SELECT v.id
FROM moz_places h
JOIN moz_historyvisits v
ON v.place_id = h.id
WHERE h.url_hash = hash(:url) AND h.url = :url
AND v.visit_date = h.last_visit_date
UNION
SELECT id
FROM moz_historyvisits
JOIN path
ON visit_id = from_visit
WHERE visit_type IN
(${PlacesUtils.history.TRANSITIONS.REDIRECT_PERMANENT},
${PlacesUtils.history.TRANSITIONS.REDIRECT_TEMPORARY})
)
SELECT visit_id, (
SELECT (
SELECT url
FROM moz_places
WHERE id = place_id)
FROM moz_historyvisits
WHERE id = visit_id) AS url
FROM path
`;
const visits = await NewTabUtils.activityStreamProvider.executePlacesQuery(
query,
{
columns: ["visit_id", "url"],
params: { url },
}
);
return visits;
}
/**
* Fetch favicon for a url by following its redirects in Places.
*
* This can improve the rich icon coverage for Top Sites since Places only
* associates the favicon to the final url if the original one gets redirected.
* Note this is not an urgent request, hence it is dispatched to the main
* thread idle handler to avoid any possible performance impact.
*/
async function fetchIconFromRedirects(url) {
const visitPaths = await fetchVisitPaths(url);
if (visitPaths.length > 1) {
const lastVisit = visitPaths.pop();
const redirectedUri = Services.io.newURI(lastVisit.url);
const iconInfo = await getFaviconInfo(redirectedUri);
if (iconInfo && iconInfo.faviconSize >= MIN_FAVICON_SIZE) {
PlacesUtils.favicons.setAndFetchFaviconForPage(
Services.io.newURI(url),
iconInfo.iconUri,
false,
PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE,
null,
Services.scriptSecurityManager.getSystemPrincipal()
);
}
}
}
this.FaviconFeed = class FaviconFeed {
constructor() {
this._queryForRedirects = new Set();
}
/**
* fetchIcon attempts to fetch a rich icon for the given url from two sources.
* First, it looks up the tippy top feed, if it's still missing, then it queries
* the places for rich icon with its most recent visit in order to deal with
* the redirected visit. See Bug 1421428 for more details.
*/
async fetchIcon(url) {
// Avoid initializing and fetching icons if prefs are turned off
if (!this.shouldFetchIcons) {
return;
}
const site = await this.getSite(getDomain(url));
if (!site) {
if (!this._queryForRedirects.has(url)) {
this._queryForRedirects.add(url);
Services.tm.idleDispatchToMainThread(() => fetchIconFromRedirects(url));
}
return;
}
let iconUri = Services.io.newURI(site.image_url);
// The #tippytop is to be able to identify them for telemetry.
iconUri = iconUri
.mutate()
.setRef("tippytop")
.finalize();
PlacesUtils.favicons.setAndFetchFaviconForPage(
Services.io.newURI(url),
iconUri,
false,
PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE,
null,
Services.scriptSecurityManager.getSystemPrincipal()
);
}
/**
* Get the site tippy top data from Remote Settings.
*/
async getSite(domain) {
const sites = await this.tippyTop.get({
filters: { domain },
syncIfEmpty: false,
});
return sites.length ? sites[0] : null;
}
/**
* Get the tippy top collection from Remote Settings.
*/
get tippyTop() {
if (!this._tippyTop) {
this._tippyTop = RemoteSettings("tippytop");
}
return this._tippyTop;
}
/**
* Determine if we should be fetching and saving icons.
*/
get shouldFetchIcons() {
return Services.prefs.getBoolPref("browser.chrome.site_icons");
}
onAction(action) {
switch (action.type) {
case at.RICH_ICON_MISSING:
this.fetchIcon(action.data.url);
break;
}
}
};
const EXPORTED_SYMBOLS = ["FaviconFeed", "fetchIconFromRedirects"];
================================================
FILE: lib/FilterAdult.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";
ChromeUtils.defineModuleGetter(
this,
"Services",
"resource://gre/modules/Services.jsm"
);
// Keep a Set of adult base domains for lookup (initialized at end of file)
let gAdultSet;
// Keep a hasher for repeated hashings
let gCryptoHash = null;
/**
* Run some text through md5 and return the base64 result.
*/
function md5Hash(text) {
// Lazily create a reusable hasher
if (gCryptoHash === null) {
gCryptoHash = Cc["@mozilla.org/security/hash;1"].createInstance(
Ci.nsICryptoHash
);
}
gCryptoHash.init(gCryptoHash.MD5);
// Convert the text to a byte array for hashing
gCryptoHash.update(text.split("").map(c => c.charCodeAt(0)), text.length);
// Request the has result as ASCII base64
return gCryptoHash.finish(true);
}
/**
* Filter out any link objects that have a url with an adult base domain.
*/
function filterAdult(links) {
return links.filter(({ url }) => {
try {
const uri = Services.io.newURI(url);
return !gAdultSet.has(md5Hash(Services.eTLD.getBaseDomain(uri)));
} catch (ex) {
return true;
}
});
}
const EXPORTED_SYMBOLS = ["filterAdult"];
// These are md5 hashes of base domains to be filtered out. Originally from:
// https://hg.mozilla.org/mozilla-central/log/default/browser/base/content/newtab/newTab.inadjacent.json
gAdultSet = new Set([
"+/UCpAhZhz368iGioEO8aQ==",
"+1e7jvUo8f2/2l0TFrQqfA==",
"+1gcqAqaRZwCj5BGiZp3CA==",
"+25t/2lo0FUEtWYK8LdQZQ==",
"+8PiQt6O7pJI/nIvQpDaAg==",
"+CLf5witKkuOvPCulTlkqw==",
"+CvLiih/gf2ugXAF+LgWqw==",
"+DWs0vvFGt6d3mzdcsdsyA==",
"+H0Rglt/HnhZwdty2hsDHg==",
"+L1FDsr5VQtuYc2Is5QGjw==",
"+LJYVZl1iPrdMU3L5+nxZw==",
"+Mp+JIyO0XC5urvMyi3wvQ==",
"+NMUaQ7XPsAi0rk7tTT9wQ==",
"+NmjwjsPhGJh9bM10SFkLw==",
"+OERSmo7OQUUjudkccSMOA==",
"+OLntmlsMBBYPREPnS6iVw==",
"+OXdvbTxHtSoLg7bZMho4w==",
"+P5q4YD1Rr5SX26Xr+tzlw==",
"+PUVXkoTqHxJHO18z4KMfw==",
"+Pl0bSMBAdXpRIA+zE02JA==",
"+QosBAnSM2h4lsKuBlqEZw==",
"+S+WXgVDSU1oGmCzGwuT3g==",
"+SclwwY8R2RPrnX54Z+A6w==",
"+VfRcTBQ80KSeJRdg0cDfw==",
"+WpF8+poKmHPUBB4UYh/ig==",
"+YVxSyViJfrme/ENe1zA7A==",
"+YrqTEJlJCv0A2RHQ8tr1A==",
"+ZozWaPWw8ws1cE5DJACeg==",
"+aF4ilbjQbLpAuFXQEYMWQ==",
"+dBv88reDrjEz6a2xX3Hzw==",
"+dIEf5FBrHpkjmwUmGS6eg==",
"+edqJYGvcy1AH2mEjJtSIg==",
"+fcjH2kZKNj8quOytUk4nQ==",
"+gO0bg8LY+py2dLM1sM7Ag==",
"+gbitI/gpxebN/rK7qj8Fw==",
"+gpHnUj2GWocP74t5XWz4w==",
"+jVN/3ASc2O44sX6ab8/cg==",
"+mJLK+6qq8xFv7O/mbILTw==",
"+n0K7OB2ItzhySZ4rhUrMg==",
"+p8pofUlwn8vV6Rp6+sz9g==",
"+tuUmnRDRWVLA+1k0dcUvg==",
"+zBkeHF4P8vLzk1iO1Zn3Q==",
"//eHwmDOQRSrv+k9C/k3ZQ==",
"/2Chaw2M9DzsadFFkCu6WQ==",
"/2c4oNniwhL3z5IOngfggg==",
"/2jGyMekNu7U136K+2N3Jg==",
"/Bwpt5fllzDHq2Ul6v86fA==",
"/DJgKE9ouibewuZ2QEnk6w==",
"/DiUApY7cVp5W9o24rkgRA==",
"/FchS2nPezycB8Bcqc2dbg==",
"/FdZzSprPnNDPwbhV1C0Cg==",
"/FsJYFNe+7UvsSkiotNJEQ==",
"/G26n5Xoviqldr5sg/Jl3w==",
"/HU2+fBqfWTEuqINc0UZSA==",
"/IarsLzJB8bf0AupJJ+/Eg==",
"/KYZdUWrkfxSsIrp46xxow==",
"/MEOgAhwb7F0nBnV4tIRZA==",
"/MeHciFhvFzQsCIw39xIZA==",
"/Ph/6l/lFNVqxAje1+PgFA==",
"/SP6pOdYFzcAl2OL05z4uQ==",
"/TSsi/AwKHtP6kQaeReI3w==",
"/VnKh/NDv7y/bfO6CWsLaQ==",
"/XC/FmMIOdhMTPqmy4DfUA==",
"/XjB6c5fxFGcKVAQ4o+OMw==",
"/YuQw7oAF08KDptxJEBS9g==",
"/a+bLXOq02sa/s8h7PhUTg==",
"/a9O7kWeXa0le45ab3+nVw==",
"/c34NtdUZAHWIwGl3JM8Tw==",
"/cJ0Nn5YbXeUpOHMfWXNHQ==",
"/cdR1i5TuQvO+u3Ov3b0KQ==",
"/gi3UZmunVOIXhZSktZ8zQ==",
"/hFhjFGJx2wRfz6hyrIpvA==",
"/jDVt9dRIn+o4IQ1DPwbsg==",
"/jH6imhTPZ/tHI4gYz2+HA==",
"/kGxvyEokQsVz0xlKzCn2A==",
"/mFp3GFkGNLhx2CiDvJv4A==",
"/mrqas0eDX+sFUNJvCQY8g==",
"/n1RLTTVpygre1dl36PDwQ==",
"/ngbFuKIAVpdSwsA3VxvNw==",
"/p/aCTIhi1bU0/liuO/a2Q==",
"/u5W2Gab4GgCMIc4KTp2mg==",
"/wIZAye9h1TUiZmDW0ZmYA==",
"/wiA2ltAuWyBhIvQAYBTQw==",
"/y/jHHEpUu5TR+R2o96kXA==",
"/zFLRvi75UL8qvg+a6zqGg==",
"00TVKawojyqrJkC7YqT41Q==",
"022B0oiRMx8Xb4Af98mTvQ==",
"02im2RooJQ/9UfUrh5LO+A==",
"0G93AxGPVwmr66ZOleM90A==",
"0HN6MIGtkdzNPsrGs611xA==",
"0K4NBxqEa3RYpnrkrD/XjQ==",
"0L0FVcH5Dlj3oL8+e9Na7g==",
"0NrvBuyjcJ2q6yaHpz/FOA==",
"0ODJyWKJSfObo+FNdRQkkA==",
"0QB0OUW5x2JLHfrtmpZQ+w==",
"0QCQORCYfLuSbq94Sbt0bQ==",
"0QbH4oI8IjZ9BRcqRyvvDQ==",
"0QxPAqRF8inBuFEEzNmLjA==",
"0SkC/4PtnX1bMYgD6r6CLA==",
"0TxcYwG72dT7Tg+eG8pP1w==",
"0UeRwDID2RBIikInqFI7uw==",
"0VsaJHR0Ms8zegsCpAKoyg==",
"0Y6iiZjCwPDwD/CwJzfioQ==",
"0ZEC3hy411LkOhKblvTcqg==",
"0ZRGz+oj2infCAkuKKuHiQ==",
"0a4SafpDIe8V4FlFWYkMHw==",
"0b/xj6fd0x+aB8EB0LC4SA==",
"0bj069wXgEJbw7dpiPr8Tg==",
"0dIeIM5Zvm5nSVWLy94LWg==",
"0e8hM3E5tnABRyy29A8yFw==",
"0egBaMnAf0CQEXf1pCIKnA==",
"0fN+eHlbRS6mVZBbH/B9FQ==",
"0fnruVOCxEczscBuv4yL9A==",
"0fpe9E6m3eLp/5j5rLrz2Q==",
"0klouNfZRHFFpdHi4ZR2hA==",
"0nOg18ZJ/NicqVUz5Jr0Hg==",
"0ofMbUCA3/v5L8lHnX4S5w==",
"0p1jMr06OyBoXQuSLYN4aQ==",
"0p8YbEMxeb73HbAfvPLQRw==",
"0q+erphtrB+6HBnnYg7O6w==",
"0rTYcuVYdilO7zEfKrxY3A==",
"0rfG4gRugAwVP0i3AGVxxg==",
"0u+0WHr7WI6IlVBBgiRi6w==",
"0yJ7TQYzcp3DXVSvwavr+w==",
"1+A9FCGP3bZhk6gU3LQtNg==",
"1+XWdu4qCqLLVjqkKz3nmA==",
"1+qmrbC8c7MJ6pxmDMcKuA==",
"1/Hxu8M9N/oNwk8bCj4FNQ==",
"1/SGIab+NnizimUmNDC4wA==",
"1/ZheMsbojazxt31j/l3iA==",
"10OltdxPXOvfatJuwPVKbQ==",
"11FE2kknwYi2Qu0JUKMn3A==",
"11U5XEwfMI7avx014LfC8g==",
"16d+fhFlgayu3ttKVV/pbg==",
"16iT/jCcPDrJEfi2bE5F+Q==",
"18RKixTv12q3xoBLz6eKiA==",
"18ndtDM9UaNfBR1cr3SHdA==",
"19yQHaBemtlgo2QkU5M6jQ==",
"1AeReq55UQotRQVKJ66pmg==",
"1ApqwW7pE+XUB2Cs2M6y7g==",
"1B5gxGQSGzVKoNd5Ol4N7g==",
"1BjsijOzgHt/0i36ZGffoQ==",
"1C50kisi9nvyVJNfq2hOEQ==",
"1E3pMgAHOnHx3ALdNoHr8Q==",
"1EI9aa955ejNo1dJepcZJw==",
"1FSrgkUXgZot2CsmbAtkPw==",
"1Gpj4TPXhdPEI4zfQFsOCg==",
"1HDgfU7xU7LWO/BXsODZAQ==",
"1I+UVx3krrD4NhzO7dgfHQ==",
"1JI9bT92UzxI8txjhst9LQ==",
"1JRgSHnfAQFQtSkFTttkqQ==",
"1LPC0BzhJbepHTSAiZ3QTw==",
"1MIn73MLroxXirrb+vyg2Q==",
"1Oykse0jQVbuR3MvW5ot4A==",
"1Pmnur6TbZ9cmemvu0+dSA==",
"1PvTn90xwZJPoVfyT5/uIQ==",
"1QGhj9NONF2rC44UdO+Izw==",
"1RQZ2pWSxT+RKyhBigtSFg==",
"1Vtrv6QUAfiYQjlLTpNovg==",
"1WIi4I62GqkjDXOYqHWJfQ==",
"1Wc8jQlDSB4Dp32wkL2odw==",
"1X14kHeKwGmLeYqpe60XEA==",
"1YO9G8qAhLIu2rShvekedw==",
"1Ym0lyBJ9aFjhJb/GdUPvQ==",
"1b2uf+CdVjufqiVpUShvHw==",
"1buQEv2YlH/ljTgH0uJEtw==",
"1cj1Fpd3+UiBAOahEhsluA==",
"1d7RPHdZ9qzAbG3Vi9BdFA==",
"1dhq3ozNCx0o4dV1syLVDA==",
"1dsKN1nG6upj7kKTKuJWsQ==",
"1eCHcz4swFH+uRhiilOinQ==",
"1eRUCdIJe3YGD5jOMbkkOg==",
"1fztTtQWNMIMSAc5Hr6jMQ==",
"1gA65t5FiBTEgMELTQFUPQ==",
"1jBaRO8Bg5l6TH7qJ8EPiw==",
"1k8tL2xmGFVYMgKUcmDcEw==",
"1lCcQWGDePPYco4vYrA5vw==",
"1m1yD4L9A7Q1Ot+wCsrxJQ==",
"1mw6LfTiirFyfjejf8QNGA==",
"1nXByug2eKq0kR3H3VjnWQ==",
"1tpM0qgdo7JDFwvT0TD78g==",
"1vqRt79ukuvdJNyIlIag8Q==",
"1wBuHqS1ciup31WTfm3NPg==",
"1xWx5V3G9murZP7srljFmA==",
"1zDfWw5LdG20ClNP1HYxgw==",
"203EqmJI9Q4tWxTJaBdSzA==",
"23C4eh3yBb5n/RNZeTyJkA==",
"23d9B9Gz5kUOi1I//EYsSQ==",
"24H9q+E8pgCEdFS7JO5kzQ==",
"25w3ZRUzCvJwAVHYCIO5uw==",
"26+yXbqI+fmIZsYl4UhUzw==",
"26Wmdp6SkKN74W0/XPcnmA==",
"29EybnMEO95Ng4l/qK4NWQ==",
"2Ct+pLXrK6Ku1f4qehjurQ==",
"2D6yhuABiaFFoXz0Lh0C+w==",
"2DNbXVgesUa7PgYQ4zX5Lw==",
"2E41e0MgM3WhFx2oasIQeA==",
"2HHqeGRMfzf3RXwVybx+ZQ==",
"2Hc5oyl0AYRy2VzcDKy+VA==",
"2QQtKtBAm2AjJ5c0WQ6BQA==",
"2QS/6OBA1T01NlIbfkTYJg==",
"2RFaMPlSbVuoEqKXgkIa5A==",
"2SI4F7Vvde2yjzMLAwxOog==",
"2SwIiUwT4vRZPrg7+vZqDA==",
"2W6lz1Z7PhkvObEAg2XKJw==",
"2Wvk/kouEEOY0evUkQLhOQ==",
"2XrR2hjDEvx8MQpHk9dnjw==",
"2aDK0tGNgMLyxT+BQPDE8Q==",
"2aIx9UdMxxZWvrfeJ+DcTw==",
"2abfl3N46tznOpr+94VONQ==",
"2bsIpvnGcFhTCSrK9EW1FQ==",
"2hEzujfG3mR5uQJXbvOPTQ==",
"2j83jrPwPfYlpJJ2clEBYQ==",
"2ksediOVrh4asSBxKcudTg==",
"2melaInV0wnhBpiI3da6/A==",
"2nSTEYzLK77h5Rgyti+ULQ==",
"2os5s7j7Tl46ZmoZJH8FjA==",
"2rOkEVl90EPqfHOF5q2FYw==",
"2rhjiY0O0Lo36wTHjmlNyw==",
"2vm7g3rk1ACJOTCXkLB3zA==",
"2wesXiib76wM9sqRZ7JYwQ==",
"2ywo4t5PPSVUCWDwUlOVwQ==",
"3++dZXzZ6AFEz7hK+i5hww==",
"3+9nURtBK3FKn0J9DQDa3g==",
"3+zsjCi7TnJhti//YXK35w==",
"3/1puZTGSrD9qNKPGaUZww==",
"300hoYyMR/mk1mfWJxS8/w==",
"301utVPZ93AnPLYbsiJggw==",
"312g8iTB9oJgk/OqcgR7Cw==",
"342VOUOxoLHUqtHANt83Hw==",
"36XDmX6j542q+Oei1/x0gw==",
"37Nkh06O979nt7xzspOFyQ==",
"3AKEYQqpkfW7CZMFQZoxOw==",
"3AVYtcIv7A5mVbVnQMaCeA==",
"3BjLFon1Il0SsjxHE2A1LQ==",
"3CJbrUdW68E3Drhe4ahUnQ==",
"3EhLkC9NqD3A6ApV6idmgg==",
"3Ejtsqw3Iep/UQd0tXnSlg==",
"3FH4D31nKV13sC9RpRZFIg==",
"3Gg9N7vjAfQEYOtQKuF/Eg==",
"3HPOzIZxoaQAmWRy9OkoSg==",
"3JhnM6G4L06NHt31lR0zXA==",
"3L3KEBHhgDwH615w4OvgZA==",
"3Leu2Sc+YOntJFlrvhaXeg==",
"3P2aJxV8Trll2GH9ptElYA==",
"3RTtSaMp1TZegJo5gFtwwA==",
"3TbRZtFtsh9ez8hqZuTDeA==",
"3TjntNWtpG7VqBt3729L6Q==",
"3UBYBMejKInSbCHRoJJ7dg==",
"3UNJ37f+gnNyYk9yLFeoYA==",
"3WVBP9fyAiBPZAq3DpMwOQ==",
"3Wfj05vCLFAB9vII5AU9tw==",
"3WwITQML938W9+MUM56a3A==",
"3XyoREdvhmSbyvAbgw2y/A==",
"3Y4w0nETru3SiSVUMcWXqw==",
"3Y6/HqS1trYc9Dh778sefg==",
"3YXp1PmMldUjBz3hC6ItbA==",
"3djRJvkZk9O2bZeUTe+7xQ==",
"3go7bJ9WqH/PPUTjNP3q/Q==",
"3hVslsq98QCDIiO40JNOuA==",
"3iC21ByW/YVL+pSyppanWw==",
"3itfXtlLPRmPCSYaSvc39Q==",
"3j0kFUZ6g+yeeEljx+WXGg==",
"3jmCreW5ytSuGfmeLv7NfQ==",
"3jqsY8/xTWELmu/az3Daug==",
"3kREs/qaMX0AwFXN0LO5ow==",
"3ltw31yJuAl4VT6MieEXXw==",
"3nthUmLZ30HxQrzr2d7xFA==",
"3oMTbWf7Bv83KRlfjNWQZA==",
"3pi3aNVq1QNJmu1j0iyL0g==",
"3rbml1D0gfXnwOs5jRZ3gA==",
"3sNJJIx1NnjYcgJhjOLJOg==",
"3v09RHCPTLUztqapThYaHg==",
"3xw8+0/WU51Yz4TWIMK8mw==",
"3y5Xk65ShGvWFbQxcZaQAQ==",
"3yDD+xT8iRfUVdxcc7RxKw==",
"3yavzOJ1mM44pOSFLLszgA==",
"4+htiqjEz9oq0YcI/ErBVg==",
"40HzgVKYnqIb6NJhpSIF0A==",
"40gCrW4YWi+2lkqMSPKBPg==",
"41WEjhYUlG6jp2UPGj11eQ==",
"444F9T6Y7J67Y9sULG81qg==",
"46FCwqh+eMkf+czjhjworw==",
"46piyANQVvvLqcoMq5G8tQ==",
"49jZr/mEW6fvnyzskyN40w==",
"49z/15Nx9Og7dN9ebVqIzg==",
"4A+RHIw+aDzw0rSRYfbc7g==",
"4BkqgraeXY7yaI1FE07Evw==",
"4CfEP8TeMKX33ktwgifGgA==",
"4DIPP/yWRgRuFqVeqIyxMQ==",
"4FBBtWPvqJ3dv4w25tRHiQ==",
"4ID0PHTzIMZz2rQqDGBVfA==",
"4KJZPCE9NKTfzFxl76GWjg==",
"4LtQrahKXVtsbXrEzYU1zQ==",
"4LvQSicqsgxQFWauqlcEjw==",
"4NHQwbb3zWq2klqbT/pG6g==",
"4NP8EFFJyPcuQKnBSxzKgQ==",
"4PBaoeEwUj79njftnYYqLg==",
"4Qinl7cWmVeLJgah8bcNkw==",
"4SdHWowXgCpCDL28jEFpAw==",
"4TQkMnRsXBobbtnBmfPKnA==",
"4VR5LiXLew6Nyn91zH9L4w==",
"4WO6eT0Rh6sokb29zSJQnQ==",
"4WRdAjiUmOQg2MahsunjAg==",
"4WcFEswYU/HHQPw77DYnyA==",
"4XNUmgwxsqDYsNmPkgNQYQ==",
"4Xh/B3C16rrjbES+FM1W8g==",
"4ZFYKa7ZgvHyZLS6WpM8gA==",
"4aPU6053cfMLHgLwAZJRNg==",
"4ekt4m38G9m599xJCmhlug==",
"4erEA42TqGA9K4iFKkxMMA==",
"4ifNsmjYf1iOn2YpMfzihg==",
"4iiCq+HhC+hPMldNQMt0NA==",
"4itEKfbRCJvqlgKnyEdIOQ==",
"4jeOFKuKpCmMXUVJSh9y0g==",
"4kXlJNuT79XXf1HuuFOlHw==",
"4kj0S8XlmhHXoUP7dQItUw==",
"4mQVNv7FHj+/O6XFqWFt/Q==",
"4mig4AMLUw+T/ect9p4CfA==",
"4qMSNAxichi3ori/pR+o0w==",
"4rrSL6N0wyucuxeRELfAmw==",
"4u3eyKc+y3uRnkASrgBVUw==",
"4wnUAbPT3AHRJrPwTTEjyw==",
"4xojeUxTFmMLGm6jiMYh/Q==",
"4yEkKp2FYZ09mAhw2IcrrA==",
"4yVqq66iHYQjiTSxGgX2oA==",
"4yrFNgqWq17zVCyffULocA==",
"50jASqzGm4VyHJbFv8qVRA==",
"50xwiYvGQytEDyVgeeOnMg==",
"51yLpfEdvqXmtB6+q27/AQ==",
"520wTzrysiRi2Td92Zq0HQ==",
"53UccFNzMi9mKmdeD82vAw==",
"54XELlPm8gBvx8D5bN3aUg==",
"59ipbMH7cKBsF9bNf4PLeQ==",
"5CMadLqS2KWwwMCpzlDmLw==",
"5DDb7fFJQEb3XTc3YyOTjg==",
"5HovoyHtul8lXh+z8ywq9A==",
"5I/heFSQG/UpWGx0uhAqGQ==",
"5KOgetfZR+O2wHQSKt41BQ==",
"5LJqHFRyIwQKA4HbtqAYQQ==",
"5LuFDNKzMd2BzpWEIYO2Ww==",
"5M3dFrAOemzQ0MAbA8bI5w==",
"5N2oi2pB69NxeNt08yPLhw==",
"5NEP7Xt7ynj6xCzWzt21hQ==",
"5Nk2Z94DhlIdfG5HNgvBbQ==",
"5PfGtbH9fmVuNnq83xIIgQ==",
"5Q/Y2V0iSVTK8HE8JerEig==",
"5S5/asYfWjOwnzYpbK6JDw==",
"5SbwLDNT6sBOy6nONtUcTg==",
"5T39s5CtSrK5awMPUcEWJg==",
"5VO1inwXMvLDBQSOahT6rg==",
"5VY++KiWgo7jXSdFJsPN3A==",
"5Wcq+6hgnWsQZ/bojERpUw==",
"5Yrj6uevT8wHRyqqgnSfeg==",
"5dUry23poD+0wxZ3hH6WmA==",
"5eHStFN7wEmIE+uuRwIlPQ==",
"5eXpiczlRdmqMYSaodOUiQ==",
"5gGoDPTc/sOIDLngmlEq4A==",
"5jHgQF4SfO/zy9xy9t+9dw==",
"5jyuDp82Fux+B0+zlx8EXw==",
"5kvyy902llnYGQdn2Py04w==",
"5l6kDfjtZjkTZPJvNNOVFw==",
"5lfLJAk1L3QzGMML3fOuSw==",
"5m1ijXEW+4RTNGZsDA/rxQ==",
"5oD/aGqoakxaezq43x0Tvw==",
"5pje7qyz8BRsa8U4a4rmoA==",
"5pqqzC/YmRIMA9tMFPi7rg==",
"5r1ZsGkrzNQEpgt/gENibw==",
"5u2PdDcIY3RQgtchSGDCGg==",
"5ugVOraop5P5z5XLlYPJyQ==",
"5w/c9WkI/FA+4lOtdPxoww==",
"5w4FbRhWACP7k2WnNitiHg==",
"6+jhreeBLfw64tJ+Nhyipw==",
"600bwlyhcy754W1E6tuyYg==",
"600mjiWke4u0CDaSQKLOOg==",
"60suecbWRfexSh7C67RENA==",
"61V74uIjaSfZM8au1dxr1A==",
"62RHCbpGU8Hb+Ubn+SCTBg==",
"63OTPaKM0xCfJOy9EDto+Q==",
"64AA4jLHXc1Dp15aMaGVcA==",
"64QzHOYX0A9++FqRzZRHlQ==",
"64YsV2qeDxk2Q6WK/h7OqA==",
"65KhGKUBFQubRRIEdh9SwQ==",
"6706ncrH1OANFnaK6DUMqQ==",
"68jPYo3znYoU4uWI7FH3/g==",
"68nqDtXOuxF7DSw6muEZvg==",
"6ACvJNfryPSjGOK39ov8Qg==",
"6CjtF1S2Y6RCbhl7hMsD+g==",
"6G2bD3Y7qbGmfPqH9TqLFA==",
"6GXHGF62/+jZ7PfIBlMxZw==",
"6HGeEPyTAu9oiKhNVLjQnA==",
"6HnWgYNKohqhoa1tnjjU3A==",
"6M6QapJ5xtMXfiD3bMaiLA==",
"6NP81geiL14BeQW6TpLnUA==",
"6PzjncEw2wHZg7SP7SQk9w==",
"6QAtjOK9enNLRhcVa2iaTg==",
"6QUGE2S8oFYx4T4nW56cCw==",
"6W79FmpUN1ByNtv5IEXY4w==",
"6WhHPWlqEUqXC52rHGRHjA==",
"6XYqR2WvDzx4fWO7BIOTjA==",
"6Z9myGCF5ylWljgIYAmhqw==",
"6ZKmm7IW7IdWuVytLr68CQ==",
"6ZMs9vCzK9lsbS6eyzZlIA==",
"6b7ue29cBDsvmj1VSa5njw==",
"6c0iuya20Ys8BsvoI4iQaQ==",
"6cTETZ9iebhWl+4W5CB+YQ==",
"6dshA8knH5qqD+KmR/kdSQ==",
"6e8boFcyc8iF0/tHVje4eQ==",
"6erpZS36qZRXeZ9RN9L+kw==",
"6fWom3YoKvW6NIg6y9o9CQ==",
"6k2cuk0McTThSMW/QRHfjA==",
"6lVSzYUQ/r0ep4W2eCzFpg==",
"6leyDVmC5jglAa98NQ3+Hg==",
"6nwR+e9Qw0qp8qIwH9S/Mg==",
"6o5g9JfKLKQ2vBPqKs6kjg==",
"6rIWazDEWU5WPZHLkqznuQ==",
"6rqK8sjLPJUIp7ohkEwfZg==",
"6sBemZt4qY/TBwqk3YcLOQ==",
"6sNP0rzCCm3w976I2q2s/w==",
"6tfM6dx3R5TiVKaqYQjnCg==",
"6txm8z4/LGCH0cpaet/Hsg==",
"6uMF5i0b/xsk55DlPumT7A==",
"6uT7LZiWjLnnqnnSEW4e/Q==",
"6v3eTZtPYBfKFSjfOo2UaA==",
"6wkfN8hyKmKU6tG3YetCmw==",
"6z8CRivao3IMyV4p4gMh7g==",
"71w3aSvuh2mBLtdqJCN3wA==",
"734u4Y1R3u7UNUnD+wWUoA==",
"74FW/QYTzr/P1k6QwVHMcw==",
"778O1hdVKHLG2q9dycUS0Q==",
"78b8sDBp28zUlYPV5UTnYw==",
"79uTykH43voFC3XhHHUzKg==",
"7E6V6/zSjbtqraG7Umj+Jw==",
"7Ephy+mklG2Y3MFdqmXqlA==",
"7Eqzyb+Kep+dIahYJWNNxQ==",
"7GgNLBppgAKcgJCDSsRqOQ==",
"7J3FoFGuTIW36q0PZkgBiw==",
"7K8l6KoP0BH82/WMLntfrg==",
"7R5rFaXCxM3moIUtoCfM2g==",
"7Tauesu7bgs5lJmQROVFiQ==",
"7VHlLw20dWck+I8tCEZilA==",
"7W9aF7dxnL+E8lbS/F7brg==",
"7XRiYvytcwscemlxd9iXIQ==",
"7Y87wVJok20UfuwkGbXxLg==",
"7b0oo4+qphu6HRvJq6qkHQ==",
"7bM/pn4G7g7Zl6Xf1r62Lg==",
"7br49X11xc2GxQLSpZWjKQ==",
"7btpMFgeGkUsiTtsmNxGQA==",
"7cnUHeaPO8txZGGWHL9tKg==",
"7dz+W494zwU5sg63v5flCg==",
"7k5rBuh8FbTTI4TP87wBPQ==",
"7l0RMKbONGS/goW/M+gnMQ==",
"7mxU5fJl/c6dXss9H3vGcQ==",
"7nr3zyWL+HHtJhRrCPhYZA==",
"7p4NpnoNSQR7ISg+w+4yFg==",
"7pkUY2UzSbGnwLvyRrbxfA==",
"7sCJ4RxbxRqVnF4MBoKfuQ==",
"7w3b73nN/fIBvuLuGZDCYQ==",
"7w4PDRJxptG8HMe/ijL6cQ==",
"7wgT9WIiMVcrj48PVAMIgw==",
"7xDIG/80SnhgxAYPL9YJtg==",
"7xTKFcog69nTmMfr5qFUTA==",
"80C9TB9/XT1gGFfQDJxRoA==",
"80PCwYh4llIKAplcDvMj4g==",
"80UE+Ivby3nwplO/HA7cPw==",
"81ZH3SO0NrOO+xoR/Ngw1g==",
"81iQLU+YwxNwq4of6e9z7A==",
"81nkjWtpBhqhvOp6K8dcWg==",
"81pAhreEPxcKse+++h1qBg==",
"82hTTe1Nr4N2g7zwgGjxkw==",
"83ERX2XJV3ST4XwvN7YWCg==",
"83WGpQGWyt6mCV+emaomog==",
"83wtvSoSP9FVBsdWaiWfpA==",
"861mBNvjIkVgkBiocCUj/Q==",
"88PNi9+yn3Bp4/upgxtWGA==",
"88tB/HgUIUnqWXEX++b5Aw==",
"897ptlztTjr7yk+pk8MT0Q==",
"8AfCSZC0uasVON9Y/0P2Pw==",
"8B12CamjOGzJDnQ+RkUf4w==",
"8BLkvEkfnOizJq0OTCYGzw==",
"8CjmgWQSAAGcXX9kz3kssw==",
"8Cm19vJW8ivhFPy0oQXVNA==",
"8DtgIyYiNFqDc5qVrpFUng==",
"8GyPup4QAiolFJ9v80/Nkw==",
"8JVHFRwAd/SCLU0CRJYofg==",
"8LNNoHe6rEQyJ0ebl151Mw==",
"8M0kSvjn5KN8bjsMdUqKZQ==",
"8N3mhHt29FZDHn1P2WH1wQ==",
"8OFxXwnPmrogpNoueZlC4Q==",
"8QK7emHS6rAcAF5QQemW/A==",
"8RtLlzkGEiisy1v9Xo0sbw==",
"8VqeoQELbCs232+Mu+HblA==",
"8WU1vLKV1GhrL7oS9PpABg==",
"8ZBiwr842ZMKphlqmNngHw==",
"8ZFPMJJYVJHsfRpU4DigSg==",
"8ZqmPJDnQSOFXvNMRQYG2Q==",
"8c+lvG5sZNimvx9NKNH3ug==",
"8cXqZub6rjgJXmh1CYJBOg==",
"8dBIsHMEAk7aoArLZKDZtg==",
"8dUcSkd2qnX5lD9B+fUe+Q==",
"8dbyfox/isKLsnVjQNsEXg==",
"8fJLQeIHaTnJ8wGqUiKU6g==",
"8g08gjG/QtvAYer32xgNAg==",
"8hsfXqi4uiuL+bV1VrHqCw==",
"8iYdEleTXGM+Wc85/7vU9w==",
"8j9GVPiFdfIRm/+ho7hpoA==",
"8nOTDhFyZ8YUA4b6M5p84w==",
"8snljTGo/uICl9q0Hxy7/A==",
"8uP4HUnSodw88yoiWXOIcw==",
"8vLA9MOdmLTo3Qg+/2GzLA==",
"8vr+ERVrM99dp+IGnCWDGQ==",
"8ylI1AS3QJpAi3I/NLMYdg==",
"9+hjTVMQUsvVKs7Tmp52tg==",
"90dtIMq0ozJXezT2r79vMQ==",
"91+Yms6Oy/rP0rVjha5z9w==",
"91LQuW6bMSxl10J/UDX23A==",
"91SdBFJEZ65M+ixGaprY/A==",
"91VcAVv7YDzkC1XtluPigw==",
"91vfsZ7Lx9x5gqWTOdM4sg==",
"96ORaz1JRHY1Gk8H74+C2g==",
"99+SBN45LwKCPfrjUKRPmw==",
"9Bet5waJF5/ZvsYaHUVEjQ==",
"9DRHdyX8ECKHUoEsGuqR4Q==",
"9DtM1vls4rFTdrSnQ7uWXw==",
"9FdpxlIFu11qIPdO7WC5nw==",
"9Gkw+hvsR/tFY1cO89topg==",
"9J53kk+InE3CKa7cPyCXMw==",
"9JKIJrlQjhNSC46H3Cstcw==",
"9L6yLO93sRN70+3qq3ObfA==",
"9MDG0WeBPpjGJLEmUJgBWg==",
"9QFYrCXsGsInUb4SClS3cQ==",
"9RGIQ2qyevNbSSEF36xk/A==",
"9RXymE9kCkDvBzWGyMgIWA==",
"9SUOfKtfKmkGICJnvbIDMg==",
"9SgfpAY0UhNC6sYGus9GgQ==",
"9T7gB0ZkdWB0VpbKIXiujQ==",
"9TalxEyFgy6hFCM73hgb7Q==",
"9UhKmKtr4vMzXTEn74BEhg==",
"9W57pTzc572EvSURqwrRhw==",
"9Y1ZmfiHJd9vCiZ6KfO1xQ==",
"9aKH1u5+4lgYhhLztQ4KWA==",
"9ajIS45NTicqRANzRhDWFA==",
"9bAWYElyRN1oJ6eJwPtCtQ==",
"9cvHJmim9e0pOaoUEtiM6A==",
"9dbn0Kzwr9adCEfBJh78uQ==",
"9iB7+VwXRbi6HLkWyh9/kg==",
"9inw7xzbqAnZDKOl/MfCqA==",
"9jxA/t3TQx8dQ+FBsn/YCg==",
"9k17UqdR1HzlF7OBAjpREA==",
"9k1u/5TgPmXrsx3/NsYUhg==",
"9lLhHcrPWI4EsA4fHIIXuw==",
"9nMltdrrBmM5ESBY2FRjGA==",
"9oQ/SVNJ4Ye9lq8AaguGAQ==",
"9oUawSwUGOmb0sDn3XS6og==",
"9onh6QKp70glZk9cX3s34A==",
"9pdeedz1UZUlv8jPfPeZ1g==",
"9pk75mBzhmcdT+koHvgDlw==",
"9qWLbRLXWIBJUXYjYhY2pg==",
"9rL8nC/VbSqrvnUtH9WsxQ==",
"9reBKZ1Rp6xcdH1pFQacjw==",
"9s3ar9q32Y5A3tla5GW/2Q==",
"9sYLg75/hudZaBA3FrzKHw==",
"9tiibT8V9VwnPOErWGNT3w==",
"9vEgJVJLEfed6wJ7hBUGgQ==",
"9viAzLFGYYudBYFu7kFamg==",
"9vmJUS7WIVOlhMqwipAknQ==",
"9wUIeSgNN36SFxy8v2unVg==",
"9xIgKpZGqq0/OU6wM5ZSHw==",
"9xmtuClkFlpz/X5E9JBWBA==",
"A+DLpIlYyCb9DaarpLN76g==",
"A2ODff+ImIkreJtDPUVrlg==",
"A3dX2ShyL9+WOi6MNJBoYQ==",
"A6TLWhipfymkjPYq8kaoDQ==",
"AChOz8avRYsvxlbWcorQ3w==",
"AEpTVUQhIEJGlXJB6rS26A==",
"AFdelaqvxRj6T3YdLgCFyg==",
"AGd0rcLnQ0n+meYyJur1Pw==",
"AGoVLd0QPcXnTedT5T95JQ==",
"ALJWKUImVE40MbEooqsrng==",
"ALlGgVDO8So71ccX0D6u2g==",
"AMfL0rH+g8c0VqOUSgNzQw==",
"ARCWkHAnVgBOIkCDQ19ZuA==",
"ARKIvf4+zRF8eCvUITWPng==",
"ATmMzriwGLl+M3ppkfcZNA==",
"AUGmvZkpkKBry5bHZn4DJA==",
"AV/YJfdoDUdRcrXVwinhQg==",
"AVjwqrTBQH1VREuBlOyUOg==",
"AX1HxQKXD12Yv5HWi39aPQ==",
"AYxGETZs477n2sa1Ulu/RQ==",
"AZs3v4KJYxdi8T1gjVjI2Q==",
"AcKwfS8FRVqb72uSkDNY/Q==",
"AcbG0e6xN8pZfYAv7QJe1Q==",
"Af9j1naGtnZf0u1LyYmK1w==",
"AfVPdxD3FyfwwNrQnVNQ7A==",
"AgDJsaW0LkpGE65Kxk5+IA==",
"Ahpi9+nl13kPTdzL+jgqMw==",
"AiMtfedwGcddA+XYNc+21g==",
"AjHz9GkRTFPjrqBokCDzFw==",
"Ak3rlzEOds6ykivfg39xmw==",
"AkAes5oErTaJiGD2I4A1Pw==",
"AklOdt9/2//3ylUhWebHRw==",
"Al8+d/dlOA5BXsUc5GL8Tg==",
"Ao1Zc0h5AdSHtYt1caWZnQ==",
"AoN/pnK4KEUaGw4V9SFjpg==",
"ApiuEPWr8UjuRyJjsYZQBw==",
"AqHVaj3JcR44hnMzUPvVYg==",
"Ar1Eb/f/LtuIjXnnVPYQlA==",
"Ar9N1VYgE7riwmcrM3bA2Q==",
"AsAHrIkMgc3RRWnklY9lJw==",
"AvdeYb9XNOUFWiiz+XGfng==",
"AwPTZpC28NJQhf5fNiJuLA==",
"AxEjImKz4tMFieSo7m60Sg==",
"AyWlT+EGzIXc395zTlEU5Q==",
"B+TsxQZf0IiQrU8X9S4dsQ==",
"B0TaUQ6dKhPfSc5V/MjLEQ==",
"B1VVUbl8pU0Phyl1RYrmBg==",
"B6reUwMkQFaCHb9BYZExpw==",
"BA18GEAOOyVXO2yZt2U35w==",
"BAJ+/jbk2HyobezZyB9LiQ==",
"BB/R8oQOcoE4j63Hrh8ifg==",
"BB9PTlwKAWkExt3kKC/Wog==",
"BDNM1u/9mefjuW1YM2DuBg==",
"BDbfe/xa9Mz1lVD82ZYRGA==",
"BH+rkZWQjTp7au6vtll/CQ==",
"BL3buzSCV78rCXNEhUhuKQ==",
"BLJk9wA88z6e0IQNrWJIVw==",
"BLbTFLSb4mkxMaq4/B2khg==",
"BMOi5JmFUg5sCkbTTffXHw==",
"BMZB1FwvAuEqyrd0rZrEzw==",
"BPT4PQxeQcsZsUQl33VGmg==",
"BTiGLT6XdZIpFBc91IJY6g==",
"BV1moliPL15M14xkL+H1zw==",
"BW0A06zoQw7S+YMGaegT7g==",
"BXGlq54wIH6R3OdYfSSDRw==",
"BYpHADmEnzBsegdYTv8B5Q==",
"BYz52gYI/Z6AbYbjWefcEA==",
"BZTzHJGhzhs3mCXHDqMjnQ==",
"BaRwTrc5ulyKbW4+QqD0dw==",
"BhKO1s1O693Fjy1LItR/Jw==",
"BjfOelfc1IBgmUxMJFjlbQ==",
"BlCgDd7EYDIqnoAiKOXX6Q==",
"BophnnMszW5o+ywgb+3Qbw==",
"Bq82MoMcDjIo/exqd/6UoA==",
"BuDVDLl0OGdomEcr+73XhQ==",
"BuENxPg7JNrWXcCxBltOPg==",
"Bv4mNIC72KppYw/nHQxfpQ==",
"Bvk8NX4l6WktLcRDRKsK/A==",
"BwRA+tMtwEvth28IwpZx+w==",
"BxFP+4o6PSlGN78eSVT1pA==",
"BxsDnI8jXr4lBwDbyHaYXw==",
"Byhi4ymFqqH8uIeoMRvPug==",
"BzkNYH03gF/mQY71RwO3VA==",
"C+Ssp+v1r+00+qiTy2d7kA==",
"C4QEzQKGxyRi2rjwioHttA==",
"C65PZm8rZxJ6tTEb6d08Eg==",
"C7UaoIEXsVRxjeA0u99Qmw==",
"CBAGa5l95f3hVzNi6MPWeQ==",
"CCK+6Dr72G3WlNCzV7nmqw==",
"CDsanJz7e3r/eQe+ZYFeVQ==",
"CF1sAlhjDQY/KWOBnSSveA==",
"CHLHizLruvCrVi9chj9sXA==",
"CHsFJfsvZkPWDXkA6ZMsDQ==",
"CJoZn5wdTXbhrWO5LkiW0g==",
"CLPzjXKGGpJ0VrkSJp7wPQ==",
"CPDs+We/1wvsGdaiqxzeCQ==",
"CQ0PPwgdG3N6Ohfwx1C8xA==",
"CQpJFrpOvcQhsTXIlJli+Q==",
"CRiL6zpjfznhGXhCIbz8pQ==",
"CRmAj3JcasAb4iZ9ZbNIbw==",
"CT3ldhWpS1SEEmPtjejR/Q==",
"CT9g8mKsIN/VeHLSTFJcNQ==",
"CUCjG2UaEBmiYWQc6+AS1Q==",
"CUEueo8QXRxkfVdfNIk/gg==",
"CWBGcRFYwZ0va6115vV/oQ==",
"CX/N/lHckmAtHKysYtGdZA==",
"CXMKIdGvm60bgfsNc+Imvg==",
"CYJB3qy5GalPLAv1KGFEZA==",
"CZNoTy26VUQirvYxSPc/5A==",
"CZbd+UoTz0Qu1kkCS3k8Xg==",
"CazLJMJjQMeHhYLwXW7YNg==",
"Ci7sS7Yi1+IwAM3VMAB4ew==",
"CiiUeJ0LeWfm7+gmEmYXtg==",
"CkDIoAFLlIRXra78bxT/ZA==",
"CkZUmKBAGu0FLpgPDrybpw==",
"Cl1u5nGyXaoGyDmNdt38Bw==",
"CmBf5qchS1V3C2mS6Rl4bw==",
"CmVD6nh8b/04/6JV9SovlA==",
"CmkmWcMK4eqPBcRbdnQvhw==",
"CnIwpRVC2URVfoiymnsdYQ==",
"CoLvjQDQGldGDqRxfQo+WQ==",
"CrJDgdfzOea2M2hVedTrIg==",
"CsPkyTZADMnKcgSuNu1qxg==",
"CtDj/h2Q/lRey20G8dzSgA==",
"CuGIxWhRLN7AalafBZLCKQ==",
"Cv079ZF55RnbsDT27MOQIA==",
"Cz1G77hsDtAjpe0WzEgQog==",
"CzP13PM/mNpJcJg8JD3s6w==",
"CzSumIcYrZlxOUwUnLR2Zw==",
"CzWhuxwYbNB/Ffj/uSCtbw==",
"D09afzGpwCEH0EgZUSmIZA==",
"D0Qt9sRlMaPnOv1xaq+XUg==",
"D0W5F7gKMljoG5rlue1jrg==",
"D175i+2bZ7aWa4quSSkQpA==",
"D2JcY4zWwqaCKebLM8lPiQ==",
"D31ZticrjGWAO45l5hFh7A==",
"D5ibbo8UJMfFZ48RffuhgQ==",
"D5jaV+HtXkSpSxJPmaBDXg==",
"D66Suu3tWBD+eurBpPXfjA==",
"D7piVoB2NJlBxK5owyo4+g==",
"D7wN7b5u5PKkMaLJBP9Ksw==",
"DA+3fjr7mgpwf6BZcExj0w==",
"DB706G73NpBSRS8TKQOVZw==",
"DBKrdpCE0awppxST4o/zzg==",
"DCjgaGV5hgSVtFY5tcwkuA==",
"DCvI9byhw0wOFwF1uP6xIQ==",
"DDitrRSvovaiXe2nfAtp4g==",
"DEaZD/8aWV6+zkiLSVN/gA==",
"DG2Qe2DqPs5MkZPOqX363Q==",
"DJ+a37tCaGF5OgUhG+T0NA==",
"DJmrmNRKARzsTCKSMLmcNA==",
"DJoy1NSZZw87oxWGlNHhfg==",
"DJscTYNFPyPmTb57g/1w+Q==",
"DKApp/alXiaPSRNm3MfSuA==",
"DLzHkTjjuH6LpWHo2ITD0Q==",
"DMHmyn2U2n+UXxkqdvKpnA==",
"DO1/jfP/xBI9N0RJNqB2Rw==",
"DQJRsUwO1fOuGlkgJavcwQ==",
"DQQB/l55iPN9XcySieNX3A==",
"DQeib845UqBMEl96sqsaSg==",
"DQlZWBgdTCoYB1tJrNS5YQ==",
"DRiFNojs7wM8sfkWcmLnhQ==",
"DWKsPfKDAtfuwgmc2dKUNg==",
"DY0IolKTYlW+jbKLPAlYjQ==",
"DYWCPUq/hpjr6puBE7KBHg==",
"DbWQI3H2tcJsVJThszfHGA==",
"DdaT4JLC7U0EkF50LzIj9w==",
"DdiNGiOSoIZxrMrGNvqkXw==",
"DinJuuBX9OKsK5fUtcaTcQ==",
"DjHszpS8Dgocv3oQkW/VZQ==",
"DjeSrUoWW2QAZOAybeLGJg==",
"Dk0L/lQizPEb3Qud6VHb1Q==",
"DmxgZsQg+Qy1GP0fPkW3VA==",
"Dmyb+a7/QFsU4d2cVQsxDw==",
"DnF6TYSJxlc+cwdfevLYng==",
"Do3aqbRKtmlQI2fXtSZfxQ==",
"DoiItHSms0B9gYmunVbRkQ==",
"DqzWt1gfyu/e7RQl5zWnuQ==",
"Dt6hvhPJu94CJpiyJ5uUkg==",
"Dt8Q5ORzTmpPR2Wdk0k+Aw==",
"DuEKxykezAvyaFO2/5ZmKQ==",
"Dulw855DfgIwiK7hr3X8vg==",
"Duz/8Ebbd0w6oHwOs0Wnwg==",
"DwOTyyCoUfaSShHZx9u6xg==",
"DwP0MQf71VsqvAbAMtC3QQ==",
"DwrNdmU5VFFf3TwCCcptPA==",
"Dz90OhYEjpaJ/pxwg1Qxhg==",
"E+02smwQGBIxv42LIF2Y4Q==",
"E1CvxFbuu9AYW604mnpGTw==",
"E2LR1aZ3DcdCBuVT7BhReA==",
"E2v8Kk60qVpQ232YzjS2ow==",
"E3jMjAgXwvwR8PA53g4+PQ==",
"E4NtzxQruLcetC23zKVIng==",
"E4ojRDwGsIiyuxBuXHsKBA==",
"E8yMPK7W0SIGTK6gIqhxiQ==",
"E9IlDyULLdeaVUzN6eky8g==",
"E9ajQQMe02gyUiW3YLjO/A==",
"E9yeifEZtpqlD0N3pomnGw==",
"EATnlYm0p3h04cLAL95JgA==",
"EC0+iUdSZvmIEzipXgj7Gg==",
"EGLOaMe6Nvzs/cmb7pNpbg==",
"EJgedRYsZPc4cT9rlwaZhg==",
"EKU3OVlT4b/8j3MTBqpMNg==",
"ENFfP93LA257G6pXQkmIdg==",
"EUXQZwLgnDG+C8qxVoBNdw==",
"EXveRXjzsjh8zbbQY2pM9g==",
"EZVQGsXTZvht1qedRLF8bQ==",
"EbGG4X18upaiVQmPfwKytg==",
"EdvIAKdRAXj7e42mMlFOGQ==",
"Ee4A3lTMLQ7iDQ7b8QP8Qg==",
"EfXDc6h69aBPE6qsB+6+Ig==",
"Egs14xVbRWjfBBX7X5Z60g==",
"Ej7W3+67kCIng3yulXGpRQ==",
"ElTNyMR4Rg8ApKrPw88WPg==",
"Epm0d/DvXkOFeM4hoPCBrg==",
"EqMlrz1to7HG4GIFTPaehQ==",
"EqYq2aVOrdX5r7hBqUJP7g==",
"Err1mbWJud80JNsDEmXcYg==",
"EuGWtIbyKToOe6DN3NkVpQ==",
"Ev/xjTi7akYBI7IeZJ4Igw==",
"EvSB+rCggob2RBeXyDQRvQ==",
"Ex3x5HeDPhgO2S9jjCFy4g==",
"EyIsYQxgFa4huyo/Lomv7g==",
"EzjbinBHx3Wr08eXpH3HXA==",
"F50iXjRo1aSTr37GQQXuJA==",
"F58ktE4O0f7C9HdsXYm+lw==",
"F5FcNti7lUa9DyF2iEpBug==",
"F5bs0GGWBx9eBwcJJpXbqg==",
"F8l+Qd9TZgzV+r8G584lKA==",
"F8tEIT5EhcvLNRU5f0zlXQ==",
"FA+nK6mpFWdD0kLFcEdhxA==",
"FAXzjjIr8l1nsQFPpgxM/g==",
"FCLQocqxxhJeleARZ6kSPg==",
"FH5Z60RXXUiDk+dSZBxD3g==",
"FHvI0IVNvih8tC7JgzvCOw==",
"FI2WhaSMb3guFLe3e9il8Q==",
"FIOCTEbzb2+KMCnEdJ7jZw==",
"FL/j3GJBuXdAo54JYiWklQ==",
"FLvED9nB9FEl9LqPn7OOrA==",
"FN7oLGBQGHXXn5dLnr/ElA==",
"FNvQqYoe0s/SogpAB7Hr1Q==",
"FUQySDFodnRhr+NUsWt0KA==",
"FV/D5uSco+Iz8L+5t7E8SA==",
"FWphIPZMumqnXr1glnbK4w==",
"FXzaxi3nAXBc8WZfFElQeA==",
"FbxScyuRacAQkdQ034ShTA==",
"FcFcn4qmPse5mJCX5yNlsA==",
"FcKjlHKfQAGoovtpf+DxWQ==",
"Fd0c8f2eykUp9GYhqOcKoA==",
"Fd2fYFs8vtjws2kx1gf6Rw==",
"FeRovookFQIsXmHXUJhGOw==",
"FhthAO5IkMyW4dFwpFS7RA==",
"Fiy3hkcGZQjNKSQP9vRqyA==",
"FltEN+7NKvzt+XAktHpfHA==",
"FnVNxl5AFH1AieYru2ZG+A==",
"FoJZ61VrU8i084pAuoWhDQ==",
"FpWDTLTDmkUhH/Sgo+g1Gg==",
"FpgdsQ2OG+bVEy3AeuLXFQ==",
"FqWLkhWl0iiD/u2cp+XK9A==",
"FrTgaF5YZCNkyfR1kVzTLQ==",
"Ft2wXUokFdUf6d2Y/lwriw==",
"FtxpWdhEmC6MT61qQv4DGA==",
"FuWspiqu5g8Eeli5Az+BkA==",
"FxnbKnuDct4OWcnFMT/a5w==",
"Fz8EI+ZpYlbcttSHs5PfpA==",
"FzqIpOcTsckSNHExrl+9jg==",
"Fzuq+Wg7clo6DTujNrxsSA==",
"G+sGF13VXPH4Ih6XgFEXxg==",
"G/PA+kt0N+jXDVKjR/054A==",
"G0LChrb0OE5YFqsfTpIL1Q==",
"G0MlFNCbRjXk4ekcPO/chQ==",
"G2UponGde3/Z+9b2m9abpQ==",
"G37U8XTFyshfCs7qzFxATg==",
"G3PmmPGHaWHpPW30xQgm3Q==",
"G4qzBI1sFP2faN+tlRL/Bw==",
"G736AX070whraDxChqUrqw==",
"G7J/za99BFbAZH+Q+/B8WA==",
"G8LFBop8u6IIng+gQuVg3w==",
"GA8k6GQ20DGduVoC+gieRA==",
"GCYI9Dn1h3gOuueKc7pdKA==",
"GDMqfhPQN0PxfJPnK1Bb9A==",
"GF0lY77rx1NQzAsZpFtXIQ==",
"GF2yvI9UWf1WY7V7HXmKPA==",
"GFRJoPcXlkKSvJRuBOAYHQ==",
"GG8a3BlwGrYIwZH9j3cnPA==",
"GHEdXgGWOeOa6RuPMF0xXg==",
"GIHKW6plyLra0BmMOurFgA==",
"GKzs8mlnQQc58CyOBTlfIg==",
"GLDNTSwygNBmuFwCIm7HtA==",
"GLmWLXURlUOJ+PMjpWEXVA==",
"GLnS9wDCje7TOMvBX9jJVA==",
"GNak/LFeoHWlTdLW1iU4eg==",
"GNrMvNXQkW7PydlyJa+f1w==",
"GQJxu1SoMBH14KPV/G/KrQ==",
"GSWncBq4nwomZCBoxCULww==",
"GT6WUDXiheKAM7tPg3he9A==",
"GTNttXfMniNhrbhn92Aykg==",
"GUiinC3vgBjbQC2ybMrMNQ==",
"GW1Uaq622QamiiF24QUA0g==",
"GWwJ32SZqD5wldrXUdNTLA==",
"GdTanUprpE3X/YjJDPpkhQ==",
"Gdf4VEDLBrKJNQ8qzDsIyw==",
"GglPoW5fvr4JSM3Zv99oiA==",
"GhpJfRSWZigLg/azTssyVA==",
"Ghuj9hAyfehmYgebBktfgA==",
"GmC+0rNDMIR+YbUudoNUXw==",
"GnJKlRzmgKN9vWyGfMq3aA==",
"GncGQgmWpI/fZyb/6zaFCg==",
"GrSbnecYAC3j5gtoKntL0A==",
"Gt4/MMrLBErhbFjGbiNqQQ==",
"GzbeM7snhe+M+J7X+gAsQw==",
"H+NHjk/GJDh/GaNzMQSzjg==",
"H+yPRiooEh5J7lAJB4RZ7Q==",
"H0UMAUfHFQH92A2AXRCBKA==",
"H1NJEI+fvOQbI51kaNQQjQ==",
"H1y2iXVaQYwP0SakN6sa+Q==",
"H1zH9I8RwfEy5DGz3z+dHw==",
"H6HPFAcdHFbQUNrYnB74dA==",
"H6j2nPbBaxHecXruxiWYkA==",
"HBRzLacCVYfwUVGzrefZYg==",
"HCbHUfsTDl6+bxPjT57lrA==",
"HCu4ZMrcLMZbPXbTlWuvvQ==",
"HDxGhvdQwGh0aLRYEGFqnw==",
"HEcOaEd9zCoOVbEmroSvJg==",
"HEghmKg3GN60K7otpeNhaA==",
"HFCQEiZf7/SNc+oNSkkwlA==",
"HFHMGgfOeO0UPrray1G+Zw==",
"HGxe+5/kkh6R9GXzEOOFHA==",
"HHxn4iIQ7m0tF1rSd+BZBg==",
"HI4ZIE5s8ez8Rb+Mv39FxA==",
"HITIVoFoWNg04NExe13dNA==",
"HJYgUxFZ66fRT8Ka73RaUg==",
"HK0yf7F97bkf1VYCrEFoWA==",
"HK9xG03FjgCy8vSR+hx8+Q==",
"HLesnV3DL+FhWF3h6RXe8g==",
"HLxROy6fx/mLXFTDSX4eLA==",
"HMQarkPWOUDIg5+5ja2dBQ==",
"HMWOlMmzocOIiJ7yG1YaDQ==",
"HOi+vsGAae4vhr+lJ5ATnQ==",
"HPvYV94ufwiNHEImu4OYvQ==",
"HRF3WL/ue3/QlYyu7NUTrA==",
"HRWYX2XOdsOqYzCcqkwIyw==",
"HYylUirJRqLm+dkp39fSOQ==",
"HaHTsLzx7V3G1SFknXpGxA==",
"HaIRV9SNPRTPDOSX9sK/bg==",
"HaSc7MZphCMysTy2JbTJkw==",
"Hb+pdSavvJ9lUXkSVZW8Og==",
"HbT6W1Ssd3W7ApKzrmsbcg==",
"HbXv8InyZqFT7i3VrllBgg==",
"HdB7Se47cWjPgpJN0pZuiA==",
"HdXg64DBy5WcL5fRRiUVOg==",
"HeQbUuBM9sqfXFXRBDISSw==",
"HfvsiCQN/3mT0FabCU5ygQ==",
"HgIFX42oUdRPu7sKAXhNWg==",
"HhBHt5lQauNl7EZXpsDHJA==",
"HiAgt86AyznvbI2pnLalVQ==",
"HjlPM2FQWdILUXHalIhQ5w==",
"HjyxyL0db2hGDq2ZjwOOhg==",
"HkbdaMuDTPBDnt3wAn5RpQ==",
"Hm6MG6BXbAGURVJKWRM6ZA==",
"HnVfyqgJ+1xSsN4deTXcIA==",
"HoaBBw2aPCyhh0f5GxF+/Q==",
"Hs3vUOOs2TWQdQZHs+FaQQ==",
"Hst3yfyTB7yBUinvVzYROQ==",
"HtDXgMuF8PJ1haWk88S0Ew==",
"HuDuxs2KiGqmeyY1s1PjpQ==",
"HwLSUie8bzH+pOJT3XQFyg==",
"HxEU37uBMeiR5y8q/pM42g==",
"Hy1nqC40l5ItxumkIC2LAA==",
"I+wVQA+jpPTJ6xEsAlYucg==",
"I07W2eDQwe6DVsm1zHKM8A==",
"I5qDndyelK4Njv4YrX7S6w==",
"I9KNZC1tijiG1T72C4cVqQ==",
"IA1jmtfpYkz/E2wD0+27WA==",
"IADk81pIu8NIL/+9Fi94pA==",
"IAMInfSYb76GxDlAr1dsTg==",
"ICPdBCdONUqPwD5BXU5lrw==",
"IEz72W2/W8xBx5aCobUFOQ==",
"IHhyR6+5sZXTH+/NrghIPg==",
"IHyIeMad23fSDisblwyfpA==",
"IKgNa2oPaFVGYnOsL+GC5Q==",
"INNBBin5ePwTyhPIyndHHg==",
"IPLD9nT5EEYG9ioaSIYuuA==",
"ITYL3tDwddEdWSD6J6ULaA==",
"ITZ3P47ALS0JguFms6/cDA==",
"IUZ5aGpkJ9rLgSg6oAmMlw==",
"IUwVHH6+8/0c+nOrjclOWA==",
"IWZnTJ3Hb9qw9HAK/M9gTw==",
"IYIP2UBRyWetVfYLRsi1SQ==",
"IYIbEaErHoFBn8sTT9ICIQ==",
"IbN736G1Px5bsYqE5gW1JQ==",
"IdadoCPmSgHDHzn1zyf8Jw==",
"IdmcpJXyVDajzeiGZixhSA==",
"IhHyHbHGyQS+VawxteLP0w==",
"IhpXs1TK7itQ3uTzZPRP5Q==",
"IindlAnepkazs5DssBCPhA==",
"IjmLaf3stWDAwvjzNbJpQA==",
"Ily2MKoFI1zr5LxBy93EmQ==",
"Iqszlv4R49UevjGxIPMhIA==",
"IrDuBrVu1HWm0BthAHyOLQ==",
"Is3uxoSNqoIo5I15z6Z2UQ==",
"IshzWega6zr3979khNVFQQ==",
"It+K/RCYMOfNrDZxo7lbcA==",
"IwLbkL33z+LdTjaFYh93kg==",
"IwfeA6d0cT4nDTCCRhK+pA==",
"J/PNYu4y6ZMWFFXsAhaoow==",
"J/eAtAPswMELIj8K2ai+Xg==",
"J0NauydfKsACUUEpMhQg8A==",
"J1nYqJ7tIQK1+a/3sMXI/Q==",
"J2NFyb8cXEpZyxWDthYQiA==",
"J4MC9He6oqjOWsYQh9nl3Q==",
"J8v2f6hWFu8oLuwhOeoQjA==",
"JATLdpQm//SQnkyCfI5x7Q==",
"JBkbaBiorCtFq9M9lSUdMg==",
"JC8Q+8yOJ52NvtVeyHo68w==",
"JFFeXsFsMA59iNtZey7LAA==",
"JFHutgSe1/SlcYKIbNNYwQ==",
"JFi6N1PlrpKaYECOnI7GFg==",
"JGEy6VP3sz3LHiyT2UwNHQ==",
"JGeqHRQpf4No74aCs+YTfA==",
"JGx8sTyvr4bLREIhSqpFkw==",
"JHBjKpCgSgrNNACZW1W+1w==",
"JIC8R48jGVqro6wmG2KXIw==",
"JJJkp1TpuDx5wrua2Wml7g==",
"JJbzQ/trOeqQomsKXKwUpQ==",
"JKg64m6mU7C/CkTwVn4ASg==",
"JKmZqz9cUnj6eTsWnFaB0A==",
"JKphO0UYjFqcbPr6EeBuqg==",
"JLq/DrW2f26NaRwfpDXIEA==",
"JPxEncA4IkfBDvpjHsQzig==",
"JQf9UmutPh3tAnu7FDk3nA==",
"JSr/lqDej81xqUvd/O2s7w==",
"JSyhTcHLTfzHsPrxJyiVrA==",
"JSyq2MIuObPnEgEUDyALjQ==",
"JVSLiwurnCelNBiG2nflpQ==",
"JXCYeWjFqcdSf6QwB54G+A==",
"JYJvOZ4CHktLrYJyAbdOnA==",
"JZRjdJLgZ+S0ieWVDj8IJg==",
"Ja3ECL7ClwDrWMTdcSQ6Ug==",
"JaYQXntiyznQzrTlEeZMIw==",
"Jbxl8Nw1vlHO9rtu0q/Fpg==",
"Jcxjli2tcIAjCe+5LyvqdQ==",
"Je1UESovkBa9T6wS0hevLw==",
"JgXSPXDqaS1G9NqmJXZG0A==",
"JgxNrUlL8wutG04ogKFPvw==",
"JipruVZx4ban3Zo5nNM37g==",
"Jit0X0srSNFnn8Ymi1EY+g==",
"Jj4IrSVpqQnhFrzNvylSzA==",
"Jm862vBTCYbv/V4T1t46+Q==",
"JnE6BK0vpWIhNkaeaYNUzw==",
"JoATsk/aJH0UcDchFMksWA==",
"JquDByOmaQEpFb47ZJ4+JA==",
"JrKGKAKdjfAaYeQH8Y2ZRQ==",
"Js7g8Dr6XsnGURA4UNF0Ug==",
"Jt4Eg6MJn8O4Ph/K2LeSUA==",
"Ju4YwtPw+MKzpbC0wJsZow==",
"JvXTdChcE3AqMbFYTT3/wg==",
"JyIDGL1m/w+pQDOyyeYupA==",
"JyUJEnU6hJu8x2NCnGrYFw==",
"JzW+yhrjXW1ivKu3mUXPXg==",
"K1CGbMfhlhIuS0YHLG30PQ==",
"K1RL+tLjICBvMupe7QppIQ==",
"K1RgR6HR5uDEQgZ32TAFgA==",
"K2gk9zWGd0lJFRMQ1AjQ/Q==",
"K3NBEG8jJTJbSrYSOC3FKw==",
"K4VS+DDkTdBblG93l2eNkA==",
"K4yZNVoqHjXNhrZzz2gTew==",
"K5lhaAIZkGeP5rH2ebSJFw==",
"K8PVQhEJCEH1ghwOdztjRw==",
"K9A87aMlJC8XB9LuFM913g==",
"KCJJfgLe00+tjSfP6EBcUg==",
"KGI/cXVz6v6CfL8H6akcUQ==",
"KI7tQFYW38zYHOzkKp9/lQ==",
"KO2XVYyNZadcQv8aCNn5JA==",
"KOm8PTa+ICgDrgK9QxCJZw==",
"KOmdvm+wJuZ/nT/o1+xOuw==",
"KPh6TwYpspne4KZA6NyMbw==",
"KQw25X4LnQ9is+qdqfxo0w==",
"KR401XBdgCrtVDSaXqPEiA==",
"KSorNz/PLR/YYkxaj1fuqw==",
"KSumhnbKxMXQDkZIpDSWmQ==",
"KTjwL+qswa+Bid8xLdjMTg==",
"KXuFON8tMBizNkCC48ICLA==",
"KXvdjZ3rRKn60djPTCENGA==",
"KYuUNrkTvjUWQovw9dNakA==",
"Kh/J1NpDBGoyDU+Mrnnxkg==",
"KhUT2buOXavGCpcDOcbOYg==",
"KhrIIHfqXl9zGE9aGrkRVg==",
"Kj1QI+s9261S3lTtPKd9eg==",
"KjfL7YyVqmCJGBGDFdJ0gw==",
"KjnL3x+56r3M2pDj1pPihA==",
"KkXlgPJPen6HLxbNn5llBw==",
"KkwQL0DeUM3nPFfHb2ej+A==",
"KlY5TGg0pR/57TVX+ik1KQ==",
"KmcGEE0pacQ/HDUgjlt7Pg==",
"KodYHHN62zESrXUye7M01g==",
"Koiog/hpN7ew5kgJbty34A==",
"Kt6BTG1zdeBZ3nlVk+BZKQ==",
"KuNY8qAJBce+yUIluW8AYw==",
"KujFdhhgB9q4oJfjYMSsLg==",
"KyLQxi5UP+qOiyZl0PoHNQ==",
"KzWdWPP2gH0DoMYV4ndJRg==",
"Kzs+/IZJO8v4uIv9mlyJ2Q==",
"L+N/6geuokiLPPSDXM9Qkg==",
"L2D7G0btrwxl9V4dP3XM5Q==",
"L2IeUnATZHqOPcrnW2APbA==",
"L2RofFWDO0fVgSz4D2mtdw==",
"L3Jt5dHQpWQk74IAuDOL8g==",
"L4+C6I7ausPl6JbIbmozAg==",
"LATQEY7f47i77M6p11wjWA==",
"LCj4hI520tA685Sscq6uLw==",
"LCvz/h9hbouXCmdWDPGWqg==",
"LDuBcL5r3PUuzKKZ9x6Kfw==",
"LEVYAE54618FrlXkDN01Kw==",
"LFcpCtnSnsCPD2gT/RA+Zg==",
"LGwcvetzQ3QqKjNh5vA8vw==",
"LHQETSI5zsejvDaPpsO29g==",
"LJeLdqmriyAQp+QjZGFkdQ==",
"LJtRcR70ug6UHiuqbT6NGw==",
"LKyOFgUKKGUU/PxpFYMILw==",
"LMCZqd3UoF/kHHwzTdj7Tw==",
"LMEtzh0+J27+4zORfcjITw==",
"LPYFDbTEp5nGtG6uO8epSw==",
"LQttmX92SI94+hDNVd8Gtw==",
"LSN9GmT6LUHlCAMFqpuPIA==",
"LUWxfy4lfgB5wUrqCOUisw==",
"LWWfRqgtph1XrpxF4N64TA==",
"LWd0+N3M94n81qd346LfJQ==",
"LZAKplVoNjeQgfaHqkyEJA==",
"La0gzdbDyXUq6YAXeKPuJA==",
"LawT9ZygiVtBk0XJ+KkQgQ==",
"LbPp1oL0t3K2BAlIN+l8DA==",
"LblwOqNiciHmt2NXjd89tg==",
"LcF0OqPWrcpHby8RwXz1Yg==",
"LcoJBEPTlSsQwfuoKQUxEw==",
"LhqRc9oewY4XaaXTcnXIHQ==",
"Lo1xTCEWSxVuIGEbBEkVxA==",
"LoUv/f2lcWpjftzpdivMww==",
"LpoayYsTO8WLFLCSh2kf2w==",
"Lqel4GdU0ZkfoJVXI5WC/Q==",
"LqgzKxbI6WTMz0AMIDJR5w==",
"LsmsPokAwWNCuC74MaqFCQ==",
"Lt/pVD4TFRoiikmgAxEWEw==",
"Lu02ic/E94s42A14m7NGCA==",
"LyPXOoOPMieqINtX8C9Zag==",
"LyYPOZKm8bBegMr5NTSBfg==",
"M/cQja3uIk1im9++brbBOA==",
"M0ESOGwJ4WZ4Ons1ljP0bQ==",
"M20iX2sUfw5SXaZLZYlTaA==",
"M2JMnViESVHTZaru6LDM6w==",
"M2suCoFHJ5fh9oKEpUG3xA==",
"M55eersiJuN9v61r8DoAjQ==",
"M98hjSxCwvZ27aBaJTGozQ==",
"M9oqlPb63e0kZE0zWOm+JQ==",
"MArbGuIAGnw4+fw6mZIxaw==",
"MBjMU/17AXBK0tqyARZP5w==",
"MFeXfNZy6Q9wBfZmPQy3xg==",
"MI+HSMRh8KTW+Afiaxd/Fw==",
"MJ1FuK8PXcmnBAG9meU84A==",
"MK7AqlJIGqK2+K5mCvMXRQ==",
"ML7ipnY/g8mA1PUIju1j8Q==",
"MLHt6Ak288G0RGhCVaOeqA==",
"MLlVniZ08FHAS5xe+ZKRaA==",
"MMaegl2Md9s/wOx5o9564w==",
"MN94B0r5CNAF9sl3Kccdbw==",
"MOrAbuJTyGKPC6MgYJlx5Q==",
"MQYM3BT77i35LG9HcqxY2Q==",
"MQvAr+OOfnYnr/Il/2Ubkg==",
"MUkRa/PjeWMhbCTq43g6Aw==",
"MVoxyIA+emaulH8Oks8Weg==",
"MWcV03ULc0vSt/pFPYPvFA==",
"MbI04HlTGCoc/6WDejwtaQ==",
"MdvhC1cuXqni/0mtQlSOCw==",
"MeKXnEfxeuQu9t3r/qWvcw==",
"MfkyURTBfkNZwB+wZKjP4g==",
"Mj87ajJ/yR41XwAbFzJbcA==",
"Ml3mi1lGS1IspHp3dYYClg==",
"MlKWxeEh8404vXenBLq4bw==",
"MlOOZOwcRGIkifaktEq0aQ==",
"MnStiFQAr3QlaRZ02SYGaQ==",
"Mofqu40zMRrlcGRLS42eBw==",
"MpAwWMt7bcs4eL7hCSLudQ==",
"MqqDg9Iyt4k3vYVW5F+LDw==",
"Mr5mCtC53+wwmwujOU/fWw==",
"MrbEUlTagbesBNg0OemHpw==",
"MrxR3cJaDHp0t3jQNThEyg==",
"MsCloSmTFoBpm7XWYb+ueQ==",
"Muf2Eafcf9G3U2ZvQ9OgtQ==",
"MvMbvZNKbXFe2XdN+HtnpQ==",
"N+K1ibXAOyMWdfYctNDSZQ==",
"N/HgDydvaXuJvTCBhG/KtA==",
"N2KovXW14hN/6+iWa1Yv3g==",
"N2X7KWekNN+fMmwyXgKD5w==",
"N3YDSkBUqSmrmNvZZx4a1Q==",
"N4/mQFyhDpPzmihjFJJn6w==",
"N65PqIWiQeS082D6qpfrAg==",
"N7fHwb397tuQHtBz1P80ZQ==",
"N8dXCawxSBX40fgRRSDqlQ==",
"N9nD7BGEM7LDwWIMDB+rEQ==",
"NBmB/cQfS+ipERd7j9+oVg==",
"ND2hYtAIQGMxBF7o7+u7nQ==",
"ND9l4JWcncRaSLATsq0LVw==",
"NDZWIhhixq7NT8baJUR4VQ==",
"NGApiVkDSwzO45GT57GDQw==",
"NKGY0ANVZ0gnUtzVx1pKSw==",
"NKRzJndo2uXNiNppVnqy1g==",
"NMbAjbnuK7EkVeY3CQI5VA==",
"NN/ymVQNa17JOTGr6ki3eQ==",
"NOmu8oZc6CcKLu+Wfz2YOQ==",
"NQVQfN3nIg9ipHiFh4BvfQ==",
"NRyFx6jqO/oo9ojvbYzsAg==",
"NSrzwNlB0bde3ph8k6ZQcQ==",
"NZtcY8fIpSKPso/KA6ZfzA==",
"Nc5kiwXCAyjpzt43G5RF1A==",
"NdULoUDGhIolzw1PyYKV0A==",
"NdVyHoTbBhX6Umz/9vbi0g==",
"Ndx5LDiVyyTz/Fh3oBTgvA==",
"Nf9fbRHm844KZ2sqUjNgkA==",
"NfxVYc3RNWZwzh2RmfXpiA==",
"Ng5v/B9Z10TTfsDFQ/XrXQ==",
"NhZbSq0CjDNOAIvBHBM9zA==",
"NiQ/m4DZXUbpca9aZdzWAw==",
"NiawWuMBDo0Q3P2xK/vnLQ==",
"NjeDgQ1nzH1XGRnLNqCmSg==",
"NmQrsmb8PVP05qnSulPe5Q==",
"NmWmDxwK5FpKlZbo0Rt8RA==",
"NoX8lkY+kd2GPuGjp+s0tQ==",
"NquRbPn8fFQhBrUCQeRRoQ==",
"Nr4zGo5VUrjXbI8Lr4YVWQ==",
"Nsd+DfRX6L54xs+iWeMjCQ==",
"NtwqUO3SKZE/9MXLbTJo/g==",
"NuBYjwlxadAH+vLWYRZ3bg==",
"NvkR0inSzAdetpI4SOXGhw==",
"NvurnIHin4O+wNP7MnrZ1w==",
"NxSdT2+MUkQN49pyNO2bJw==",
"NyF+4VRog7etp90B9FuEjA==",
"O/EizzJSuFY8MpusBRn7Tg==",
"O1ckWUwuhD44MswpaD6/rw==",
"O209ftgvu0vSr0UZywRFXA==",
"O538ibsrI4gkE5tfwjxjmg==",
"O5N2yd+QQggPBinQ+zIhtQ==",
"O7JiE0bbp583G6ZWRGBcfw==",
"O839JUrR+JS30/nOp428QA==",
"OChiB4BzcRE8Qxilu6TgJg==",
"OEJ40VmMDYzc2ESEMontRA==",
"OERGn45uzfDfglzFFn6JAg==",
"OFLn4wun6lq484I7f6yEwg==",
"OGpsXRHlaN8BvZftxh1e7A==",
"OHJBT2SEv5b5NxBpiAf7oQ==",
"OIwtfdq37eQ0qoXuB2j7Hw==",
"OMO4pqzfcbQ11YO4nkTXfg==",
"OONAvFS/kmH7+vPhAGTNSg==",
"OOS6wQCJsXH8CsWEidB35A==",
"OVHqwV8oQMC5KSMzd5VemA==",
"OaNpzwshdHUZMphQXa6i8w==",
"Oc3BqTF3ZBW3xE0QsnFn/A==",
"OlpA9HsF8MBh7b45WZSSlg==",
"OlwHO6Sg2zIwsCOCRu0HiQ==",
"Omi2ZB9kdR1HrVP2nueQkA==",
"Omr+zPWVucPCSfkgOzLmSQ==",
"OnmvXbyT2BYsSDJYZhLScA==",
"OpC/sL320wl5anx6AVEL+A==",
"OpL+vHwPasW30s2E1TYgpA==",
"OrqJKjRndcZ8OjE3cSQv7g==",
"Otz/PgYOEZ1CQDW54FWJIQ==",
"OwArFF1hpdBupCkanpwT+Q==",
"OwIGvTh8FPFqa4ijNkguAw==",
"Owg8qCpjZa+PmbhZew6/sw==",
"OzFRv+PzPqTNmOnvZGoo5g==",
"OzH7jTcyeM7RPVFtBdakpQ==",
"OzMR5D2LriC5yrVd5hchnA==",
"P0Pc8owrqt6spdf7FgBFSw==",
"P14k+fyz0TG9yIPdojp52w==",
"P3y5MoXrkRTSLhCdLlnc4A==",
"P430CeF2MDkuq11YdjvV8A==",
"P5WPQc5NOaK7WQiRtFabkw==",
"P5fucOJhtcRIoElFJS4ffg==",
"P5wS+xB8srW4a5KDp/JVkA==",
"P7eMlOz9YUcJO+pJy0Kpkw==",
"P8lUiLFoL100c9YSQWYqDA==",
"PAlx9+U+yQCAc5Fi0BOG0w==",
"PBULPuFXb6V3Di713n3Gug==",
"PCOGl7GIqbizAKj/sZmlwQ==",
"PD+yHtJxZJ2XEvjIPIJHsQ==",
"PF0lpolQQXlpc3qTLMBk8w==",
"PHwJ5ZAqqftZ4ypr8H1qiQ==",
"PKtXc4x4DEjM45dnmPWzyg==",
"PMCWKgog/G+GFZcIruSONw==",
"PMvG4NqJP76kMRAup6TSZA==",
"PPa7BDMpRdxJdBxkuWCxKA==",
"PTAm/jGkie7OlgVOvPKpaA==",
"PTW+fhZq/ErxHqpM0DZwHQ==",
"PXC6ZpdMH0ATis/jGW12iA==",
"PaROi5U16Tk35p0EKX5JpA==",
"ParhxI6RtLETBSwB0vwChQ==",
"PbDVq2Iw1eeM8c2o/XYdTA==",
"PbnxuVerGwHyshkumqAARg==",
"Pc+u0MAzp4lndTz4m6oQ5w==",
"PcdBtV8pfKU0YbDpsjPgwg==",
"PcoVtZrS1x1Q+6nfm4f80w==",
"PdBgXFq5mBqNxgCiqaRnkw==",
"PeJS+mXnAA6jQ0WxybRQ8w==",
"PfkWkSbAxIt1Iso0znW0+Q==",
"PggVPQL5YKqSU/1asihcrg==",
"PibGJQNw7VHPTgqeCzGUGA==",
"Po0lhBfiMaXhl+vYh1D8gA==",
"PolhKCedOsplEcaX4hQ0YQ==",
"Pp1ZMxJ8yajdbfKM4HAQxA==",
"PqLCd/pwc+q5GkL6MB0jTg==",
"Pt3i49uweYVgWze3OjkjJA==",
"Pu9pEf+Tek3J+3jmQNqrKw==",
"Pv9FWQEDLKnG/9K9EIz4Gw==",
"PwvPBc+4L73xK22S9kTrdA==",
"PxReytUUn/BbxYTFMu1r2Q==",
"PybPZhJErbRTuAafrrkb3g==",
"Q0TJZxpn3jk67L7N+YDaNA==",
"Q1pdQadt12anX1QRmU2Y/A==",
"Q3TpCE+wnmH/1h/EPWsBtQ==",
"Q4bfQslDSqU64MOQbBQEUw==",
"Q6vGRQiNwoyz7bDETGvi5g==",
"Q7Df6zGwvb4rC+EtIKfaSw==",
"Q7teXmTHAC5qBy+t7ugf0w==",
"Q8RVI/kRbKuXa8HAQD7zUA==",
"QAz7FA+jpz9GgLvwdoNTEQ==",
"QCpzCTReHxGm5lcLsgwPCA==",
"QGYFMpkv37CS2wmyp42ppg==",
"QH36wzyIhh6I56Vnx79hRA==",
"QH3lAwOYBAJ0Fd5pULAZqw==",
"QIKjir/ppRyS63BwUcHWmw==",
"QJEbr3+42P9yiAfrekKdRQ==",
"QTz21WkhpPjfK8YoBrpo+w==",
"QV0OG5bpjrjku4AzDvp9yw==",
"QVwuN66yPajcjiRnVk/V8g==",
"QWURrsEgxbJ8MWcaRmOWqw==",
"Qc+XYy2qyWJ5VVwd2PExbw==",
"Qf7JFJJuuacSzl6djUT2EQ==",
"Qg1ubGl+orphvT990e5ZPA==",
"QiozlNcQCbqXtwItWExqJQ==",
"QmSBVvdk0tqH9RAicXq2zA==",
"QmcURiMzmVeUNaYPSOtTTg==",
"QoUC9nyK1BAzoUVnBLV2zw==",
"QoqHzpHDHTwQD5UF30NruQ==",
"QozQL0DTtr+PXNKifv6l6g==",
"Qrh7OEHjp80IW+YzQwzlJg==",
"QsquNcCZL9wv7oZFqm64vQ==",
"QtD35QhE8sAccPrDnhtQmQ==",
"Qv6wWP4PpycDGxe7EZNSCw==",
"QvYZxsLdu+3nV/WhY1DsYg==",
"Qx6rVv9Xj8CBjqikWI9KFA==",
"QyyiJ5I/OZC50o89fa5EmQ==",
"R+beucURp/H5jLs4kW6wmg==",
"R/y6+JJP8rzz1KITJ4qWBw==",
"R1TCCfgltnXBvt5AiUnCtQ==",
"R2OOV18CV/YpWL1xzr/VQg==",
"R2Use39If2C0FVBP7KDerA==",
"R36O31Pj8jn0AWSuqI7X2Q==",
"R3ijnutzvK6IKV3AKHQZSA==",
"R5oOM58zdbVxFSDQnNWqeA==",
"R6Me6sSGP5xpNI8R0xGOWw==",
"R6cO8GzYfOGTIi773jtkXw==",
"R81DX/5a7DYKkS4CU+TL+w==",
"R8FxgXWKBpEVbnl41+tWEw==",
"R8ULpSNu9FcCwXZM0QedSg==",
"R906Kxp2VFVR3VD+o6Vxcw==",
"R97chlspND/sE9/HMScXjQ==",
"RAAw14BA1ws5Wu/rU7oegw==",
"RAECgYZmcF4WxcFcZ4A0Ww==",
"RBMv0IxXEO3o7MnV47Bzow==",
"RClzwwKh51rbB4ekl99EZA==",
"RDgGGxTtcPvRg/5KRRlz4w==",
"REnDNe9mGfqVGZt+GdsmjQ==",
"RHKCMAqrPjvUYt13BVcmvw==",
"RHToSGASrwEmvzjX6VPvNQ==",
"RIVYGO2smx9rmRoDVYMPXw==",
"RIZYDgXqsIdTf9o2Tp/S7g==",
"RJJqFMeiCZHdsqs72J17MQ==",
"RKVDdE1AkILTFndYWi9wFg==",
"RM5CpIiB94Sqxi462G7caA==",
"RNK9G1hfuz3ETY/RmA9+aA==",
"RNdyt6ZRGvwYG5Ws3QTuEA==",
"ROSt+NlEoiPFtpRqKtDUrQ==",
"RQOlmzHwQKFpafKPJj0D8w==",
"RQywrOLZEKw9+kG6qTzr3g==",
"RUmhye56tQu9xXs4SRJpOQ==",
"RVD3Ij6sRwwxTUDAxwELtA==",
"RWI0HfpP7643OSEZR8kxzw==",
"RYkDwwng6eeffPHxt8iD9A==",
"RZTpYKxOAH9JgF1QFGN+hw==",
"RfSwpO/ywQx4lfgeYlBr2w==",
"RgtwfY5pTolKrUGT+6Pp6g==",
"RhcqXY4OsZlVVF7ZlkTeRw==",
"RiahBXX2JbPzt8baPiP/8g==",
"RkQK9S1ezo+dFYHQP57qrw==",
"RlNPyhgYOIn28R4vKCVtYA==",
"RnOXOygwJFqrD+DlM3R5Ew==",
"RnxOYPSQdHS6fw4KkDJtrA==",
"RppDe/WGt1Ed6Vqg1+cCkQ==",
"RqYpA5AY7mKPaSxoQfI1CA==",
"RrE3B3X/SJi3CqCUlTYwaw==",
"Rrq0ak9YexLqqbSD4SSXlw==",
"Rs8deApkoosIJSfX7NXtAA==",
"RuLeQHP1wHsxhdmYMcgtrQ==",
"RvXWAFwM+mUAPW1MjPBaHA==",
"Rvchz/xjcY9uKiDAkRBMmA==",
"Rww3qkF3kWSd+AaMT0kfdw==",
"RxmdoO8ak8y/HzMSIm+yBQ==",
"Ry3zgZ6KHrpNyb7+Tt2Pkw==",
"RzeH+G3gvuK1z+nJGYqARQ==",
"S+b37XhKRm8cDwRb1gSsKQ==",
"S2MAIYeDQeJ1pl9vhtYtUg==",
"S3VQa6DH+BdlSrxT/g6B5g==",
"S47hklz3Ow+n5aY6+qsCoA==",
"S4RvORcJ3m6WhnAgV4YfYA==",
"S4rFuiKLFKZ+cL7ldiTwpg==",
"S7Vjy/gOWp0HozPP1RUOZw==",
"S8jlvuYuankCnvIvMVMzmg==",
"S9L29U2P5K8wNW+sWbiH7w==",
"SCO9nQncEcyVXGCtx30Jdg==",
"SChDh/Np1HyTPWfICfE1uA==",
"SDi5+FoP9bMyKYp+vVv1XA==",
"SEGu+cSbeeeZg4xWwsSErQ==",
"SEIZhyguLoyH7So0p1KY0A==",
"SESKbGF35rjO64gktmLTWA==",
"SElc2+YVi3afE1eG1MI7dQ==",
"SFn78uklZfMtKoz2N0xDaQ==",
"SIuKH/Qediq0TyvqUF93HQ==",
"SM7E98MyViSSS9G0Pwzwyw==",
"SNPYH4r/J9vpciGN2ybP5Q==",
"SOdpdrk2ayeyv0xWdNuy9g==",
"SPGpjEJrpflv1hF0qsFlPw==",
"SPHU6ES1WVm0Mu2LB+YjrA==",
"SSKhl2L3Mvy93DcZulADtA==",
"SUAwMWLMml8uGqagz5oqhQ==",
"SVFbcjXbV7HRg+7jUrzpwg==",
"SVLHWPCCH7GPVCF7QApPbw==",
"SVuEYfQ9FGyVMo1672n0Yg==",
"SbMjjI8/P8B9a9H2G0wHEQ==",
"Scto+9TWxj1eZgvNKo+a9A==",
"SfwnYZCKP1iUJyU1yq4eKg==",
"SiSlasZ+6U2IZYogqr2UPg==",
"Slu3z535ijcs5kzDnR7kfA==",
"SmRWEzqddY9ucGAP5jXjAg==",
"Sr9c0ReRpkDYGAiqSy683g==",
"Srl4HivgHMxMOUHyM3jvNw==",
"StDtLMlCI75g4XC59mESEQ==",
"StoXC7TBzyRViPzytAlzyQ==",
"StpQm/cQF8cT0LFzKUhC5w==",
"SusSOsWNoAerAIMBVWHtfA==",
"Swjn3YkWgj0uxbZ1Idtk+A==",
"SzCGM8ypE58FLaR1+1ccxQ==",
"Szko0IPE7RX2+mfsWczrMg==",
"T/6gSz2HwWJDFIVrmcm8Ug==",
"T1pMWdoNDpIsHF8nKuOn2A==",
"T6LA+daQqRI38iDKZTdg1A==",
"T7waQc3PvTFr0yWGKmFQdQ==",
"T9WoUJNwp8h4Yydixbx6nA==",
"TA9WjiLAFgJubLN4StPwLw==",
"TAD0Lk95CD86vbwrcRogaQ==",
"TBQpcKq2huNC5OmI2wzRQw==",
"TDrq23VUdzEU/8L5i8jRJQ==",
"TGB+FIzzKnouLh5bAiVOQg==",
"THfzE2G2NVKKfO+A2TjeFw==",
"THs1r8ZEPChSGrrhrNTlsA==",
"TI90EuS/bHq/CAlX32UFXg==",
"TIKadc6FAaRWSQUg5OATgg==",
"TIWSM78m0RprwgPGK/e0JA==",
"TLJbasOoVO435E5NE5JDcA==",
"TNyvLixb03aP2f8cDozzfA==",
"TSGL3iQYUgVg/O9SBKP9EA==",
"TSPFvkgw6uLsJh66Ou0H9w==",
"TVlHoi8J7sOZ2Ti7Dm92cQ==",
"TXab/hqNGWaSK+fXAoB2bg==",
"TYlnrwgyeZoRgOpBYneRAg==",
"TZ3ATPOFjNqFGSKY3vP2Hw==",
"TZT86wXfzFffjt0f95UF5w==",
"TafM7nTE5d+tBpRCsb8TjQ==",
"TahqPgS7kEg+y6Df0HBASw==",
"TcFinyBrUoAEcLzWdFymow==",
"TcGhAJHRr7eMwGeFgpFBhg==",
"TcyyXrSsQsnz0gJ36w4Dxw==",
"TeBGJCqSqbzvljIh9viAqA==",
"TfHvdbl2M4deg65QKBTPng==",
"TfNHjSTV8w6Pg6+FaGlxvA==",
"TgWe70YalDPyyUz6n88ujg==",
"Tk5MAqd1gyHpkYi8ErlbWg==",
"TlJizlASbPtShZhkPww4UA==",
"Tm4zk2Lmg8w4ITMI31NfTA==",
"Tmx0suRHzlUK4FdBivwOwA==",
"Tp52d1NndiC9w3crFqFm9g==",
"TrLmfgwaNATh24eSrOT+pw==",
"TrWS+reCJ0vbrDNT5HDR9w==",
"Tu6w6DtX2RJJ3Ym3o3QAWw==",
"TuaG3wRdM9BWKAxh2UmAsg==",
"Tud+AMyuFkWYYZ73yoJGpQ==",
"Tug3eh+28ttyf+U7jfpg5w==",
"U+bB5NjFIuQr/Y5UpXHwxA==",
"U+oTpcjhc0E+6UjP11OE/Q==",
"U0KmEI6e5zJkaI4YJyA5Ew==",
"U49SfOBeqQV9wzsNkboi8Q==",
"U6VQghxOXsydh3Naa5Nz4A==",
"U9kE50Wq5/EHO03c5hE4Ug==",
"UAqf4owQ+EmrE45hBcUMEw==",
"UEMwF4kwgIGxGT4jrBhMPQ==",
"UHpge5Bldt9oPGo2oxnYvQ==",
"UIXytIHyVODxlrg+eQoARA==",
"UK+R+hAoVeZ4xvsoZjdWpw==",
"UNRlg6+CYVOt68NwgufGNA==",
"UNdKik7Vy23LjjPzEdzNsg==",
"UNt7CNMtltJWq8giDciGyA==",
"UP7NXAE0uxHRXUAWPhto0w==",
"UP9mmAKzeQqGhod7NCqzhg==",
"UPYR575ASaBSZIR3aX1IgQ==",
"UPzS4LR3p/h0u69+7YemrQ==",
"UQTQk5rrs6lEb1a+nkLwfg==",
"USCvrMEm/Wqeu9oX6FrgcQ==",
"USq1iF90eUv41QBebs3bhw==",
"UTmTgvl+vGiCDQpLXyVgOg==",
"UVEZPoH9cysC+17MKHFraw==",
"UXUNYEOffgW3AdBs7zTMFA==",
"UZoibx+y1YJy/uRSa9Oa2w==",
"Ua6aO6HwM+rY4sPR19CNFA==",
"UbABE6ECnjB+9YvblE9CYw==",
"UbSFw5jtyLk5MealqJw++A==",
"Ugt8HVC/aUzyWpiHd0gCOQ==",
"UgvtdE2eBZBUCAJG/6c0og==",
"Uh1mvZNGehK1AaI4a1auKQ==",
"Uje3Ild84sN41JEg3PEHDg==",
"UjmDFO7uzjl4RZDPeMeNyg==",
"Um1ftRBycvb+363a90Osog==",
"Umd+5fTcxa3mzRFDL9Z8Ww==",
"Uo+FIhw1mfjF6/M8cE1c/Q==",
"Uo1ebgsOxc3eDRds1ah3ag==",
"UreSZCIdDgloih8KLeX7gg==",
"UtLYUlQJ02oKcjNR3l+ktg==",
"Uudn69Kcv2CGz2FbfJSSEA==",
"UvC1WADanMrhT+gPp/yVqA==",
"Uw6Iw+TP9ZdZGm2b/DAmkg==",
"UwqBVd4Wfias4ElOjk2BzQ==",
"Uy4QI8D2y1bq/HDNItCtAw==",
"UymZUnEEQWVnLDdRemv+Tw==",
"UzPPFSXgeV7KW4CN5GIQXA==",
"V+QzdKh5gxTPp2yPC9ZNEg==",
"V/xG5QFyx1pihimKmAo8ZA==",
"V1fvtnJ0L3sluj9nI5KzRw==",
"V2P75JFB4Se9h7TCUMfeNA==",
"V5HEaY3v9agOhsbYOAZgJA==",
"V5HKdaTHjA8IzvHNd9C51g==",
"V6CRKrKezPwsRdbm0DJ2Yg==",
"V6zyoX6MERIybGhhULnZiw==",
"V7eji28JSg3vTi30BCS7gw==",
"V8m51xgUgywRoV6BGKUrgg==",
"V8q+xz4ljszLZMrOMOngug==",
"V9G1we3DOIQGKXjjPqIppQ==",
"V9vkAanK+Pkc4FGAokJsTA==",
"VAg/aU5nl72O+cdNuPRO4g==",
"VCL3xfPVCL5RjihQM59fgg==",
"VE4sLM5bKlLdk85sslxiLQ==",
"VGRCSrgGTkBNb8sve0fYnQ==",
"VH70dN82yPCRctmAHMfCig==",
"VI8pgqBZeGWNaxkuqQVe7g==",
"VIC7inSiqzM6v9VqtXDyCw==",
"VIkS30v268x+M1GCcq/A8A==",
"VJt2kPVBLEBpGpgvuv1oUw==",
"VK95g27ws2C6J2h/7rC2qA==",
"VOB+9Bcfu8aHKGdNO0iMRw==",
"VOvrzqiZ1EHw+ZzzTWtpsw==",
"VPa7DG6v7KnzMvtJPb88LQ==",
"VPqyIomYm7HbK5biVDvlpw==",
"VQIpquUqmeyt/q6OgxzduQ==",
"VRnx+kd6VdxChwsfbo1oeQ==",
"VUDsc9RMS1fSM43c+Jo9dQ==",
"VWNDBOtjiiI4uVNntOlu/A==",
"VWb8U4jF/Ic0+wpoXi/y/g==",
"VWy9lB5t4fNCp4O/4n8S4w==",
"VX+cVXV8p9i5EBTMoiQOQQ==",
"VXu4ARjq7DS2IR/gT24Pfw==",
"VZX1FnyC8NS2k3W+RGQm4g==",
"VaJc9vtYlqJbRPGb5Tf0ow==",
"VbCoGr8apEcN7xfdaVwVXw==",
"VbHoWmtiiPdABvkbt+3XKQ==",
"Vg2E5qEDfC+QxZTZDCu9yQ==",
"VhYGC8KYe5Up+UJ2OTLKUw==",
"Vik8tGNxO0xfdV0pFmmFDw==",
"ViweSJuNWbx5Lc49ETEs/A==",
"VjclDY8HN4fSpB263jsEiQ==",
"VllbOAjeW3Dpbj5lp2OSmA==",
"VoPth5hDHhkQcrQTxHXbuw==",
"VpmBstwR7qPVqPgKYQTA3g==",
"VsXEBIaMkVftkxt1kIh7TA==",
"Vu0E+IJXBnc25x4n41kQig==",
"VzQ1NwNv9btxUzxwVqvHQg==",
"VznvTPAAwAev+yhl9oZT0w==",
"W+M4BcYNmjj7xAximDGWsA==",
"W/0s1x3Qm+wN8DhROk6FrQ==",
"W/5ThNLu43uT1O+fg0Fzwg==",
"W04GeDh+Tk/I1S85KlozRA==",
"W2x0SBzSIsTRgyWUCOZ/lg==",
"W4CfeVp9mXgk04flryL7iA==",
"W4utAK3ws0zjiba/3i91YA==",
"W5now3RWSzzMDAxsHSl++Q==",
"W8bATujVUT80v2XGJTKXDg==",
"W8y32OLHihfeV0XFw7LmOg==",
"WADmxH7R6B4LR+W6HqQQ6A==",
"WBu0gJmmjVdVbjDmQOkU6w==",
"WGKFTWJac8uehn3N59yHJw==",
"WHutPin+uUEqtrA7L8878A==",
"WKehT4nGF2T7aKuzABDMlA==",
"WLsh3UF4WXdHwgnbKEwRlQ==",
"WLwpjgr9KzevuogoHZaVUw==",
"WN7lFJfw4lSnTCcbmt5nsg==",
"WNfDNaWUOqABQ6c6kR+eyw==",
"WQMffxULFKJ+bun6NrCURA==",
"WQznrwqvMhUlM3CzmbhAOQ==",
"WRjYdKdtnd1G9e/vFXCt0g==",
"WRoJMO0BCJyn5V6qnpUi4Q==",
"WTr3q/gDkmB4Zyj7Ly20+w==",
"WVhfn2yJZ43qCTu0TVWJwA==",
"WWN44lbUnEdHmxSfMCZc6w==",
"WY7mCUGvpXrC8gkBB46euw==",
"WbAdlac/PhYUq7J2+n5f+w==",
"Wd0dOs7eIMqW5wnILTQBtg==",
"WdCWezJU4JK43EOZ9YHVdg==",
"Wf2olJCYZRGTTZxZoBePuQ==",
"WjDqf1LyFyhdd8qkwWk+MA==",
"WkSJpxBa45XJRWWZFee7hw==",
"Wn+Vj4eiWx0WPUHr3nFbyA==",
"WnHK5ZQDR6Da5cGODXeo0A==",
"WrJMOuXSLKKzgmIDALkyNw==",
"WtT0QAERZSiIt2SFDiAizg==",
"WwraoO97OTalvavjUsqhxQ==",
"Wx9jh/teM0LJHrvTScssyQ==",
"WyCFB4+6lVtlzu3ExHAGbQ==",
"WzjvUJ4jZAEK7sBqw+m07A==",
"X/Gha4Ajjm/GStp/tv+Jvw==",
"X1PaCfEDScclLtOTiF5JUw==",
"X2Tawm2Cra6H7WtXi1Z4Qw==",
"X2YfnPXgF2VHVX95ZcBaxQ==",
"X4hrgqMIcApsjA9qOWBoCw==",
"X4kdXUuhcUqMSduqhfLpxA==",
"X4o0OkTz0ec70mzgwRfltA==",
"X6Ln4si8G5aKar52ZH/FEQ==",
"X6ulLp4noBgefQTsbuIbYQ==",
"X9QAaNjgiOeAWSphrGtyVw==",
"XA2hUgq3GVPpxtRYiqnclg==",
"XAq/C+XyR6m3uzzLlMWO5Q==",
"XEwOJG24eaEtAuBWtMxhwg==",
"XF/yncdoT4ruPeXCxEhl9Q==",
"XGAXhUFjORwKmAq9gGEcRg==",
"XHHEg/8KZioW/4/wgSEkbQ==",
"XHjrTLXkm/bBY/BewmJcCQ==",
"XJihma9zSRrXLC+T+VcFDA==",
"XLq/nWX8lQqjxsK9jlCqUg==",
"XOG1PYgqoG8gVLIbVLTQgg==",
"XSb71ae0v+yDxNF5HJXGbQ==",
"XTCcsVfEvqxnjc0K5PLcyw==",
"XV13yK0QypJXmgI+dj4KYw==",
"XV5MYe0Q7YMtoBD6/iMdSw==",
"XVVy3e6dTnO3HpgD6BtwQw==",
"XXFr0WUuGsH5nXPas7hR3Q==",
"Xconi1dtldH90Wou9swggw==",
"XddlSluOH6VkR7spFIFmdQ==",
"XdkxmYYooeDKzy7PXVigBQ==",
"XePy/hhnQwHXFeXUQQ55Vg==",
"XfBOCJwi2dezYzLe316ivw==",
"XfY+QUriCAA1+3QAsswdgg==",
"XgPHx2+ULpm14IOZU2lrDg==",
"XjjrIpsmATV/lyln4tPb+g==",
"Xo8ZjXOIoXlBjFCGdlPuZw==",
"XpGXh76RDgXC4qnTCsnNHA==",
"XqFSbgvgZn0CpaZoZiRauQ==",
"XqTK/2QuGWj50tGmiDxysA==",
"XqUO7ULEYhDOuT/I2J8BOA==",
"XqW7UBTobbV4lt1yfh0LZw==",
"XrFDomoH2qFjQ2jJ2yp9lA==",
"XsF7R12agx/KkRWl0TyXRA==",
"Xv0mNYedaBc57RrcbHr9OA==",
"XwKWd03sAz8MmvJEuN08xA==",
"Y1Nm3omeWX2MXaCjDDYnWQ==",
"Y1flEyZZAYxauMo4cmtJ1w==",
"Y26jxXvl79RcffH8O8b9Ew==",
"Y5KKN7t/v9JSxG/m1GMPSA==",
"Y5XR8Igvau/h+c1pRgKayg==",
"Y5iDQySR2c3MK7RPMCgSrw==",
"Y78dviyBS3Jq9zoRD5sZtQ==",
"Y7OofF9eUvp7qlpgdrzvkg==",
"Y7XpxIwsGK3Lm/7jX/rRmg==",
"Y7iDCWYrO1coopM3RZWIPg==",
"YA+zdEC+yEgFWRIgS1Eiqw==",
"YA0kMTJ82PYuLA4pkn4rfw==",
"YHM6NNHjmodv+G0mRLK7kw==",
"YK+q7uJObkQZvOwQ9hplMg==",
"YLz+HA6qIneP+4naavq44Q==",
"YNqIHCmBp/EbCgaPKJ7phw==",
"YPgMthbpcBN2CMkugV60hQ==",
"YVlRQHQglkbj3J2nHiP/Hw==",
"YXHQ3JI9+oca8pc/jMH6mA==",
"YZ39RIXpeLAhyMgmW2vfkQ==",
"YZt6HwCvdI5DRQqndA/hBQ==",
"YaUKOTyByjUvp1XaoLiW5Q==",
"YfbfE3WyYOW7083Y8sGfwQ==",
"YgVpC5d5V6K/BpOD663yQA==",
"YhLEPsi/TNyeUJw69SPYzQ==",
"Yig+Wh18VIqdsmwtwfoUQw==",
"Yjm5tSq1ejZn3aWqqysNvA==",
"YmaksRzoU+OwlpiEaBDYaQ==",
"YmjZJyNfHN5FaTL/HAm8ww==",
"YodhkayN5wsgPZEYN7/KNA==",
"YrEP9z2WPQ8l7TY1qWncDA==",
"YtZ8CYfnIpMd2FFA5fJ+1Q==",
"Yw4ztKv6yqxK9U1L0noFXg==",
"Yy2pPhITTmkEwoudXizHqQ==",
"YzTV0esAxBFVls3e0qRsnA==",
"Z+bsbVP91KrJvxrujBLrrQ==",
"Z0sjccxzKylgEiPCFBqPSA==",
"Z2MkqmpQXdlctCTCUDPyzw==",
"Z2rwGmVEMCY6nCfHO3qOzw==",
"Z5B+uOmPZbpbFWHpI9WhPw==",
"Z8T1b9RsUWf59D06MUrXCQ==",
"Z9bDWIgcq6XwMoU2ECDR5Q==",
"ZAQHWU6RMg4IadOxuaukyw==",
"ZCdad3AwhVArttapWFwT/Q==",
"ZH5Es/4lJ+D5KEkF1BVSGg==",
"ZIZx4MehWTVXPN9cVQBmyA==",
"ZItMIn1vhGqAlpDHclg0Ig==",
"ZJY+hujfd58mTKTdsmHoQQ==",
"ZJc7GV0Yb6MrXkpDVIuc8g==",
"ZKXxq9yr7NGBOHidht34uQ==",
"ZKeTDCboOgCptrjSfgu0xw==",
"ZKvox7BaQg4/p5jIX69Umw==",
"ZNrjP1fLdQpGykFXoLBNPw==",
"ZQ0ZnTsZKWxbRj7Tilh24Q==",
"ZQSDYgpsimK+lYGdXBWE/w==",
"ZRWyfXyXqAaOEjkzWl949Q==",
"ZRnR6i+5WKMRfs3BDRBCJg==",
"ZSmN8mmI9lDEHkJqBBg0Nw==",
"ZV8mEgJweIYk0/l0BFKetA==",
"ZVnErH1Si4u51QoT0OT7pA==",
"ZWXfE3uGU91WpPMGyknmqw==",
"ZXeMG5eqQpZO/SGKC4WQkA==",
"ZYW30FfgwHmW6nAbUGmwzA==",
"ZZImGypBWwYOAW43xDRWCQ==",
"ZaPsR9X77SNt7dLjMJUh8A==",
"ZbLVNTQSVZQWTNgC4ZGfQg==",
"ZcuIvc8fDI+2uF0I0uLiVA==",
"ZfRlID+pC1Rr4IY14jolMw==",
"ZgdpqFrVGiaHkh9o3rDszg==",
"ZgjifTVKmxOieco81gnccQ==",
"ZiJ/kJ9GneF3TIEm08lfvQ==",
"ZlBNHAiYsfaEEiPQ1z+rCA==",
"ZlOAnCLV1PkR0kb3E+Nfuw==",
"ZmVpw1TUVuT13Zw/MNI5hQ==",
"ZmblZauRqO5tGysY3/0kDw==",
"ZoNSxARrRiKZF5Wvpg7bew==",
"Zqd6+81TwYuiIgLrToFOTQ==",
"ZqjnqxZE/BjOUY0CMdVl0g==",
"ZqkmoGB0p5uT5J6XBGh7Tw==",
"ZrCezGLz38xKmzAom6yCTQ==",
"ZrCnZB/U/vcqEtI1cSvnww==",
"ZtWvgitOSRDWq7LAKYYd4Q==",
"ZtmnX24AwYAXHb2ZDC6MeQ==",
"ZuayB6IpbeITokKGVi9R5w==",
"ZvvxwDd0I6MsYd7aobjLUA==",
"ZyDh3vCQWzS5DI1zSasXWA==",
"ZybIEGf1Rn/26vlHmuMxhw==",
"ZydKlOpn2ySBW0G3uAqwuw==",
"ZygAjaN62XhW5smlLkks+Q==",
"Zyo0fzewcqXiKe2mAwKx5g==",
"ZyoaR1cMiKAsElmYZqKjLA==",
"Zz/5VMbw1TqwazReplvsEg==",
"ZzT5b0dYQXkQHTXySpWEaA==",
"ZzduJxTnXLD9EPKMn1LI4Q==",
"a/Y6IAVFv0ykRs9WD+ming==",
"a1aL8zQ+ie3YPogE3hyFFg==",
"a4EYNljinYTx9vb1VvUA6A==",
"a4rPqbDWiMivVzaRxvAj7g==",
"a5gZ5uuRrXEAjgaoh7PXAg==",
"a6IszND1m+6w+W+CvseC7g==",
"a6vem8n6WmRZAalDrHNP0g==",
"a7Pv1SOWYnkhIUC22dhdDA==",
"aD4QvtMlr8Lk/zZgZ6zIMg==",
"aEnHUfn7UE/Euh6jsMuZ7g==",
"aFJuE/s+Kbge4ppn+wulkA==",
"aIPde9CtyZrhbHLK740bfw==",
"aJFbBhYtMbTyMFBFIz/dTA==",
"aK9nybtiIBUvxgs1iQFgsw==",
"aLY2pCT0WfFO5EJyinLpPg==",
"aLh1XEUrfR9W82gzusKcOg==",
"aMa1yVA71/w6Uf1Szc9rMA==",
"aMmrAzoRWLOMPHhBuxczKg==",
"aN5x46Gw1VihRalwCt1CGg==",
"aOeJZUIZM9YWjIEokFPnzQ==",
"aRpdnrOyu5mWB1P5YMbvOA==",
"aRrcmH+Ud3mF1vEXcpEm4w==",
"aTWiWjyeSDVY/q8y9xc2zg==",
"aWZRql2IUPVe9hS3dxgVfQ==",
"aXqiibI6BpW3qilV6izHaQ==",
"aXrbsro7KLV8s4I4NMi4Eg==",
"aXs9qTEXLTkN956ch3pnOA==",
"aY6B28XdPnuYnbOy9uSP8A==",
"adJAjAFyR2ne1puEgRiH+g==",
"adT+OjEB2kqpeYi4kQ6FPg==",
"afMd/Hr3rYz/l7a3CfdDjg==",
"ahAbmGJZvUOXrcK6OydNGQ==",
"alJtvTAD7dH/zss/Ek1DMQ==",
"alqHQBz8V446EdzuVfeY5Q==",
"anyANMnNkUqr3JuPJz5Qzw==",
"apWEPWUvMC24Y+2vTSLXoA==",
"aqcOby9QyEbizPsgO3g0yw==",
"ash1r2J6B0PUxJe8P0otVQ==",
"asouSfUjJa8yfMG7BBe+fA==",
"auvG6kWMnhCMi7c7e9eHrw==",
"avFTp3rS6z5zxQUZQuaBHQ==",
"avZp5K7zJvRvJvpLSldNAw==",
"aw4CzX8pYbPVMuNrGCEcWg==",
"axEl7xXt/bwlvxKhI7hx4g==",
"ayBGGPEy++biljvGcwIjXA==",
"aySnrShOW4/xRSzl/dtSKQ==",
"ays5/F7JANIgPHN0vp2dqQ==",
"b06KGv5zDYsTxyTbQ9/eyA==",
"b0vZfEyuTja2JYMa20Rtbg==",
"b16O4LF7sVqB7aLU2f3F1A==",
"b3BQG9/9qDNC/bNSTBY/sQ==",
"b3q8kjHJPj9DWrz3yNgwjQ==",
"b4BoZmzVErvuynxirLxn0w==",
"b4aFwwcWMXsSdgS1AdFOXA==",
"b53qqLnrTBthRXmmnuXWvw==",
"b6rrRA0W247O+FfvDHbVCQ==",
"b85nxzs8xiHxaqezuDVWvg==",
"b8BZV1NfBdLi70ir4vYvZg==",
"bA2kaTpeXflTElTnQRp6GQ==",
"bBEndaOStXBpAK79FrgHaw==",
"bG+P+p34t/IJ1ubRiWg6IA==",
"bGGUhiG9SqJMHQWitXTcYQ==",
"bIk7Fa6SW7X18hfDjTKowg==",
"bJ1cZW7KsXmoLw0BcoppJg==",
"bJgsuw29cO2WozqsGZxl7w==",
"bK045TkBlz+/3+6n6Qwvrg==",
"bL2FuwsPT7a7oserJQnPcw==",
"bLEntCrCHFy9pg3T3gbBzg==",
"bLd38ZNkVeuhf0joEAxnBQ==",
"bLsStF0DDebpO+xulqGNtg==",
"bMWFvjM8eVezU1ZXKmdgqw==",
"bMb1ia0rElr2ZpZVhva0Jw==",
"bNDKcFu8T5Y6OoLSV+o/Sw==",
"bNq/hj0Cjt4lkLQeVxDVdQ==",
"bO55S58bqDiRWXSAIUGJKw==",
"bPRX2zl+K1S0iWAWUn1DZw==",
"bQ7J5mebp38rfP/fuqQOsg==",
"bQKkL+/KUCsAXlwwIH0N3w==",
"bTNRjJm+FfSQVfd56nNNqQ==",
"bUF0JIfS4uKd3JZj2xotLQ==",
"bUxQBaqKyvlSHcuRL9whjg==",
"bV9r7j2kNJpDCEM5E2339Q==",
"bWwtTFlhO3xEh/pdw0uWaQ==",
"bb/U8UynPHwczew/hxLQxw==",
"bbBsi6tXMVWyq3SDVTIXUg==",
"beSrliUu0BOadCWmx+yZyA==",
"bfUD03N2PRDT+MZ+WFVtow==",
"bhVbgJ4Do4v56D9mBuR/EA==",
"birqO8GOwGEI97zYaHyAuw==",
"bjLZ7ot/X/vWSVx4EYwMCg==",
"bkRdUHAksJZGzE1gugizYQ==",
"blygTgAHZJ3NzyAT33Bfww==",
"bs2QG8yYWxPzhtyMqO6u3A==",
"bsHIShcLS134C+dTxFQHyA==",
"bvbMJZMHScwjJALxEyGIyg==",
"bvyB6OEwhwCIfJ6KRhjnRw==",
"bz294kSG4egZnH2dJ8HwEg==",
"bzVeU2qM9zHuzf7cVIsSZw==",
"bzXXzQGZs8ustv0K4leklA==",
"c1wbFbN7AdUERO/xVPJlgw==",
"c3WVxyC5ZFtzGeQlH5Gw+w==",
"c5Tc7rTFXNJqYyc0ppW+Iw==",
"c5q/8n7Oeffv3B1snHM/lA==",
"c5ymZKqx/td1MiS2ERiz9A==",
"c6Yhwy/q3j7skXq52l36Ww==",
"cBBOQn7ZjxDku0CUrxq2ng==",
"cFFE2R4GztNoftYkqalqUQ==",
"cHSj5dpQ04h/WyefjABfmQ==",
"cHkOsVd80Rgwepeweq4S1g==",
"cLR0Ry4/N5swqga1R6QDMw==",
"cMo6l1EQESx1rIo+R4Vogg==",
"cNsC9bH30eM1EZS6IdEdtQ==",
"cSHSg9xJz/3F6kc+hKXkwg==",
"cT3PwwS6ALZA/na9NjtdzA==",
"cTvDd8okNUx0RCMer6O8sw==",
"cUyqCa7Oue934riyC17F8g==",
"cVhdRFuZaW/09CYPmtNv5g==",
"cWUg7AfqhiiEmBIu+ryImA==",
"cWdlhVZD7NWHUGte24tMjg==",
"cXpfd6Io6Glj2/QzrDMCvA==",
"ca+kx+kf7JuZ3pfYKDwFlg==",
"caepyBOAFu0MxbcXrGf6TA==",
"catI+QUNk3uJ+mUBY3bY8Q==",
"cbBXgB1WQ/i8Xul0bYY2fg==",
"ccK42Lm8Tsv73YMVZRwL6A==",
"cchuqe+CWCJpoakjHLvUfA==",
"ccmy4GVuX967KaQyycmO0w==",
"ccy3Ke2k4+evIw0agHlh3w==",
"cdWUm6uLNzR/knuj2x75eA==",
"cffrYrBX3UQhfX1TbAF+GQ==",
"cfh5VZFmIqJH/bKboDvtlA==",
"cgSEbLqqvDsNUyeA3ryJ6Q==",
"chwv4+xbEAa93PHg8q9zgQ==",
"ck86G8HsbXflyrK7MBntLg==",
"ckugAisBNX18eQz+EnEjjw==",
"cl4t9FXabQg7tbh1g7a0OA==",
"coGEgMVs2b314qrXMjNumQ==",
"cszpMdGbsbe6BygqMlnC9Q==",
"ctJYJegZhG42i+vnPFWAWw==",
"cu4ZluwohhfIYLkWp72pqA==",
"cuQslgfqD2VOMhAdnApHrA==",
"cvMJ714elj/HUh89a9lzOQ==",
"cvOg7N4DmTM+ok1NBLyBiQ==",
"cvZT1pvNbIL8TWg+SoTZdA==",
"cvrGmub2LoJ+FaM5HTPt9A==",
"cw1gBLtxH/m4H7dSM7yvFg==",
"cwBNvZc0u4bGABo88YUsVQ==",
"cxpZ4bloGv734LBf4NpVhA==",
"cxqHS4UbPolcYUwMMzgoOA==",
"czBWiYsQtNFrksWwoQxlOw==",
"d+ctfXU0j07rpRRzb5/HDA==",
"d/Wd3Ma1xYyoMByPQnA9Cw==",
"d0NBFiwGlQNclKObRtGVMQ==",
"d0VAZLbLcDUgLgIfT1GmVQ==",
"d0qvm3bl38rRCpYdWqolCQ==",
"d13Rj3NJdcat0K/kxlHLFw==",
"dAq8/1JSQf1f4QPLUitp0g==",
"dCDaYYrgASXPMGFRV0RCGg==",
"dChBe9QR29ObPFu/9PusLg==",
"dFSavcNwGd8OaLUdWq3sng==",
"dFetwmFw+D6bPMAZodUMZQ==",
"dG98w8MynOoX7aWmkvt+jg==",
"dGjcKAOGBd4gIjJq7fL+qQ==",
"dGrf9SWJ13+eWS6BtmKCNw==",
"dJHKDkfMFJeoULg7U4wwDQ==",
"dK2DU3t1ns+DWDwfBvH3SQ==",
"dL6n/JsK+Iq6UTbQuo/GOw==",
"dM9up4vKQV5LeX82j//1jQ==",
"dMRx4Mf6LrN64tiJuyWmDw==",
"dNTU+/2DdZyGGTdc+3KMhQ==",
"dNq2InSVDGnYXjkxPNPRxA==",
"dOS+mVCy3rFX9FvpkTxGXA==",
"dRFCIbVu0Y8XbjG5i+UFCQ==",
"dTMoNd6DDr1Tu8tuZWLudw==",
"dUx1REyXKiDFAABooqrKEA==",
"dVh/XMTUIx1nYN4q1iH1bA==",
"dXDPnL1ggEoBqR13aaW9HA==",
"dZg5w8rFETMp9SgW7m0gfg==",
"dZgMquvZmfLqP4EcFaWCiA==",
"daBhAvmE9shDgmciDAC5eg==",
"dhTevyxTYAuKbdLWhG47Kw==",
"dihDsG7+6aocG6M9BWrCzQ==",
"dmAfbd9F0OJHRAhNMEkRsA==",
"dml2gqLPsKpbIZ93zTXwCQ==",
"dnvatwSEcl73ROwcZ4bbIQ==",
"dpSTNOCPFHN5yGoMpl1EUA==",
"dqVw2q2nhCvTcW82MT7z0g==",
"drfODfDI6GyMW7hzkmzQvA==",
"dsueq9eygFXILDC7ZpamuA==",
"dtnE401dC0zRWU0S/QOTAg==",
"duRFqmvqF93uf/vWn8aOmg==",
"dxWv00FN/2Cgmgq9U3NVDQ==",
"e/nWuo5YalCAFKsoJmFyFA==",
"e2xLFVavnZIUUtxJx+qa1g==",
"e369ZIQjxMZJtopA//G55Q==",
"e4B3HmWjW+6hQzcOLru6Xg==",
"e5KCqQ/1GAyVMRNgQpYf6g==",
"e5l9ZiNWXglpw6nVCtO8JQ==",
"e5txnNRcGs2a9+mBFcF1Qg==",
"e9GqAEnk8XI5ix6kJuieNQ==",
"eAOEgF5N80A/oDVnlZYRAw==",
"eBapvE+hdyFTsZ0y5yrahg==",
"eC/RcoCVQBlXdE9WtcgXIw==",
"eCy/T+a8kXggn1L8SQwgvA==",
"eDWsx4isnr2xPveBOGc7Hw==",
"eDcyiPaB954q5cPXcuxAQw==",
"eFimq+LuHi42byKnBeqnZQ==",
"eFkXKRd2dwu/KWI5ZFpEzw==",
"eJDUejE/Ez/7kV+S74PDYg==",
"eJFIQh/TR7JriMzYiTw4Sg==",
"eJLrGwPRa6NgWiOrw1pA7w==",
"eJlcN+gJnqAnctbWSIO9uA==",
"eKQCVzLuzoCLcB4im8147A==",
"eLYKLr4labZeLiRrDJ9mnA==",
"ePlsM/iOMme2jEUYwi15ng==",
"eQ45Mvf5in9xKrP6/qjYbg==",
"eRwaYiog2DdlGQyaltCMJg==",
"eS/vTdSlMUnpmnl1PbHjyw==",
"eTMPXa60OTGjSPmvR4IgGw==",
"eV+RwWPiGEB+76bqvw+hbA==",
"eWgLAqJOU+fdn8raHb9HCw==",
"eXFOya6x5inTdGwJx/xtUQ==",
"eYAQWuWZX2346VMCD6s7/A==",
"eYE9No9sN5kUZ5ePEyS3+Q==",
"eddhS+FkXxiUnbPoCd5JJw==",
"edlXkskLx287vOBZ9+gVYg==",
"ehfPlu6YctzzpQmFiQDxGA==",
"ehwc2vvwNUAI7MxU4MWQZw==",
"ejfikwrSPMqEHjZAk3DMkA==",
"emVLJVzha7ui5OFHPJzeRQ==",
"enj9VEzLbmeOyYugTmdGfQ==",
"epY+dsm5EMoXnZCnO4WSHw==",
"es/L9iW8wsyLeC5S4Q8t+g==",
"eshD40tvOA6bXb0Fs/cH3A==",
"etRjRvfL/IwceY/IJ1tgzQ==",
"euxzbIq4vfGYoY3s1QmLcw==",
"evaWFoxZNQcRszIRnxqB+A==",
"ewPT4dM12nDWEDoRfiZZnA==",
"ewe/P3pJLYu/kMb5tpvVog==",
"ezsm4aFd6+DO9FUxz0A8Pg==",
"f/BjtP5fmFw2dRHgocbFlg==",
"f07bdNVAe9x+cAMdF1bByQ==",
"f09F7+1LRolRL5nZTcfKGA==",
"f0H/AFSx2KLZi9kVx5BAZg==",
"f1+fHgR5rDPsCZOzqrHM7Q==",
"f1Gs++Iilgq9GHukcnBG3w==",
"f1h+Vp+xmdZsZIziHrB2+g==",
"f5Xo7F1uaiM760Qbt978iw==",
"f6Ye5F0Lkn34uLVDCzogFQ==",
"f6iLrMpxKhFxIlfRsFAuew==",
"f9ywiGXsz+PuEsLTV3zIbQ==",
"fAKFfwlCOyhtdBK6yNnsNg==",
"fDOUzPTU2ndpbH0vgkgrJQ==",
"fFvXa1dbMoOOoWZdHxPGjw==",
"fHL+fHtDxhALZFb9W/uHuw==",
"fHNpW230mNib08aB7IM3XQ==",
"fKalNdhsyxTt1w08bv9fJA==",
"fM5uYpkvJFArnYiQ3MrQnA==",
"fO0+6TsjL+45p9mSsMRiIg==",
"fOARCnIg/foF/6tm7m9+3w==",
"fQS0jnQMnHBn7+JZWkiE/g==",
"fS471/rN4K2m10mUwGFuLg==",
"fSANOaHD0Koaqg7AoieY9A==",
"fU32wmMeD44UsFSqFY0wBA==",
"fU5ZZ1bIVsV+eXxOpGWo/Q==",
"fUAy3f9bAglLvZWvkO2Lug==",
"fVCRaPsTCKEVLkoF4y3zEw==",
"fW3QZyq5UixIA1mP6eWgqQ==",
"fX4G68hFL7DmEmjbWlCBJQ==",
"fY9VATklOvceDfHZDDk57A==",
"fZrj3wGQSt8RXv0ykJROcQ==",
"fbTm027Ms0/tEzbGnKZMDA==",
"fdqt93OrpG13KAJ5cASvkg==",
"fgXfRuqFfAu8qxbTi4bmhA==",
"fgdUFvQPb5h+Rqz8pzLsmw==",
"fhcbn9xE/6zobqQ2niSBgA==",
"fiv0DJivQeqUkrzDNlluRw==",
"fmC+85h5WBuk8fDEUWPjtQ==",
"fo3JL+2kPgDWfP+CCrFlFw==",
"foPAmiABJ3IXBoed2EgQXA==",
"foXSDEUwMhfHWJSmSejsQg==",
"fpXijBOM3Ai1RkmHven5Ww==",
"fsW2DaKYTCC7gswCT+ByQQ==",
"fsoXIbq0T0nmSpW8b+bj+g==",
"fsrX00onlGvfsuiCc35pGg==",
"ftsf2qztw3NC78ep/CZXWQ==",
"fv/PW8oexJYWf5De30fdLQ==",
"fvm0IQfnbfZFETg9v3z/Fg==",
"fxg/vQq9WPpmQsqQ4RFYaA==",
"fy54Milpa7KZH/zgrDmMXQ==",
"fzkmVWKhJsxyCwiqB/ULnQ==",
"g/z9yk94XaeBRFj4hqPzdw==",
"g0GbRp2hFVIdc7ct7Ky7ag==",
"g0aTR8aJ0uVy3YvGYu5xrw==",
"g0kHTNRI7x/lAsr92EEppw==",
"g0lWrzEYMntVIahC7i0O2g==",
"g1ELwsk6hQ+RAY1BH640Pg==",
"g2nh2xENCFOpHZfdEXnoQA==",
"g5EzTJ0KA4sO3+Opss3LMg==",
"g6udffWh7qUnSIo1Ldn3eA==",
"g6zSo8BvLuKqdmBFM1ejLA==",
"g8TcogVxHpw7uhgNFt5VCQ==",
"gAoV4BZYdW1Wm712YXOhWQ==",
"gB8wkuIzvuDAIhDtNT1gyA==",
"gBgJF0PiGEfcUnXF0RO7/w==",
"gC7gUwGumN7GNlWwfIOjJQ==",
"gDLjxT7vm07arF4SRX5/Vg==",
"gDxqUdxxeXDYhJk9zcrNyA==",
"gEHGeR2F82OgBeAlnYhRSw==",
"gFEnTI8os2BfRGqx9p5x8w==",
"gGLz3Ss+amU7y6JF09jq7A==",
"gICaI06E9scnisonpvqCsA==",
"gK7dhke5ChQzlYc/bcIkcg==",
"gR0sgItXIH8hE4FVs9Q07w==",
"gR3B8usSEb0NLos51BmJQg==",
"gTB2zM3RPm27mUQRXc/YRg==",
"gTnsH3IzALFscTZ1JkA9pw==",
"gU3gu8Y5CYVPqHrZmLYHbQ==",
"gUNP5w7ANJm257qjFxSJrA==",
"gW0oKhtQQ7BxozxUWw5XvQ==",
"gXlb7bbRqHXusTE5deolGA==",
"gYGQBLo5TdMyXks0LsZhsQ==",
"gYgCu/qUpXWryubJauuPNw==",
"gYnznEt9r97haD/j2Cko7g==",
"gYvdNJCDDQmNhtJ6NKSuTA==",
"gZNJ1Qq6OcnwXqc+jXzMLQ==",
"gZWTFt5CuLqMz6OhWL+hqQ==",
"gaEtlJtD6ZjF5Ftx0IFt0A==",
"gf1Ypna/Tt+TZ08Y+GcvGg==",
"gfhkPuMvjoC3CGcnOvki3Q==",
"gfnbviaVhKvv1UvlRGznww==",
"ggIfX1J4dX3xQoHnHUI7VA==",
"gglLMohmJDPRGMY1XKndjQ==",
"ghp8sWGKWw20S/z1tbTxFg==",
"ginkFyNVMwkZLE49AbfqfA==",
"gkrg0NR0iCaL7edq0vtewA==",
"glnqaRfwm6NxivtB2nySzw==",
"gnAIpoCyl3mQytLFgBEgGA==",
"gnez1VrH+UHT8C/SB9qGdA==",
"gnkadeCgjdmLdlu/AjBZJg==",
"goSgZ8N5UbT5NMnW3PjIlQ==",
"gqehq46BhFX2YLknuMv02w==",
"gsC/mWD8KFblxB0JxNuqJw==",
"gvvyX5ATi4q9NhnwxRxC8w==",
"gwyVIrTk5o0YMKQq4lpJ+Q==",
"gxwbqZDHLbQVqXjaq42BCg==",
"h+KRDKIvyVUBmRjv1LcCyg==",
"h0MH5NGFfChgmRJ3E/R3HQ==",
"h13Xuonj+0dD1xH86IhSyQ==",
"h1NNwMy0RjQmLloSw1hvdg==",
"h2B0ty0GobQhDnFqmKOpKQ==",
"h2cnQQF2/R3Mq2hWdDdrTg==",
"h3vYYI9yhpSZV2MQMJtwFQ==",
"h5HsEsObPuPFqREfynVblw==",
"h7Fc+eT/GuC8iWI+YTD0UQ==",
"hCzsi1yDv9ja5/o7t94j9Q==",
"hDGa2yLwNvgBd/v6mxmQaQ==",
"hDILjSpTLqJpiSSSGu445A==",
"hIABph+vhtSF5kkZQtOCTA==",
"hIJA+1QGuKEj+3ijniyBSQ==",
"hIjgi20+km+Ks23NJ4VQ6Q==",
"hJ8leLNuJ6DK5V8scnDaZQ==",
"hJSP7CostefBkJrwVEjKHA==",
"hK8KhTFcR06onlIJjTji/Q==",
"hKOsXOBoFTl/K4xE+RNHDA==",
"hN9bmMHfmnVBVr+7Ibd2Ng==",
"hNHqznsrIVRSQdII6crkww==",
"hP7dSa8lLn9KTE/Z0s4GVQ==",
"hPnPQOhz4QKhZi02KD6C+A==",
"hRxbdeniAVFgKUgB9Q3Y+g==",
"hSNZWNKUtDtMo6otkXA/DA==",
"hSkY45CeB6Ilvh0Io4W6cg==",
"hUWqqG1QwYgGC5uXJpCvJw==",
"hW9DJA1YCxHmVUAF7rhSmQ==",
"hWoxz5HhE50oYBNRoPp1JQ==",
"hY82j+sUQQRpCi6CCGea5A==",
"hZlX6qOfwxW5SPfqtRqaMw==",
"hdzol5dk//Q6tCm4+OndIA==",
"hf9HFxWRNX2ucH8FLS7ytA==",
"hfcH5Az2M7rp+EjtVpPwsg==",
"hiYg+aVzdBUDCG0CXz9kCw==",
"hkOBNoHbno2iNR7t3/d4vg==",
"hlMumZ7RJFpILuKs09ABtw==",
"hlu7os0KtAkpBTBV6D2jyQ==",
"hlvtFGW8r0PkbUAYXEM+Hw==",
"hnCUnoxofUiqQvrxl73M8w==",
"hq35Fjgvrcx6I9e6egWS4w==",
"hqeSvwu8eqA072iidlJBAw==",
"htDbVu1xGhCRd8qoMlBoMg==",
"htNVAogFakQkTX6GHoCVXg==",
"hv5GrLEIjPb4bGOi8RSO0w==",
"hvsZ5JmVevK1zclFYmxHaw==",
"hy303iin+Wm7JA6MeelwiQ==",
"i2sSvrTh/RdLJX0uKhbrew==",
"i42XumprV/aDT5R0HcmfIQ==",
"i6ZYpFwsyWyMJNgqUMSV1A==",
"i6r+mZfyhZyqlYv56o0H+w==",
"i8XXN7jcrmhnrOVDV8a2Hw==",
"i9IRqAqKjBTppsxtPB7rdw==",
"iANKiuMqWzrHSk9nbPe3bQ==",
"iCF+GWw9/YGQXsOOPAnPHQ==",
"iCnm5fPmSmxsIzuRK6osrA==",
"iFtadcw8v6betKka9yaJfg==",
"iGI9uqMoBBAjPszpxjZBWQ==",
"iGuY4VxcotHvMFXuXum7KA==",
"iGykaF+h4p46HhrWqL8Ffg==",
"iIWxFdolLcnXqIjPMg+5kQ==",
"iIm8c9uDotr87Aij+4vnMw==",
"iJ2nT8w8LuK11IXYqBK+YA==",
"iK0dWKHjVVexuXvMWJV9pg==",
"iPwX3SbbG9ez9HoHsrHbKw==",
"iQ304I1hmLZktA1d1cuOJA==",
"iS9wumBV5ktCTefFzKYfkA==",
"iSeH0JFSGK73F470Rhtesw==",
"iUsUCB0mfRsE9KPEQctIzw==",
"iVDd2Zk7vwmEh97LkOONpQ==",
"iWNlSnwrtCmVF89B+DZqOQ==",
"ibsb1ncaLZXAYgGkMO7tjQ==",
"ieEAgvK9LsWh2t6DsQOpWA==",
"ifZM0gBm9g9L09YlL+vXBg==",
"ifuJCv9ZA84Vz1FYAPsyEA==",
"ilBBNK/IV69xKTShvI94fQ==",
"imZ+mwiT22sW2M9alcUFfg==",
"inrUwXyKikpOW0y2Kl1wGw==",
"ionqS0piAOY2LeSReAz4zg==",
"ipPPjxpXHS1tcykXmrHPMQ==",
"irnD9K8bsT+up/JUrxPw6A==",
"iruDC5MeywV4yA8o1tw/KQ==",
"isep9d+Q7DEUf0W7CJJYzw==",
"itPtn+JaO4i7wz2wOPOmDQ==",
"iu5csar0IQQBOTgw5OvJwQ==",
"iujlt9fXcUXEYc+T2s5UjA==",
"iwKBOGDTFzV4aXgDGfyUkw==",
"izeyFvXOumNgVyLrbKW45g==",
"j+8/VARfbQSYhHzj0KPurQ==",
"j+lDhAnWAyso+1N8cm85hQ==",
"j4FBMnNfdBwx0VsDeTvhFg==",
"j8nMH8mK/0Aae7ZkqyPgdg==",
"j8to4gtSIRYpCogv2TESuQ==",
"jCgdKXsBCgf7giUKnr6paQ==",
"jEdanvXKyZdZJG6mj/3FWw==",
"jEqP0dyHKHiUjZ9dNNGTlQ==",
"jGHMJqbj6X1NdTDyWmXYAQ==",
"jHOoSl3ldFYr9YErEBnD3w==",
"jKJn4czwUl/6wtZklcMsSg==",
"jLI3XpVfjJ6IzrwOc4g9Pw==",
"jLkmUZ6fV56GfhC0nkh4GA==",
"jMZKSMP2THqwpWqJNJRWdw==",
"jNJQ6otieHBYIXA9LjXprg==",
"jNcMS2zX1iSZN9uYnb2EIg==",
"jOPdd330tB6+7C29a9wn0Q==",
"jQVlDU+HjZ2OHSDBidxX5A==",
"jQjyjWCEo9nWFjP4O8lehw==",
"jS0JuioLGAVaHdo/96JFoQ==",
"jTg9Y6EfpON4CRFOq0QovA==",
"jTmPbq+wh30+yJ/dRXk1cA==",
"jV/D2B11NLXZRH77sG9lBw==",
"jWsC7kdp2YmIZpfXGUimiA==",
"jZMDIu95ITTjaUX0pk4V5g==",
"jd6IpPJwOJW1otHKtKZ5Gw==",
"jdRzkUJrWxrqoyNH9paHfQ==",
"jdVMQqApseHH3fd91NFhxg==",
"jfegbZSZWkDoPulFomVntA==",
"jgNijyoj2JrQNSlUv4gk4A==",
"ji+1YHlRvzevs3q5Uw1gfA==",
"ji306HRiq965zb8EZD2uig==",
"jiV+b/1EFMnHG6J0hHpzBg==",
"jjNMPXbmpFNsCpWY0cv3eg==",
"jkUpkLoIXuu7aSH8ZghIAQ==",
"joDXdLpXvRjOqkRiYaD/Sw==",
"jon1y9yMEGfiIBjsDeeJdA==",
"jp5Em/0Ml4Txr1ptTUQjpg==",
"jpNUgFnanr9Sxvj2xbBXZw==",
"jpjpNjL1IKzJdGqWujhxCw==",
"jqPQ0aOuvOJte/ghI1RVng==",
"jrRH0aTUYCOpPLZwzwPRfQ==",
"jrfRznO0nAz6tZM1mHOKIA==",
"jt9Ocr9D8EwGRgrXVz//aQ==",
"jx7rpxbm1NaUMcE2ktg5sA==",
"jz7QlwxCIzysP39Cgro8jg==",
"k+IBS52XdOe5/hLp28ufnA==",
"k/Aou2Jmyh8Bu3k8/+ndsQ==",
"k/OVIllJvW6BefaLEPq7DA==",
"k/pBSWE2BvUsvJhA9Zl5uw==",
"k0XIjxp2vFG7sTrKcfAihA==",
"k1DPiH6NkOFXP/r3N12GyA==",
"k2KP9oPMnHmFlZO6u6tgyw==",
"k6OmSlaSZ5CB0i7SD9LczQ==",
"k8eZxqwxiN/ievXdLSEL/w==",
"kBAB2PSjXwqoQOXNrv80AA==",
"kFrRjz7Cf2KvLtz9X6oD+w==",
"kGeXrHEN6o7h5qJYcThCPw==",
"kHcBZXoxnFJ+GMwBZ/xhfQ==",
"kIGxCUxSlNgsKZ45Al1lWw==",
"kJdY3XEdJS/hyHdR+IN0GA==",
"kMUdiwM7WR8KGOucLK4Brw==",
"kNGIV3+jQmJlZDTXy1pnyA==",
"kRnBEH6ILR5GNSmjHYOclw==",
"kSUectNPXpXNg+tIveTFRw==",
"kTCHqcb3Cos51o8cL+MXcg==",
"kUhyc3G8Zvx8+q5q5nVEhw==",
"kUudvRfA33uJDzHIShQd3Q==",
"kWPUUi7x9kKKa6nJ+FDR5Q==",
"kZ/mZZg9YSDmk2rCGChYAg==",
"kZ0D191c/uv4YMG15yVLDw==",
"kZkmDatUOdIqs7GzH3nI1A==",
"ka7pMp8eSiv92WgAsz2vdA==",
"kcJ1acgBv6FtUhV8KuWoow==",
"kgKWQJJQKLUuD2VYKIKvxA==",
"kggaIvN2tlbZdZRI8S5Apw==",
"kgyUtd8MFe0tuuxDEUZA9w==",
"kh51WUI5TRnKhur6ZEpRTQ==",
"kj5WqpRCjWAfjM7ULMcuPQ==",
"kjWYVC7Eok2w2YT4rrI+IA==",
"kkbX+a00dfiTgbMI+aJpMg==",
"kly/2kE4/7ffbO34WTgoGg==",
"knYKU74onR6NkGVjQLezZg==",
"kq26VyDyJTH/eM6QvS2cMw==",
"kr8tw1+3NxoPExnAtTmfxg==",
"ksOFI9C7IrDNk4OP6SpPgw==",
"kuWGANwzNRpG4XmY7KjjNg==",
"kvAaIJb+aRAfKK104dxFAA==",
"kwlAQhR2jPMmfLTAwcmoxw==",
"kydoXVaNcx1peR5g6i588g==",
"kzGNkWh3fz27cZer4BspUQ==",
"kzTl7WH/JXsX1fqgnuTOgw==",
"kzXsrxWRnWhkA82LsLRYog==",
"kzYddqiMsY3EYrpxve2/CQ==",
"l+x2QhxG8wb5AQbcRxXlmA==",
"l0E0U/CJsyCVSTsXW4Fp+w==",
"l2NppPcweAtmA1V2CNdk2Q==",
"l2ZB9TvT68rn8AAN4MdxWw==",
"l2mAbuFF3QBIUILDODiUHQ==",
"l4ddTxbTCW5UmZW+KRmx6A==",
"l5f3I6osM9oxLRAwnUnc5A==",
"l6QHU5JsJExNoOnqxBPVbw==",
"l6Ssc04/CnsqUua9ELu2iQ==",
"l8/KMItWaW3n4g1Yot/rcQ==",
"lC5EumoIcctvxYqwELqIqw==",
"lFUq6PGk9dBRtUuiEW7Cug==",
"lHN2dn2cUKJ8ocVL3vEhUQ==",
"lJFPmPWcDzDp5B2S8Ad8AA==",
"lK2xe+OuPutp4os0ZAZx5w==",
"lM/EhwTsbivA7MDecaVTPw==",
"lMaO8Yf+6YNowGyhDkPhQA==",
"lMjip5hbCjkD9JQjuhewDg==",
"lNF8PvUIN02NattcGi5u4g==",
"lON3WM0uMJ30F8poBMvAjQ==",
"lOPJhHqCtMRFZfWMX/vFZQ==",
"lTE6u9G/RzvmbuAzq2J2/Q==",
"lV70RNlE++04G1KFB3BMXA==",
"lY+tivtsfvU0LJzBQ6itYQ==",
"lacCCRiWdquNm4YRO7FoKA==",
"leDlMcM+B1mDE8k5SWtUeg==",
"lf1fwA0YoWUZaEybE+LyMQ==",
"lfOLLyZNbsWQgHRhicr4ag==",
"lffapwUUgaQOIqLz2QPbAg==",
"lhAOM81Ej6YZYBu45pQYgg==",
"lizovLQxu6L9sbafNQuShQ==",
"lkl6XkrTMUpXi46dPxTPxg==",
"lkzFdvtBx5bV6xZO0cxK7g==",
"ll2M0QQzBsj5OFi02fv3Yg==",
"llOvGOUDVfX68jKnAlvVRA==",
"llujnWE17U8MIHmx4SbrSA==",
"lqhgbgEqROAdfzEnJ17eXA==",
"lsBTMnse2BgPS6wvPbe7JA==",
"luO1R8dUM9gy1E2lojRQoA==",
"luR/kvHLwA6tSdLeTM4TzA==",
"lwYQm2ynA3ik2gE1m11IEg==",
"lyfqic/AbEJbCiw+wA01FA==",
"lz+SeifYXxamOLs1FsFmSQ==",
"lzUQ1o7JAbdJYpmEqi6KnQ==",
"m+eh+ZqS74w2q0vejBkjaw==",
"m/Lp4U75AQyk9c8cX14HJg==",
"m06wctjNc3o7iyBHDMZs2w==",
"m3XYojKO+I6PXlVRUQBC3w==",
"m416yrrAlv+YPClGvGh+qQ==",
"m5JIUETVXcRza4VL4xlJbg==",
"m6get5wjq5j1i5abnpXuZQ==",
"m6srF+pMehggHB1tdoxlPg==",
"m9iuy4UtsjmyPzy6FTTZvw==",
"mAiD16zf+rCc7Qzxjd5buA==",
"mAzsVkijuqihhmhNTTz65g==",
"mDXHuOmI4ayjy2kLSHku1Q==",
"mI0eT4Rlr7QerMIngcu/ng==",
"mMLhjdWNnZ8zts9q+a2v3g==",
"mMfn8OaKBxtetweulho+xQ==",
"mNlYGAOPc6KIMW8ITyBzIg==",
"mNv2Q67zePjk/jbQuvkAFA==",
"mPk1IsU5DmDFA/Ym5+1ojw==",
"mPwCyD0yrIDonVi+fhXyEQ==",
"mS99D+CXhwyfVt8xJ+dJZA==",
"mSJF9dJnxZ15lTC6ilbJ2A==",
"mSstwJq7IkJ0JBJ5T8xDKg==",
"mTAqtg6oi0iytHQCaSVUsA==",
"mTLBkP+yGHsdk5g7zLjVUw==",
"mU4CqbAwpwqegxJaOz9ofQ==",
"mUek9NkXm8HiVhQ6YXiyzA==",
"mVT74Eht+gAowINoMKV7IQ==",
"mW6TCje9Zg2Ep7nzmDjSYQ==",
"mXBfDUt/sBW5OUZs2sihvw==",
"mXPtbPaoNAAlGmUMmJEWBQ==",
"mXZ4JeBwT2WJQL4a/Tm4jQ==",
"mXycPfF5zOvcj1p4hnikWw==",
"mc45FSMtzdw2PTcEBwHWPw==",
"md6zNd7ZBn3qArYqQz7/fw==",
"me61ST+JrXM5k3/a11gRAA==",
"meHzY9dIF7llDpFQo1gyMg==",
"miiOqnhtef1ODjFzMHnxjA==",
"mjFBVRJ7TgnJx+Q74xllPg==",
"mjQS8CpyGnsZIDOIEdYUxg==",
"mk1CKDah7EzDJEdhL22B7w==",
"mmRob7iyTkTLDu8ObmTPow==",
"mnalaO6xJucSiZ0+99r3Cg==",
"mpOtwBvle+nyY6lUBwTemw==",
"mpWNaUH9kn4WY26DWNAh3Q==",
"mr1qjhliRfl87wPOrJbFQg==",
"mrinv7KooPQPrLCNTRWCFg==",
"mrxlFD3FBqpSZr1kuuwxGg==",
"msstzxq++XO0AqNTmA7Bmg==",
"mxug34EekabLz0JynutfBg==",
"myzvc+2MfxGD9uuvZYdnqQ==",
"n+xYzfKmMoB3lWkdZ+D3rg==",
"n1M2dgFPpmaICP+JwxHUug==",
"n1ixvP7SfwYT3L2iWpJg6A==",
"n5GA+pA9mO/f4RN9NL9lNg==",
"n6QVaozMGniCO0PCwGQZ6w==",
"n7Bns42aTungqxKkRfQ5OQ==",
"n7KL1Kv027TSxBVwzt9qeA==",
"n7h9v2N1gOcvMuBEf8uThw==",
"nDAsSla+9XfAlQSPsXtzPA==",
"nE72uQToQFVLOzcu/nMjww==",
"nFBXCPeiwxK9mLXPScXzTA==",
"nFPDZGZowr3XXLmDVpo7hg==",
"nGzPc0kI/EduVjiK7bzM6Q==",
"nHTsDl0xeQPC5zNRnoa0Rw==",
"nHUpYmfV59fe3RWaXhPs3Q==",
"nL4iEd3b5v4Y9fHWDs+Lrw==",
"nMuMtK/Zkb3Xr34oFuX/Lg==",
"nNaGqigseHw30DaAhjBU3g==",
"nOiwBFnXxCBfPCHYITgqNg==",
"nR3ACzeVF5YcLX6Gj6AGyQ==",
"nULSbtw2dXbfVjZh33pDiA==",
"nUgYO7/oVNSX8fJqP2dbdg==",
"nVDxVhaa2o38gd1XJgE3aw==",
"nW3zZshjZEoM8KVJoVfnuQ==",
"nY/H7vThZ+dDxoPRyql+Cg==",
"neQoa8pvETr07blVMN3pgA==",
"nf8x+F03kOpMhsCSUWEhVg==",
"ng1Q0A7ljho3TUWWYl46sw==",
"nhAnHuCGXcYlqzOxrrEe1g==",
"nkbLVLvh3ClKED97+nH+7Q==",
"nkedTagkmf6YE4tEY+0fKw==",
"nknBKPgb7US42v8A0fTl/w==",
"nmD7fEU4u7/4+W/pkC4/0Q==",
"nqpKfidczdgrNaAyPi7BOQ==",
"nqtQI1bSM7DCO9P1jGV97Q==",
"nsnX3tKkN1elr18E31tXDw==",
"nvLEpj6ZZF3LWH3wUB6lKg==",
"nvUKoKfC6j8fz3gEDQrc/w==",
"nvmBgp0YlUrdZ05INsEE8Q==",
"nwtCsN1xEYaHvEOPzBv+qQ==",
"nx/U4Tode5ILux4DSR+QMg==",
"nxDGRpePV3H4NChn4eLwag==",
"nyaekSYTKzfSeSfPrB114Q==",
"nykEOLL/o7h0cs0yvdeT2g==",
"o+areESiXgSO0Lby56cBeg==",
"o+nYS4TqJc6XOiuUzEpC3A==",
"o/Y4U6rWfsUCXJ72p5CUGw==",
"o1uhaQg5/zfne84BFAINUQ==",
"o1zeXHJEKevURAAbUE/Vog==",
"o5XVEpdP4OXH0NEO4Yfc/A==",
"o64LDtKq/Fulf1PkVfFcyg==",
"o7y4zQXQAryST2cak4gVbw==",
"o9tdzmIu+3J/EYU4YWyTkA==",
"oAHVGBSJ2cf4dVnb/KEYmw==",
"oDca3JEdRb4vONT9GUUsaQ==",
"oFNMOKbQXcydxnp8fUNOHw==",
"oFanDWdePmmZN0xqwpUukA==",
"oGH7SMLI2/qjd9Vnhi3s0A==",
"oIU19xAvLJwQSZzIH577aA==",
"oIWwTbkVS5DDL47mY9/1KQ==",
"oKt57TPe4PogmsGssc3Cbg==",
"oLWWIn/2AbKRHnddr2og9g==",
"oMJLQTH1wW7LvOV0KRx/dw==",
"oNOI17POQCAkDwj6lJsYOA==",
"oONlXCW4aAqGczQ/bUllBw==",
"oPcxgoismve6+jXyIKK6AQ==",
"oPlhC4ebXdkIDazeMSn1fQ==",
"oQjugfjraFziga1BcwRLRA==",
"oR8rvIZoeoaZ/ufpo0htfQ==",
"oSnrpW4UmmVXtUGWqLq+tQ==",
"oUqO4HrBvkpSL781qAC9+w==",
"oVlG+0rjrg2tdFImxIeVBA==",
"oad5SwflzN0vfNcyEyF4EA==",
"obW3kzv2KBvuckU7F+tfjA==",
"ocRh5LR1ZIN9Johnht8fhQ==",
"ocpLRASvTgqfkY20YlVFHQ==",
"ocvA1/NbyxM0hanwwY6EiA==",
"odGhKtO4bDW5R8SYiI5yCg==",
"ogcuGHUZJkmv+vCz567a2g==",
"ohK6EftXOqBzIMI+5XnESw==",
"ojZY7Gi2QJXE/fp6Wy31iA==",
"ojf6uL85EuEYgLvHoGhUrw==",
"ojugpLIfzflgU2lonfdGxA==",
"ol9xhVTG9e1wNo50JdZbOA==",
"olTSlmirL9MFhKORiOKYkQ==",
"omAjyj1l6gyQAlBGfdxJTw==",
"onFcHOO1c3pDdfCb5N4WkQ==",
"oqlkgrYe9aCOwHXddxuyag==",
"oxoZP897lgMg/KLcZAtkAg==",
"oyYtf08AkWLR52bXm5+sKw==",
"ozVqYsmUueKifb4lDyVyrg==",
"p+bx+/WQWALXEBCTnIMr4w==",
"p/48hurJ1kh2FFPpyChzJg==",
"p/7qM5+Lwzw1/lIPY91YxQ==",
"p0eNK7zJd7D/HEGaVOrtrQ==",
"p2JPOX8yDQ0agG+tUyyT/g==",
"p3V7NfveB6cNxFW7+XQNeQ==",
"p48i7AfSSAyTdJSyHvOONw==",
"p73gSu4d+4T/ZNNkIv9Nlw==",
"p8W1LgFuW6JSOKjHkx3+aA==",
"pCQmlnn3BxhsV2GwqjRhXg==",
"pFKzcRHSUBqSMtkEJvrR1Q==",
"pGQEWJ38hb/ZYy2P1+FIuw==",
"pHo1O5zrCHCiLvopP2xaWw==",
"pHozgRyMiEmyzThtJnY4MQ==",
"pKaTI+TfcV3p/sxbd2e7YQ==",
"pT1raq2fChffFSIBX3fRiA==",
"pUfWmRXo70yGkUD/x5oIvA==",
"pVG1hL96/+hQ+58rJJy6/A==",
"pVgjGg4TeTNhKimyOu3AAw==",
"pW4gDKtVLj48gNz6V17QdA==",
"pZfn6IiG+V28fN8E2hawDQ==",
"pa8nkpAAzDKUldWjIvYMYg==",
"pcoBh5ic7baSD4TZWb3BSw==",
"pdPwUHauXOowaq9hpL2yFw==",
"pdaY6kZ8+QqkMOInvvACNA==",
"peMW+rpwmXrSwplVuB/gTA==",
"pfGcaa49SM3S6yJIPk/EJQ==",
"plXHHzA8X9QGwWzlJxhLRw==",
"pnJnBzAJlO4j3IRqcfmhkQ==",
"prCOYlboBnzmLEBG/OeVrQ==",
"prOsOG0adI4o+oz50moipw==",
"pulldyBt2sw6QDvTrCh6zw==",
"pv/m2mA/RJiEQu2Qyfv9RA==",
"pvXHwJ3dwf9GDzfDD9JI3g==",
"pw1jplCdTC+b0ThX0FXOjw==",
"pxuSWn1u+bHtRjyh2Z8veA==",
"pyrUqiZ98gVXxlXQNXv5fA==",
"pzC8Y0Vj9MPBy3YXR32z6w==",
"q/siBRjx6wNu+OTvpFKDwA==",
"q4z6A4l3nhX3smTmXr+Sig==",
"q5g3c8tnQTW2EjNfb2sukw==",
"q6LG0VzO1oxiogAAU63hyg==",
"q7m/EtZySBjZNBjQ5m1hKw==",
"q8YF9G2jqydAxSqwyyys5Q==",
"qA0sTaeNPNIiQbjIe1bOgQ==",
"qCPfJTR8ecTw6u6b1yHibA==",
"qE/h/Z+6buZWf+cmPdhxog==",
"qIFpKKwUmztsBpJgMaVvSg==",
"qIUJPanWmGzTD1XxvHp+6w==",
"qNOSm15bdkIDSc/iUr+UTQ==",
"qNyy6Fc0b8oOMWqqaliZ/w==",
"qO4HlyHMK5ygX+6HbwQe8w==",
"qOEIUWtGm5vx/+fg4tuazg==",
"qP1cCE4zsKGTPhjbcpczMw==",
"qQQwJ/aF87BbnLu3okXxaw==",
"qYHdgFAXhF/XcW4lxqfvWQ==",
"qYuo5vY8V3tZx41Kh9/4Dw==",
"qZ2q5j2gH3O56xqxkNhlIA==",
"qaTdVEeZ6S8NMOxfm+wOMA==",
"qcpeZWUlPllQYZU6mHVwUw==",
"qenHZKKlTUiEFv6goKM/Mw==",
"qkvEep4vvXhc2ZJ6R449Mg==",
"qngzBJbiTB4fivrdnE5gOg==",
"qnkFUlJ8QT322JuCI3LQgg==",
"qnsBdl050y9cUaWxbCczRw==",
"qnzWszsyJhYtx8wkMN6b1g==",
"qoK2keBg3hdbn7Q24kkVXg==",
"qpFJZqzkklby+u1UT3c1iA==",
"qt5CsMts2aD4lw/4Q6bHYQ==",
"qxALQrqHoDq9d91nU0DckA==",
"qyRmvxh8p4j4f+61c10ZFQ==",
"r/b5px/UImGNjT/X5sYjuA==",
"r0QffVKB9OD9yGsOtqzlhA==",
"r0hAwlS0mPZVfCSB+2G6uQ==",
"r1VGXWeqGeGbfKjigaAS+Q==",
"r2f2MyT+ww1g9uEBzdYI1w==",
"r36kVMpF+9J+sfI3GeGqow==",
"r3lQAYOYhwlLnDWQIunKqg==",
"r95wJtP5rsTExKMS7QhHcw==",
"rBt6L/KLT7eybxKt5wtFdg==",
"rCxoo4TP/+fupXMuIM0sDA==",
"rHagXw+CkF3uEWPWDKXvog==",
"rIMXaCaozDvrdpvpWvyZOQ==",
"rJ9qVn8/2nOxexWzqIHlcQ==",
"rJCuanCy51ydVD4nInf9IQ==",
"rKAQxu80Q8g1EEhW5Wh8tg==",
"rKb3TBM4EPx/RErFOFVCnQ==",
"rLZII1R6EGus+tYCiUtm6g==",
"rM/BOovNgnvebKMxZQdk7g==",
"rMm9bHK69h0fcMkMdGgeeA==",
"rOYeIcB+Rg5V6JG2k4zS2w==",
"rSvhrHyIlnIBlfNJqemEbw==",
"rTwJggSxTbwIYdp07ly0LA==",
"rUp5Mfc57+A8Q29SPcvH/Q==",
"rWliqgfZ3/uCRBOZ9sMmdA==",
"rXGWY/Gq+ZEsmvBHUfFMmQ==",
"rXSbbRABEf4Ymtda45w8Fw==",
"rXfWkabSPN+23Ei1bdxfmQ==",
"rXtGpN17Onx8LnccJnXwJQ==",
"rZKD8oJnIj5fSNGiccfcvA==",
"raKMXnnX6PFFsbloDqyVzQ==",
"raYifKqev8pASjjuV+UTKQ==",
"rcY4Ot40678ByCfqvGOGdg==",
"rdeftHE7gwAT67wwhCmkYQ==",
"rfPTskbnoh3hRJH6ZAzQRg==",
"rgcXxjx3pDLotH7TTfAoZw==",
"rh7bzsTQ1UZjG7amysr0Gg==",
"rhgtLQh0F9bRA6IllM7AGw==",
"ri4AOITPdB1YHyXV+5S51g==",
"rkeLYwMZ1/pW2EmIibALfA==",
"rlXt6zKE7DswUl0oWGOQUQ==",
"rqHKB91H3qVuQAm+Ym5cUA==",
"rqucO37p86LpzehR/asCSQ==",
"rs2QrN4qzAHCHhkcrAvIfA==",
"rtJdfki8fG6CB36CADp0QA==",
"rtd6mqFgGe98mqO0pFGbSw==",
"rueNryrchijjmWaA3kljYg==",
"rvE64KQGkVkbl07y7JwBqw==",
"rwplpbNJz0ADUHTmzAj15Q==",
"rwtF86ZAbWyKI6kLn4+KBw==",
"rxfACPLtKXbYua18l3WlUw==",
"rzj6mjHCcMEouL66083BAg==",
"s+eHg5K9zZ2Jozu5Oya9ZQ==",
"s/BZAhh1cTV3JCDUQsV8mA==",
"s2AKVTwrY65/SWqQxDGJQg==",
"s5+78jS4hQYrFtxqTW3g1Q==",
"s5RUHVRNAoKMuPR/Jkfc2Q==",
"s7iW1M6gkAMp+D/3jHY58w==",
"s8NpalwgPdHPla7Zi9FJ3w==",
"sBpytpE38xz0zYeT+0qc2A==",
"sC11Rf/mau3FG5SnON4+vQ==",
"sCLMrLjEUQ6P1L8tz90Kxg==",
"sEeblUmISi1HK4omrWuPTA==",
"sGLPmr568+SalaQr8SE/PA==",
"sLJrshdEANp0qk2xOUtTnQ==",
"sLdxIKap0ZfC3GpUk3gjog==",
"sNmW2b2Ud7dZi3qOF8O8EQ==",
"sQAxqWXeiu/Su0pnnXgI9A==",
"sQskMBELEq86o1SJGQqfzg==",
"sQzCwNDlRsSH7iB9cTbBcg==",
"sS6QcitMPdvUBLiMXkWQkw==",
"sWLcS+m4aWk31BiBF+vfJQ==",
"sXlFMSTBFnq0STHj6cS/8w==",
"sa2DECaqYH1z1/AFhpHi+g==",
"saEpnDGBSZWqeXSJm34eOA==",
"scCQPl0em2Zmv/RQYar60g==",
"sfIClgTMtZo9CM9MHaoqhQ==",
"sfowXUMdN2mCoBVrUzulZg==",
"sfte/o9vVNyida/yLvqADA==",
"siHwJx6EgeB1gBT9z/vTyw==",
"skrQRB9xbOsiSA19YgAdIQ==",
"snGTzo540cCqgBjxrfNpKw==",
"soBA65OmZdfBGJkBmY/4Iw==",
"spHVvA/pc7nF9Q4ON020+w==",
"spJI3xFUlpCDqzg0XCxopA==",
"sr3UXbMg5zzkRduFx/as7g==",
"sw+bmpzqsM4gEQtnqocQLQ==",
"swJhrPwllq5JORWiP5EkDA==",
"swsVVsPi/5aPFBGP+jmPIw==",
"syeBfQBUmkXNWCZ1GV8xSA==",
"t+bYn9UqrzKiuxAYGF7RLA==",
"t0WN8TwMLgi8UVEImoFXKg==",
"t2EkpUsLOEOsrnep0nZSmA==",
"t2vWMIh2BvfDSQaz5T1TZw==",
"t3Txxjq43e/CtQmfQTKwWg==",
"t5U+VMsTtlWAAWSW+00SfQ==",
"t5wh9JGSkQO78QoQoEqvXA==",
"t7HaNlXL16fVwjgSXmeOAQ==",
"t8pjhdyNJirkvYgWIO/eKg==",
"tBQDfy48FnIOZI04rxfdcA==",
"tFMJRXfWE9g78O1uBUxeqQ==",
"tFmWYH82I3zb+ymk5dhepA==",
"tG+rpfJBXlyGXxTmkceiKA==",
"tHDbi43e6k6uBgO0hA+Uiw==",
"tIqwBotg052wGBL65DZ+yA==",
"tJt6VDdAPEemBUvnoc4viA==",
"tOdlnsE3L3XCBDJRmb/OqA==",
"tOkYq1BZY152/7IJ6ZYKUg==",
"tU31r8zla146sqczdKXufg==",
"tVhXk9Ff3wAg56FbdNtcFg==",
"tVvWdA+JqH0HR2OlNVRoag==",
"tVw8U1AsslIFmQs4H1xshg==",
"tX8X8KoxUQ8atFSCxgwE1Q==",
"tXVb5f90k9l3e1oK2NGXog==",
"tXuu7YpZOuMLTv87NjKerA==",
"tY916jrSySzrL+YTcVmYKQ==",
"tYeIZjIm0tVEsYxH1iIiUQ==",
"tb5+2dmYALJibez1W4zXgA==",
"td7nDgTDmKPSODRusMcupw==",
"tdgI9v7cqJsgCAeW1Fii1A==",
"tdiTXKrkqxstDasT0D5BPA==",
"tejpAZp7y32SO2+o4OGvwQ==",
"tfgO55QqUyayjDfQh+Zo1Q==",
"tj2rWvF2Fl+XIccctj8Mhw==",
"tnUtJ/DQX9WaVJyTgemsUA==",
"tq5xUJt8GtjDIh1b48SthQ==",
"tr+U/vt+MIGXPRQYYWJfRg==",
"trjM81KANPZrg9iSThWx6Q==",
"tsiqwelcBAMU/HpLGBtMGw==",
"twPn6wTGqI0aR//0wP3xtA==",
"twjiDKJM7528oIu/el4Zbg==",
"tzV7ixFH37ze4zuLILTlfA==",
"u/QxrP1NOM/bOJlJlsi/jQ==",
"u2WQlcMxOACy6VbJXK4FwA==",
"u5cUPxM6/spLIV8VidPrAA==",
"uC2lzm7HaMAoczJO6Z/IhQ==",
"uChFnF0oCwARhAOz/d47eA==",
"uESeJe/nYrHCq4RQbrNpGA==",
"uExgqZkkJnZj252l5dKAGg==",
"uIkVijg7RPi/1j7c18G1qA==",
"uJZGw3IY2nCcdVeWW1geNQ==",
"uMq8cDVWFD+tpn8aeP8Pqg==",
"uNWFZlP7DA96sf+LWiAhtQ==",
"uNzpptKjihEfKRo5A1nWmw==",
"uO+uK1DntCxVRr1KttfUIw==",
"uOHrw37yF9oLLVd16nUpeg==",
"uOkMpYy/7DYYoethJdixfQ==",
"uPdjKJIGzN7pbGZDZdCGaA==",
"uPi8TsGY3vQsMVo/nsbgVQ==",
"uPm+cF4Jq08S5pQhYFjU8A==",
"uPnL9tboMZo0Kl2fe24CmA==",
"uQs79rbD/wEakMUxqMI48A==",
"uSIiF1r9F18avZczmlEuMQ==",
"uT6WRh5UpVdeABssoP2VTg==",
"uTA0XbiH3fTeVV7u5z0b3w==",
"uTHBqApdKOAgdwX3cjrCYQ==",
"uU1TX5DoDg6EcFKgFcn0GA==",
"uXuPA/2KJbb7ZX+NymN3dw==",
"uXvr6vi5kazZ9BCg2PWPJA==",
"uZ2gUA74/7Q33tI2TcGQlg==",
"ucLMWnNDSqE4NOCGWvcGWw==",
"udU65VtsvJspYmamiOsgXw==",
"ueODvMv/f9ZD8O0aIHn4sg==",
"ugY8rTtJkN4CXWMVcRZiZw==",
"uhT12XY79CtbwhcSfAmAXQ==",
"ulLuTZqhEDkX0EJ3xwRP9A==",
"ulpDxLeQnIRPnq6oaah2AA==",
"up2MVDi9ve+s83/nwNtZ7Q==",
"uqe3rFveJ2JIkcZQ3ZMXHQ==",
"uqp92lAqjec8UQYfyjaEZw==",
"ur9JDCVNwzSH4q4ngDlHNQ==",
"uu+ncs63SdQIvG6z4r7Q3Q==",
"uuiJ+yB7JLDh2ulthM0mjg==",
"uvKYnKE01D5r7kR9UQyo5A==",
"uvzmRcvgepW6mZbMfYgcNw==",
"uwA6N5LptSXqIBkTO0Jd7Q==",
"uwGivY3/C9WK+dirRPJZ4A==",
"uzEgwx1iAXAvWPKSVwYSeQ==",
"uzkNhmo2d08tv5AmnyqkoQ==",
"v/PshI6JjkL9nojLlMNfhg==",
"v0Bvws1WYVoEgDt8xmVKew==",
"v1AWe5qb5y3vSKFb7ADeEw==",
"v4xIYrfPGILEbD/LwVDDzA==",
"v6jZicMNM3ysm3U5xu0HoQ==",
"v7BrkRmK0FfWSHunTRHQFQ==",
"vCekQ2nOQKiN/q8Be/qwZg==",
"vFFzkWgGyw6OPADONtEojQ==",
"vFox1d3llOeBeCUZGvTy0A==",
"vFtC0B2oe1gck28JOM1dyg==",
"vGKknndb4j6VTV8DxeT4fQ==",
"vHGjRRSlZHJIliCwIkCAmQ==",
"vHVXsAMQqc0qp7HA5Q+YkA==",
"vHmQUl4WHXs1E/Shh+TeyA==",
"vIORTYSHFIXk5E2NyIvWcQ==",
"vMuaLvAntJB5o7lmt/kVXA==",
"vOJ55zFdgPPauPyFYBf01w==",
"vRgkZZGVN7YZrlml0vxrKA==",
"vSKsa0JhLCe9QFZKkcj58Q==",
"vTAmgfq3GxL4+ubXpzwk5w==",
"vUC0HlTTHj6qNHwfviDtAw==",
"vUE8Iw3NyWXURpXyoNJdaw==",
"vWn9OPnrJgfPavg4D6T/HQ==",
"vX7RIhatQeXAMr1+OjzhZw==",
"vZtL0yWpSIA+9v8i23bZSg==",
"vb6Agwzk4JG0Nn7qRPPFMQ==",
"vbyiKeDCQ4q9dDRI1Q0Ong==",
"vg3jozLXEmAnmJwdfcEN0g==",
"vhdFtKVH4bVatb4n8KzeXw==",
"vjrSYGUpeKOtJ2cNgLFg2g==",
"vljJciS+uuIvL7XXm5688g==",
"vmqfGJE6r4yDahtU/HLrxw==",
"vnOJ3e9Zd4wPx8PX7QgZzQ==",
"voO3krg4sdy4Iu+MZEr8+g==",
"vqYHQ3MnHrAIAr1QHwfIag==",
"vsRNZx4thFFFPneubKq1Fw==",
"vvEH5A39TTe1AOC11rRCLA==",
"vvh9vAIrXjIwLVkuJb5oDQ==",
"vwno3vugCvt6ooT3CD4qIQ==",
"w+jzM0I5DRzoUiLS/9QIMQ==",
"w0PKdssv+Zc5J/BbphoxpA==",
"w1zN28mSrI/gqHsgs4ME3A==",
"w3G+qXXqqKi8F5s+qvkBUg==",
"w5N/aHbtOIKzcvG3GlMjGA==",
"wDiGoFEfIVEDyyc4VpwhWQ==",
"wEJDulZafLuXCvcqBYioFQ==",
"wHA+D5cObfV3kGORCdEknw==",
"wI7JrSPQwYHpv2lRsQu9nQ==",
"wIfvvLKC61gOpsddUFjVog==",
"wJ4uCrl4DPg70ltw1dZO3w==",
"wJKFMqh6MGctWfasjHrPEg==",
"wJpepvmtQQ3sz3tVFDnFqw==",
"wK6Srd83eLigZ11Q20XGrg==",
"wM8tnXO4PDlLVHspZFcjYw==",
"wMOE/pEKVIklE75xjt6b6w==",
"wMum67lfk5E1ohUObJgrOg==",
"wMyJLQJdmrC2TSeFkIuSvQ==",
"wOc4TbwQGUwOC1B3BEZ4OQ==",
"wOhbpTzmFla8R0kI9OiHaA==",
"wPhJcp7U7IVX83szbIOOxQ==",
"wQKL8Ga6JQkpZ7yymDkC3w==",
"wR2Gxb07nkaPcZHlEjr8iA==",
"wRqaDZVHHurp5whOQ1kDbQ==",
"wTO49YX/ePHMWtcoxUAHpw==",
"wUYhs4j3W9nIywu1HIv2JA==",
"wVfSZYjMjbTsD2gaSbwuqQ==",
"wX2URK6eDDHeEOF3cgPgHA==",
"wX70jKLKJApHnhyK0r6t3A==",
"wajwXfWz2J+O+NVaj6j2UQ==",
"wc+8ohFWgOF4VlSYiZIGwQ==",
"wdRyYjaM11VmqkkxV/5bsA==",
"wfwuxn+Vja1DNwiDwL2pcQ==",
"wgH1GlUxWi6/yLLFzE76uQ==",
"who8uUamlHWHXnBf7dwy4A==",
"wlWxtQDJ+siGhN2fJn3qtw==",
"wnfYUctNK+UPwefX5y4/Rw==",
"wpZqFkKafFpLcykN2IISqg==",
"wqUJ1Gq1Yz2cXFkbcCmzHQ==",
"wqWqe0KRjZlUIrGgEOG9Mg==",
"wrewZ0hoHODf7qmoGcOd7g==",
"wsp+vmW8sEqXYVURd/gjHA==",
"wt+qDLU38kzNU75ZYi3Hbw==",
"wtyAZIfhomcHe9dLbYoSvA==",
"wux5Y8AipBnc5tJapTzgEQ==",
"wv4NC9CIpwuGf/nOQYe/oA==",
"wxkb8evGEaGf/rg/1XUWiA==",
"wy/Z8505o4sVovk4UuBp1A==",
"wyqmQGB6vgRVrYtmB2vB7w==",
"wyx5mnUMgP5wjykjAfTO7w==",
"x+8rwkqKCv0juoT5m1A4eg==",
"x/BIDm6TKMhqu/gtb3kGyw==",
"x/MpsQvziUpW40nNUHDS5Q==",
"x0eIHCvQLd2jdDaXwSWTYQ==",
"x1A74vg/hwwjAx6GrkU8zw==",
"x2NpqNnqRihktNzpxmepkQ==",
"x2nSgcTjA3oGgI8mMgiqjw==",
"x5lyMArsv1MuJmEFlWCnNw==",
"x5zMDuW66467ofgL3spLUQ==",
"x6M66krXSi0EhppwmDmsxA==",
"x6lNRGgJcRxgKTlzhc1WPg==",
"x8kRVzohTdhkryvYeMvkMw==",
"x9TIZ9Ua++3BX+MpjgTuWA==",
"x9VwDdFPp/rJ+SF16ooWYg==",
"xAAipGfHTGTjp9Qk1MR8RQ==",
"xJi0T+psHOXMivSOVpMWeQ==",
"xLm/bJBonpTs0PwsF0DvRg==",
"xMIHeno2qj3V8q9H1xezeg==",
"xNilc7UOu1kyP0+nK5MrLw==",
"xPe76nHyHmald6kmMQsKdg==",
"xQpYjaAmrQudWgsdu24J0A==",
"xTizUioizbMQxD0T6fy/EQ==",
"xUXEE7OBBCudsQnuj5ycOA==",
"xWYecfzAtXT9WyQ8NYY/hw==",
"xX6atcCApI08oVLjjLteLg==",
"xYD8jrCDmuQna+p1ebnKDQ==",
"xbBxUP9JyY0wDgHDipBHeg==",
"xdCCdP8SNBOK3IsX6PiPQA==",
"xdmY+qyoxxuRZa9kuNpDEg==",
"xfYZ6qhWNBqqJ0PdWRjOwA==",
"xfjBQk3CrNjhufdPIhr91A==",
"xiFlcSfa/gnPiO+LwbixcQ==",
"xiyRfVG0EfBA+rCk+tgWRQ==",
"xjA21QjNdThLW3VV7SCnrg==",
"xjTMO2mvtpvwQrounD4e8g==",
"xktOghh1S9nIX6fXWnT+Ug==",
"xmGgK3W5y+oCd0K2u8XjZQ==",
"xmsYnsJq78/f9xuKuQ2pBQ==",
"xoPSM86Se+1hHX0y3hhdkw==",
"xs8J3cesq7lDhP/dNltqOw==",
"xsCZVhCk2qJmOqvUjK3Y8Q==",
"xsf0m31Am0W9eLhopAkfnA==",
"xukOAM0QVsA72qEy0yku9A==",
"xvipmmwKdYt4eoKvvRnjEg==",
"xweGAZf+Yb3TtwR/sGmGIA==",
"xzGzN5Hhbh0m/KezjNvXbQ==",
"y+1I05LDAYJ09tKMs3zW6g==",
"y+cl1/Knb9MZPz8nBB0M+w==",
"y/e3HSdg7T19FanRpJ7+7Q==",
"y1J+o6DC2sETFsySgpDZyA==",
"y2JOIoIiT9cV1VxplZPraQ==",
"y2Tn2gmhKs5WKc01ce74rg==",
"y4/HohCJxtt+cT7nLJB08w==",
"y4Y4mSSTw/WrIdRpktc5Hw==",
"y4iBxAMn/KzMmaWShdYiIw==",
"y4mfEDerrhaqApDdhP5vjA==",
"y7yS9x3yshVhMpDbQtfYOQ==",
"yCu+DVU/ceMTOZ5h/7wQTg==",
"yD3Dd4ToRrl53k/2NSCJiw==",
"yDrAd1ot38soBk7zKdnT8A==",
"yKLLiqzxfrCsr6+Rm6kx1Q==",
"yKrsKX4/1B1C0TyvciNz5w==",
"yL1DwlIIREPuyuCFULi0uw==",
"yLAhLNezvqVHmN1SfMRrPw==",
"yOE90OHQdyOfrAgwDvn2gA==",
"yPIeWcW8+3HjDagegrN8bw==",
"yQCLV9IoPyXEOaj3IdFMWw==",
"yQmNZnp/JZywbBiZs3gecA==",
"yS/yMnJDHW0iaOsbj4oPTg==",
"yTVJKBn72RjakMBXDoBKHg==",
"yTgN5xFIdz1MzFS6xMl5uQ==",
"yU3N0HMSP5etuHPNrVkZtg==",
"yV3IbbTWAbHMhMGVvgb/ZQ==",
"yYBIS9PZbKo7Gram7IXWPA==",
"yYVW07lOZHdgtX42xJONIA==",
"yYmnM/WOgi+48Rw7foGyXA==",
"yYp4iuI5f/y/l1AEJxYolQ==",
"ybpTgPr3SjJ12Rj5lC/IMA==",
"ycjv4XkS5O7zcF3sqq9MwQ==",
"yctId8ltkl3+xqi9bj+RqA==",
"ydVj2odhergi+2zGUwK4/A==",
"yf06Slv9l3IZEjVqvxP2aA==",
"yfAaL0MMtSXPQ37pBdmHxQ==",
"yhI5jHlfFJxu4eV5VJO2zQ==",
"yhRi5M9Etuu9HSu4d24i3w==",
"yhexr/OFKfZl0o3lS70e4w==",
"ylA6sU7Kaf9fMNIx1+sIlw==",
"ymtA8EMPMgmMcimWZZ0A1Q==",
"ynaj4XjU27b7XbqPyxI8Ig==",
"yqQPU4jT9XvRABZgNQXjgg==",
"yqtj8GfLaUHYv/BsdjxIVw==",
"ysRQ+7Aq7eVLOp88KnFVMA==",
"ytDXLDBqWiU1w3sTurYmaw==",
"yteeQr3ub2lDXgLziZV+DQ==",
"yxCyBXqGWA735JEyljDP7Q==",
"z+1oDVy8GJ5u/UDF+bIQdA==",
"z/e5M2lE9qh3bzB97jZCKA==",
"z0BU//aSjYHAkGGk3ZSGNg==",
"z20AAnvj7WsfJeOu3vemlA==",
"z3L2BNjQOMOfTVBUxcpnRA==",
"z4Bft++f72QeDh4PWGr/sw==",
"z4oKy2wKH+sbNSgGjbdHGw==",
"z5DveTu377UW8IHnsiUGZg==",
"z920R8eahJPiTsifrPYdxA==",
"z9cd+Qj+ueX34Zf3997MNQ==",
"zCRZgVsHbQZcVMHd9pGD3A==",
"zCpibjrZOA3FQ4lYt0WoVA==",
"zDSQ3NJuUGkVOlvVCATRwA==",
"zDUZCzQesFjO1JI3PwDjfg==",
"zEzWZ6l7EKoVUxvk/l78Mw==",
"zJ7ScHNxr2leCDNNcuDApA==",
"zNLlWGW/aKBhUwQZ4DZWoQ==",
"zVupSPz7cD0v/mD/eUIIjg==",
"zZtYkKU50PPEj6qSbO5/Sw==",
"za4rzveYVMFe3Gw531DQJQ==",
"zaqyy3GaJ7cp8qDoLJWcTw==",
"zbjXhZaeyMfdTb2zxvmRMg==",
"zeELfk015D5krExLKRUYtg==",
"zeHF6fdeqcOId3fRUGscRw==",
"zgEyxj/sCs63O98sZS94Yw==",
"zi04Yc01ZheuFAQc59E45A==",
"zirOtGUXeRL22ezfotZfQg==",
"zm+z+OOyHhljV2TjA3U9zw==",
"zrZWcqQsUE3ocWE0fG+SOA==",
"ztULoqHvCOE6qV7ocqa4/w==",
"zwQ/3MzTJ9rfBmrANIh14w==",
"zwY6tCjjya/bgrYaCncaag==",
"zxsSqovedB3HT99jVblCnQ==",
"zyA9f5J7mw5InjhcfeumAQ==",
]);
================================================
FILE: lib/HighlightsFeed.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 { actionTypes: at } = ChromeUtils.import(
"resource://activity-stream/common/Actions.jsm"
);
const { shortURL } = ChromeUtils.import(
"resource://activity-stream/lib/ShortURL.jsm"
);
const { SectionsManager } = ChromeUtils.import(
"resource://activity-stream/lib/SectionsManager.jsm"
);
const {
TOP_SITES_DEFAULT_ROWS,
TOP_SITES_MAX_SITES_PER_ROW,
} = ChromeUtils.import("resource://activity-stream/common/Reducers.jsm");
const { Dedupe } = ChromeUtils.import(
"resource://activity-stream/common/Dedupe.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"filterAdult",
"resource://activity-stream/lib/FilterAdult.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"LinksCache",
"resource://activity-stream/lib/LinksCache.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"NewTabUtils",
"resource://gre/modules/NewTabUtils.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"Screenshots",
"resource://activity-stream/lib/Screenshots.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"PageThumbs",
"resource://gre/modules/PageThumbs.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"DownloadsManager",
"resource://activity-stream/lib/DownloadsManager.jsm"
);
const HIGHLIGHTS_MAX_LENGTH = 16;
const MANY_EXTRA_LENGTH =
HIGHLIGHTS_MAX_LENGTH * 5 +
TOP_SITES_DEFAULT_ROWS * TOP_SITES_MAX_SITES_PER_ROW;
const SECTION_ID = "highlights";
const SYNC_BOOKMARKS_FINISHED_EVENT = "weave:engine:sync:applied";
const BOOKMARKS_RESTORE_SUCCESS_EVENT = "bookmarks-restore-success";
const BOOKMARKS_RESTORE_FAILED_EVENT = "bookmarks-restore-failed";
const RECENT_DOWNLOAD_THRESHOLD = 36 * 60 * 60 * 1000;
this.HighlightsFeed = class HighlightsFeed {
constructor() {
this.dedupe = new Dedupe(this._dedupeKey);
this.linksCache = new LinksCache(
NewTabUtils.activityStreamLinks,
"getHighlights",
["image"]
);
PageThumbs.addExpirationFilter(this);
this.downloadsManager = new DownloadsManager();
}
_dedupeKey(site) {
// Treat bookmarks, pocket, and downloaded items as un-dedupable, otherwise show one of a url
return (
site &&
(site.pocket_id || site.type === "bookmark" || site.type === "download"
? {}
: site.url)
);
}
init() {
Services.obs.addObserver(this, SYNC_BOOKMARKS_FINISHED_EVENT);
Services.obs.addObserver(this, BOOKMARKS_RESTORE_SUCCESS_EVENT);
Services.obs.addObserver(this, BOOKMARKS_RESTORE_FAILED_EVENT);
SectionsManager.onceInitialized(this.postInit.bind(this));
}
postInit() {
SectionsManager.enableSection(SECTION_ID);
this.fetchHighlights({ broadcast: true });
this.downloadsManager.init(this.store);
}
uninit() {
SectionsManager.disableSection(SECTION_ID);
PageThumbs.removeExpirationFilter(this);
Services.obs.removeObserver(this, SYNC_BOOKMARKS_FINISHED_EVENT);
Services.obs.removeObserver(this, BOOKMARKS_RESTORE_SUCCESS_EVENT);
Services.obs.removeObserver(this, BOOKMARKS_RESTORE_FAILED_EVENT);
}
observe(subject, topic, data) {
// When we receive a notification that a sync has happened for bookmarks,
// or Places finished importing or restoring bookmarks, refresh highlights
const manyBookmarksChanged =
(topic === SYNC_BOOKMARKS_FINISHED_EVENT && data === "bookmarks") ||
topic === BOOKMARKS_RESTORE_SUCCESS_EVENT ||
topic === BOOKMARKS_RESTORE_FAILED_EVENT;
if (manyBookmarksChanged) {
this.fetchHighlights({ broadcast: true });
}
}
filterForThumbnailExpiration(callback) {
const state = this.store
.getState()
.Sections.find(section => section.id === SECTION_ID);
callback(
state && state.initialized
? state.rows.reduce((acc, site) => {
// Screenshots call in `fetchImage` will search for preview_image_url or
// fallback to URL, so we prevent both from being expired.
acc.push(site.url);
if (site.preview_image_url) {
acc.push(site.preview_image_url);
}
return acc;
}, [])
: []
);
}
/**
* Chronologically sort highlights of all types except 'visited'. Then just append
* the rest at the end of highlights.
* @param {Array} pages The full list of links to order.
* @return {Array} A sorted array of highlights
*/
_orderHighlights(pages) {
const splitHighlights = { chronologicalCandidates: [], visited: [] };
for (let page of pages) {
if (page.type === "history") {
splitHighlights.visited.push(page);
} else {
splitHighlights.chronologicalCandidates.push(page);
}
}
return splitHighlights.chronologicalCandidates
.sort((a, b) => a.date_added < b.date_added)
.concat(splitHighlights.visited);
}
/**
* Refresh the highlights data for content.
* @param {bool} options.broadcast Should the update be broadcasted.
*/
async fetchHighlights(options = {}) {
// If TopSites are enabled we need them for deduping, so wait for
// TOP_SITES_UPDATED. We also need the section to be registered to update
// state, so wait for postInit triggered by SectionsManager initializing.
if (
(!this.store.getState().TopSites.initialized &&
this.store.getState().Prefs.values["feeds.topsites"]) ||
!this.store.getState().Sections.length
) {
return;
}
// We broadcast when we want to force an update, so get fresh links
if (options.broadcast) {
this.linksCache.expire();
}
// Request more than the expected length to allow for items being removed by
// deduping against Top Sites or multiple history from the same domain, etc.
const manyPages = await this.linksCache.request({
numItems: MANY_EXTRA_LENGTH,
excludeBookmarks: !this.store.getState().Prefs.values[
"section.highlights.includeBookmarks"
],
excludeHistory: !this.store.getState().Prefs.values[
"section.highlights.includeVisited"
],
excludePocket: !this.store.getState().Prefs.values[
"section.highlights.includePocket"
],
});
if (
this.store.getState().Prefs.values["section.highlights.includeDownloads"]
) {
// We only want 1 download that is less than 36 hours old, and the file currently exists
let results = await this.downloadsManager.getDownloads(
RECENT_DOWNLOAD_THRESHOLD,
{ numItems: 1, onlySucceeded: true, onlyExists: true }
);
if (results.length) {
// We only want 1 download, the most recent one
manyPages.push({
...results[0],
type: "download",
});
}
}
const orderedPages = this._orderHighlights(manyPages);
// Remove adult highlights if we need to
const checkedAdult = this.store.getState().Prefs.values.filterAdult
? filterAdult(orderedPages)
: orderedPages;
// Remove any Highlights that are in Top Sites already
const [, deduped] = this.dedupe.group(
this.store.getState().TopSites.rows,
checkedAdult
);
// Keep all "bookmark"s and at most one (most recent) "history" per host
const highlights = [];
const hosts = new Set();
for (const page of deduped) {
const hostname = shortURL(page);
// Skip this history page if we already something from the same host
if (page.type === "history" && hosts.has(hostname)) {
continue;
}
// If we already have the image for the card, use that immediately. Else
// asynchronously fetch the image. NEVER fetch a screenshot for downloads
if (!page.image && page.type !== "download") {
this.fetchImage(page);
}
// Adjust the type for 'history' items that are also 'bookmarked' when we
// want to include bookmarks
if (
page.type === "history" &&
page.bookmarkGuid &&
this.store.getState().Prefs.values[
"section.highlights.includeBookmarks"
]
) {
page.type = "bookmark";
}
// We want the page, so update various fields for UI
Object.assign(page, {
hasImage: page.type !== "download", // Downloads do not have an image - all else types fall back to a screenshot
hostname,
type: page.type,
pocket_id: page.pocket_id,
});
// Add the "bookmark", "pocket", or not-skipped "history"
highlights.push(page);
hosts.add(hostname);
// Remove internal properties that might be updated after dispatch
delete page.__sharedCache;
// Skip the rest if we have enough items
if (highlights.length === HIGHLIGHTS_MAX_LENGTH) {
break;
}
}
const { initialized } = this.store
.getState()
.Sections.find(section => section.id === SECTION_ID);
// Broadcast when required or if it is the first update.
const shouldBroadcast = options.broadcast || !initialized;
SectionsManager.updateSection(
SECTION_ID,
{ rows: highlights },
shouldBroadcast
);
}
/**
* Fetch an image for a given highlight and update the card with it. If no
* image is available then fallback to fetching a screenshot.
*/
fetchImage(page) {
// Request a screenshot if we don't already have one pending
const { preview_image_url: imageUrl, url } = page;
return Screenshots.maybeCacheScreenshot(
page,
imageUrl || url,
"image",
image => {
SectionsManager.updateSectionCard(SECTION_ID, url, { image }, true);
}
);
}
onAction(action) {
// Relay the downloads actions to DownloadsManager - it is a child of HighlightsFeed
this.downloadsManager.onAction(action);
switch (action.type) {
case at.INIT:
this.init();
break;
case at.SYSTEM_TICK:
case at.TOP_SITES_UPDATED:
this.fetchHighlights({ broadcast: false });
break;
case at.PREF_CHANGED:
// Update existing pages when the user changes what should be shown
if (action.data.name.startsWith("section.highlights.include")) {
this.fetchHighlights({ broadcast: true });
}
break;
case at.PLACES_HISTORY_CLEARED:
case at.PLACES_LINK_BLOCKED:
case at.DOWNLOAD_CHANGED:
case at.POCKET_LINK_DELETED_OR_ARCHIVED:
this.fetchHighlights({ broadcast: true });
break;
case at.PLACES_LINKS_CHANGED:
case at.PLACES_SAVED_TO_POCKET:
this.linksCache.expire();
this.fetchHighlights({ broadcast: false });
break;
case at.UNINIT:
this.uninit();
break;
}
}
};
const EXPORTED_SYMBOLS = [
"HighlightsFeed",
"SECTION_ID",
"MANY_EXTRA_LENGTH",
"SYNC_BOOKMARKS_FINISHED_EVENT",
"BOOKMARKS_RESTORE_SUCCESS_EVENT",
"BOOKMARKS_RESTORE_FAILED_EVENT",
];
================================================
FILE: lib/LinksCache.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 EXPORTED_SYMBOLS = ["LinksCache"];
// This should be slightly less than SYSTEM_TICK_INTERVAL as timer
// comparisons are too exact while the async/await functionality will make the
// last recorded time a little bit later. This causes the comparasion to skip
// updates.
// It should be 10% less than SYSTEM_TICK to update at least once every 5 mins.
// https://github.com/mozilla/activity-stream/pull/3695#discussion_r144678214
const EXPIRATION_TIME = 4.5 * 60 * 1000; // 4.5 minutes
/**
* Cache link results from a provided object property and refresh after some
* amount of time has passed. Allows for migrating data from previously cached
* links to the new links with the same url.
*/
this.LinksCache = class LinksCache {
/**
* Create a links cache for a given object property.
*
* @param {object} linkObject Object containing the link property
* @param {string} linkProperty Name of property on object to access
* @param {array} properties Optional properties list to migrate to new links.
* @param {function} shouldRefresh Optional callback receiving the old and new
* options to refresh even when not expired.
*/
constructor(
linkObject,
linkProperty,
properties = [],
shouldRefresh = () => {}
) {
this.clear();
// Allow getting links from both methods and array properties
this.linkGetter = options => {
const ret = linkObject[linkProperty];
return typeof ret === "function" ? ret.call(linkObject, options) : ret;
};
// Always migrate the shared cache data in addition to any custom properties
this.migrateProperties = ["__sharedCache", ...properties];
this.shouldRefresh = shouldRefresh;
}
/**
* Clear the cached data.
*/
clear() {
this.cache = Promise.resolve([]);
this.lastOptions = {};
this.expire();
}
/**
* Force the next request to update the cache.
*/
expire() {
delete this.lastUpdate;
}
/**
* Request data and update the cache if necessary.
*
* @param {object} options Optional data to pass to the underlying method.
* @returns {promise(array)} Links array with objects that can be modified.
*/
async request(options = {}) {
// Update the cache if the data has been expired
const now = Date.now();
if (
this.lastUpdate === undefined ||
now > this.lastUpdate + EXPIRATION_TIME ||
// Allow custom rules around refreshing based on options
this.shouldRefresh(this.lastOptions, options)
) {
// Update request state early so concurrent requests can refer to it
this.lastOptions = options;
this.lastUpdate = now;
// Save a promise before awaits, so other requests wait for correct data
// eslint-disable-next-line no-async-promise-executor
this.cache = new Promise(async (resolve, reject) => {
try {
// Allow fast lookup of old links by url that might need to migrate
const toMigrate = new Map();
for (const oldLink of await this.cache) {
if (oldLink) {
toMigrate.set(oldLink.url, oldLink);
}
}
// Update the cache with migrated links without modifying source objects
resolve(
(await this.linkGetter(options)).map(link => {
// Keep original array hole positions
if (!link) {
return link;
}
// Migrate data to the new link copy if we have an old link
const newLink = Object.assign({}, link);
const oldLink = toMigrate.get(newLink.url);
if (oldLink) {
for (const property of this.migrateProperties) {
const oldValue = oldLink[property];
if (oldValue !== undefined) {
newLink[property] = oldValue;
}
}
} else {
// Share data among link copies and new links from future requests
newLink.__sharedCache = {};
}
// Provide a helper to update the cached link
newLink.__sharedCache.updateLink = (property, value) => {
newLink[property] = value;
};
return newLink;
})
);
} catch (error) {
reject(error);
}
});
}
// Provide a shallow copy of the cached link objects for callers to modify
return (await this.cache).map(link => link && Object.assign({}, link));
}
};
================================================
FILE: lib/NaiveBayesTextTagger.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 { toksToTfIdfVector } = ChromeUtils.import(
"resource://activity-stream/lib/Tokenize.jsm"
);
this.NaiveBayesTextTagger = class NaiveBayesTextTagger {
constructor(model) {
this.model = model;
}
/**
* Determines if the tokenized text belongs to class according to binary naive Bayes
* classifier. Returns an object containing the class label ("label"), and
* the log probability ("logProb") that the text belongs to that class. If
* the positive class is more likely, then "label" is the positive class
* label. If the negative class is matched, then "label" is set to null.
*/
tagTokens(tokens) {
let fv = toksToTfIdfVector(tokens, this.model.vocab_idfs);
let bestLogProb = null;
let bestClassId = -1;
let bestClassLabel = null;
let logSumExp = 0.0; // will be P(x). Used to create a proper probability
for (let classId = 0; classId < this.model.classes.length; classId++) {
let classModel = this.model.classes[classId];
let classLogProb = classModel.log_prior;
// dot fv with the class model
for (let pair of Object.values(fv)) {
let [termId, tfidf] = pair;
classLogProb += tfidf * classModel.feature_log_probs[termId];
}
if (bestLogProb === null || classLogProb > bestLogProb) {
bestLogProb = classLogProb;
bestClassId = classId;
}
logSumExp += Math.exp(classLogProb);
}
// now normalize the probability by dividing by P(x)
logSumExp = Math.log(logSumExp);
bestLogProb -= logSumExp;
if (bestClassId === this.model.positive_class_id) {
bestClassLabel = this.model.positive_class_label;
} else {
bestClassLabel = null;
}
let confident =
bestClassId === this.model.positive_class_id &&
bestLogProb > this.model.positive_class_threshold_log_prob;
return {
label: bestClassLabel,
logProb: bestLogProb,
confident,
};
}
};
const EXPORTED_SYMBOLS = ["NaiveBayesTextTagger"];
================================================
FILE: lib/NewTabInit.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 { actionCreators: ac, actionTypes: at } = ChromeUtils.import(
"resource://activity-stream/common/Actions.jsm"
);
/**
* NewTabInit - A placeholder for now. This will send a copy of the state to all
* newly opened tabs.
*/
this.NewTabInit = class NewTabInit {
constructor() {
this._repliedEarlyTabs = new Map();
}
reply(target) {
// Skip this reply if we already replied to an early tab
if (this._repliedEarlyTabs.get(target)) {
return;
}
const action = {
type: at.NEW_TAB_INITIAL_STATE,
data: this.store.getState(),
};
this.store.dispatch(ac.AlsoToOneContent(action, target));
// Remember that this early tab has already gotten a rehydration response in
// case it thought we lost its initial REQUEST and asked again
if (this._repliedEarlyTabs.has(target)) {
this._repliedEarlyTabs.set(target, true);
}
}
onAction(action) {
switch (action.type) {
case at.NEW_TAB_STATE_REQUEST:
this.reply(action.meta.fromTarget);
break;
case at.NEW_TAB_INIT:
// Initialize data for early tabs that might REQUEST twice
if (action.data.simulated) {
this._repliedEarlyTabs.set(action.data.portID, false);
}
break;
case at.NEW_TAB_UNLOAD:
// Clean up for any tab (no-op if not an early tab)
this._repliedEarlyTabs.delete(action.meta.fromTarget);
break;
}
}
};
const EXPORTED_SYMBOLS = ["NewTabInit"];
================================================
FILE: lib/NmfTextTagger.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 { toksToTfIdfVector } = ChromeUtils.import(
"resource://activity-stream/lib/Tokenize.jsm"
);
this.NmfTextTagger = class NmfTextTagger {
constructor(model) {
this.model = model;
}
/**
* A multiclass classifier that scores tokenized text against several classes through
* inference of a nonnegative matrix factorization of TF-IDF vectors and
* class labels. Returns a map of class labels as string keys to scores.
* (Higher is more confident.) All classes get scored, so it is up to
* consumer of this data determine what classes are most valuable.
*/
tagTokens(tokens) {
let fv = toksToTfIdfVector(tokens, this.model.vocab_idfs);
let fve = Object.values(fv);
// normalize by the sum of the vector
let sum = 0.0;
for (let pair of fve) {
// eslint-disable-next-line prefer-destructuring
sum += pair[1];
}
for (let i = 0; i < fve.length; i++) {
// eslint-disable-next-line prefer-destructuring
fve[i][1] /= sum;
}
// dot the document with each topic vector so that we can transform it into
// the latent space
let toksInLatentSpace = [];
for (let topicVect of this.model.topic_word) {
let fvDotTwv = 0;
// dot fv with each topic word vector
for (let pair of fve) {
let [termId, tfidf] = pair;
fvDotTwv += tfidf * topicVect[termId];
}
toksInLatentSpace.push(fvDotTwv);
}
// now project toksInLatentSpace back into class space
let predictions = {};
Object.keys(this.model.document_topic).forEach(topic => {
let score = 0;
for (let i = 0; i < toksInLatentSpace.length; i++) {
score += toksInLatentSpace[i] * this.model.document_topic[topic][i];
}
predictions[topic] = score;
});
return predictions;
}
};
const EXPORTED_SYMBOLS = ["NmfTextTagger"];
================================================
FILE: lib/OnboardingMessageProvider.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";
/* globals Localization */
ChromeUtils.defineModuleGetter(
this,
"AttributionCode",
"resource:///modules/AttributionCode.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"AddonRepository",
"resource://gre/modules/addons/AddonRepository.jsm"
);
const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
const FX_MONITOR_CLIENT_ID = "802d56ef2a9af9fa";
const L10N = new Localization([
"branding/brand.ftl",
"browser/branding/brandings.ftl",
"browser/branding/sync-brand.ftl",
"browser/newtab/onboarding.ftl",
]);
const TRAILHEAD_ONBOARDING_TEMPLATE = {
trigger: { id: "firstRun" },
template: "trailhead",
includeBundle: {
length: 3,
template: "onboarding",
trigger: { id: "showOnboarding" },
},
};
const TRAILHEAD_MODAL_VARIANT_CONTENT = {
className: "joinCohort",
benefits: ["sync", "monitor", "lockwise"].map(id => ({
id,
title: { string_id: `onboarding-benefit-${id}-title` },
text: { string_id: `onboarding-benefit-${id}-text` },
})),
learn: {
text: { string_id: "onboarding-welcome-modal-family-learn-more" },
url: "https://www.mozilla.org/firefox/accounts/",
},
form: {
title: { string_id: "onboarding-welcome-form-header" },
text: { string_id: "onboarding-join-form-body" },
email: { string_id: "onboarding-join-form-email" },
button: { string_id: "onboarding-join-form-continue" },
},
skipButton: { string_id: "onboarding-start-browsing-button-label" },
};
const TRAILHEAD_FULL_PAGE_CONTENT = {
title: { string_id: "onboarding-welcome-body" },
learn: {
text: { string_id: "onboarding-welcome-learn-more" },
url: "https://www.mozilla.org/firefox/accounts/",
},
form: {
title: { string_id: "onboarding-welcome-form-header" },
text: { string_id: "onboarding-join-form-body" },
email: { string_id: "onboarding-fullpage-form-email" },
button: { string_id: "onboarding-join-form-continue" },
},
};
const JOIN_CONTENT = {
className: "joinCohort",
title: { string_id: "onboarding-welcome-body" },
benefits: ["products", "knowledge", "privacy"].map(id => ({
id,
title: { string_id: `onboarding-benefit-${id}-title` },
text: { string_id: `onboarding-benefit-${id}-text` },
})),
learn: {
text: { string_id: "onboarding-welcome-learn-more" },
url: "https://www.mozilla.org/firefox/accounts/",
},
form: {
title: { string_id: "onboarding-join-form-header" },
text: { string_id: "onboarding-join-form-body" },
email: { string_id: "onboarding-join-form-email" },
button: { string_id: "onboarding-join-form-continue" },
},
skipButton: { string_id: "onboarding-start-browsing-button-label" },
};
const ONBOARDING_MESSAGES = () => [
{
id: "TRAILHEAD_1",
utm_term: "trailhead-join",
...TRAILHEAD_ONBOARDING_TEMPLATE,
content: {
...JOIN_CONTENT,
},
},
{
id: "TRAILHEAD_2",
targeting: "trailheadInterrupt == 'sync'",
utm_term: "trailhead-sync",
...TRAILHEAD_ONBOARDING_TEMPLATE,
content: {
className: "syncCohort",
title: { string_id: "onboarding-sync-welcome-header" },
subtitle: { string_id: "onboarding-sync-welcome-content" },
benefits: [],
learn: {
text: { string_id: "onboarding-sync-welcome-learn-more-link" },
url: "https://www.mozilla.org/firefox/accounts/",
},
form: {
title: { string_id: "onboarding-sync-form-header" },
text: { string_id: "onboarding-sync-form-sub-header" },
email: { string_id: "onboarding-sync-form-input" },
button: { string_id: "onboarding-sync-form-continue-button" },
},
skipButton: { string_id: "onboarding-sync-form-skip-login-button" },
},
},
{
id: "TRAILHEAD_3",
targeting: "trailheadInterrupt == 'cards'",
utm_term: "trailhead-cards",
...TRAILHEAD_ONBOARDING_TEMPLATE,
},
{
id: "TRAILHEAD_4",
template: "trailhead",
targeting: "trailheadInterrupt == 'nofirstrun'",
trigger: { id: "firstRun" },
},
{
id: "TRAILHEAD_5",
targeting: "trailheadInterrupt == 'modal_control'",
utm_term: "trailhead-modal_control",
...TRAILHEAD_ONBOARDING_TEMPLATE,
content: {
...JOIN_CONTENT,
},
},
{
id: "TRAILHEAD_6",
targeting: "trailheadInterrupt == 'modal_variant_a'",
utm_term: "trailhead-modal_variant_a",
...TRAILHEAD_ONBOARDING_TEMPLATE,
content: {
...TRAILHEAD_MODAL_VARIANT_CONTENT,
title: { string_id: "onboarding-welcome-modal-get-body" },
},
},
{
id: "TRAILHEAD_7",
targeting: "trailheadInterrupt == 'modal_variant_b'",
utm_term: "trailhead-modal_variant_b",
...TRAILHEAD_ONBOARDING_TEMPLATE,
content: {
...TRAILHEAD_MODAL_VARIANT_CONTENT,
title: { string_id: "onboarding-welcome-modal-supercharge-body" },
},
},
{
id: "TRAILHEAD_8",
targeting: "trailheadInterrupt == 'modal_variant_c'",
utm_term: "trailhead-modal_variant_c",
...TRAILHEAD_ONBOARDING_TEMPLATE,
content: {
...TRAILHEAD_MODAL_VARIANT_CONTENT,
title: { string_id: "onboarding-welcome-modal-privacy-body" },
},
},
{
id: "TRAILHEAD_9",
targeting: "trailheadInterrupt == 'modal_variant_f'",
utm_term: "trailhead-modal_variant_f",
...TRAILHEAD_ONBOARDING_TEMPLATE,
content: {
...JOIN_CONTENT,
form: TRAILHEAD_MODAL_VARIANT_CONTENT.form,
},
},
{
id: "FULL_PAGE_1",
targeting: "trailheadInterrupt == 'full_page_d'",
utm_term: "trailhead-full_page_d",
...TRAILHEAD_ONBOARDING_TEMPLATE,
content: {
...TRAILHEAD_FULL_PAGE_CONTENT,
},
template: "full_page_interrupt",
},
{
id: "FULL_PAGE_2",
targeting: "trailheadInterrupt == 'full_page_e'",
utm_term: "trailhead-full_page_e",
...TRAILHEAD_ONBOARDING_TEMPLATE,
content: {
className: "fullPageCardsAtTop",
...TRAILHEAD_FULL_PAGE_CONTENT,
},
template: "full_page_interrupt",
},
{
id: "EXTENDED_TRIPLETS_1",
template: "extended_triplets",
campaign: "firstrun_triplets",
targeting:
"trailheadTriplet && ((currentDate|date - profileAgeCreated) / 86400000) < 7",
includeBundle: {
length: 3,
template: "onboarding",
trigger: { id: "showOnboarding" },
},
frequency: { lifetime: 5 },
utm_term: "trailhead-cards",
},
{
id: "TRAILHEAD_CARD_1",
template: "onboarding",
bundled: 3,
order: 2,
content: {
title: { string_id: "onboarding-tracking-protection-title2" },
text: { string_id: "onboarding-tracking-protection-text2" },
icon: "tracking",
primary_button: {
label: { string_id: "onboarding-tracking-protection-button2" },
action:
Services.locale.appLocaleAsLangTag.substr(0, 2) === "en"
? {
type: "OPEN_URL",
data: {
args: "https://mzl.la/ETPdefault",
where: "tabshifted",
},
}
: {
type: "OPEN_PREFERENCES_PAGE",
data: { category: "privacy-trackingprotection" },
},
},
},
targeting: "trailheadTriplet == 'privacy'",
trigger: { id: "showOnboarding" },
},
{
id: "TRAILHEAD_CARD_2",
template: "onboarding",
bundled: 3,
order: 1,
content: {
title: { string_id: "onboarding-data-sync-title" },
text: { string_id: "onboarding-data-sync-text2" },
icon: "devices",
primary_button: {
label: { string_id: "onboarding-data-sync-button2" },
action: {
type: "OPEN_URL",
addFlowParams: true,
data: {
args:
"https://accounts.firefox.com/?service=sync&action=email&context=fx_desktop_v3&entrypoint=activity-stream-firstrun&style=trailhead",
where: "tabshifted",
},
},
},
},
targeting:
"trailheadTriplet in ['supercharge', 'static'] || ( 'dynamic' in trailheadTriplet && usesFirefoxSync == false)",
trigger: { id: "showOnboarding" },
},
{
id: "TRAILHEAD_CARD_3",
template: "onboarding",
bundled: 3,
order: 2,
content: {
title: { string_id: "onboarding-firefox-monitor-title" },
text: { string_id: "onboarding-firefox-monitor-text2" },
icon: "ffmonitor",
primary_button: {
label: { string_id: "onboarding-firefox-monitor-button" },
action: {
type: "OPEN_URL",
data: { args: "https://monitor.firefox.com/", where: "tabshifted" },
},
},
},
// Use service oauth client_id to identify 'Firefox Monitor' service attached to Firefox Account
// https://docs.telemetry.mozilla.org/datasets/fxa_metrics/attribution.html#service-attribution
targeting: `trailheadTriplet in ['supercharge', 'static'] || ('dynamic' in trailheadTriplet && !("${FX_MONITOR_CLIENT_ID}" in attachedFxAOAuthClients|mapToProperty('id')))`,
trigger: { id: "showOnboarding" },
},
{
id: "TRAILHEAD_CARD_4",
template: "onboarding",
bundled: 3,
order: 3,
content: {
title: { string_id: "onboarding-browse-privately-title" },
text: { string_id: "onboarding-browse-privately-text" },
icon: "private",
primary_button: {
label: { string_id: "onboarding-browse-privately-button" },
action: { type: "OPEN_PRIVATE_BROWSER_WINDOW" },
},
},
targeting: "'dynamic' in trailheadTriplet",
trigger: { id: "showOnboarding" },
},
{
id: "TRAILHEAD_CARD_5",
template: "onboarding",
bundled: 3,
order: 5,
content: {
title: { string_id: "onboarding-firefox-send-title" },
text: { string_id: "onboarding-firefox-send-text2" },
icon: "ffsend",
primary_button: {
label: { string_id: "onboarding-firefox-send-button" },
action: {
type: "OPEN_URL",
data: { args: "https://send.firefox.com/", where: "tabshifted" },
},
},
},
targeting: "trailheadTriplet == 'payoff'",
trigger: { id: "showOnboarding" },
},
{
id: "TRAILHEAD_CARD_6",
template: "onboarding",
bundled: 3,
order: 6,
content: {
title: { string_id: "onboarding-mobile-phone-title" },
text: { string_id: "onboarding-mobile-phone-text" },
icon: "mobile",
primary_button: {
label: { string_id: "onboarding-mobile-phone-button" },
action: {
type: "OPEN_URL",
data: {
args: "https://www.mozilla.org/firefox/mobile/",
where: "tabshifted",
},
},
},
},
targeting:
"trailheadTriplet in ['supercharge', 'static'] || ('dynamic' in trailheadTriplet && sync.mobileDevices < 1)",
trigger: { id: "showOnboarding" },
},
{
id: "TRAILHEAD_CARD_7",
template: "onboarding",
bundled: 3,
order: 4,
content: {
title: { string_id: "onboarding-send-tabs-title" },
text: { string_id: "onboarding-send-tabs-text2" },
icon: "sendtab",
primary_button: {
label: { string_id: "onboarding-send-tabs-button" },
action: {
type: "OPEN_URL",
data: {
args:
"https://support.mozilla.org/kb/send-tab-firefox-desktop-other-devices",
where: "tabshifted",
},
},
},
},
targeting: "'dynamic' in trailheadTriplet",
trigger: { id: "showOnboarding" },
},
{
id: "TRAILHEAD_CARD_8",
template: "onboarding",
bundled: 3,
order: 2,
content: {
title: { string_id: "onboarding-pocket-anywhere-title" },
text: { string_id: "onboarding-pocket-anywhere-text2" },
icon: "pocket",
primary_button: {
label: { string_id: "onboarding-pocket-anywhere-button" },
action: {
type: "OPEN_URL",
data: {
args: "https://getpocket.com/firefox_learnmore",
where: "tabshifted",
},
},
},
},
targeting: "trailheadTriplet == 'multidevice'",
trigger: { id: "showOnboarding" },
},
{
id: "TRAILHEAD_CARD_9",
template: "onboarding",
bundled: 3,
order: 7,
content: {
title: { string_id: "onboarding-lockwise-strong-passwords-title" },
text: { string_id: "onboarding-lockwise-strong-passwords-text" },
icon: "lockwise",
primary_button: {
label: { string_id: "onboarding-lockwise-strong-passwords-button" },
action: {
type: "OPEN_ABOUT_PAGE",
data: { args: "logins", where: "tabshifted" },
},
},
},
targeting: "'dynamic' in trailheadTriplet",
trigger: { id: "showOnboarding" },
},
{
id: "TRAILHEAD_CARD_10",
template: "onboarding",
bundled: 3,
order: 4,
content: {
title: { string_id: "onboarding-facebook-container-title" },
text: { string_id: "onboarding-facebook-container-text2" },
icon: "fbcont",
primary_button: {
label: { string_id: "onboarding-facebook-container-button" },
action: {
type: "OPEN_URL",
data: {
args:
"https://addons.mozilla.org/firefox/addon/facebook-container/",
where: "tabshifted",
},
},
},
},
targeting: "trailheadTriplet == 'payoff'",
trigger: { id: "showOnboarding" },
},
{
id: "TRAILHEAD_CARD_11",
template: "onboarding",
bundled: 3,
order: 0,
content: {
title: { string_id: "onboarding-import-browser-settings-title" },
text: { string_id: "onboarding-import-browser-settings-text" },
icon: "import",
primary_button: {
label: { string_id: "onboarding-import-browser-settings-button" },
action: { type: "SHOW_MIGRATION_WIZARD" },
},
},
targeting: "trailheadTriplet == 'dynamic_chrome'",
trigger: { id: "showOnboarding" },
},
{
id: "RETURN_TO_AMO_1",
template: "return_to_amo_overlay",
content: {
header: { string_id: "onboarding-welcome-header" },
title: { string_id: "return-to-amo-sub-header" },
addon_icon: null,
icon: "gift-extension",
text: {
string_id: "return-to-amo-addon-header",
args: { "addon-name": null },
},
primary_button: {
label: { string_id: "return-to-amo-extension-button" },
action: {
type: "INSTALL_ADDON_FROM_URL",
data: { url: null, telemetrySource: "rtamo" },
},
},
secondary_button: {
label: { string_id: "return-to-amo-get-started-button" },
},
},
includeBundle: {
length: 3,
template: "onboarding",
trigger: { id: "showOnboarding" },
},
targeting:
"attributionData.campaign == 'non-fx-button' && attributionData.source == 'addons.mozilla.org'",
trigger: { id: "firstRun" },
},
{
id: "FXA_ACCOUNTS_BADGE",
template: "toolbar_badge",
content: {
delay: 10000, // delay for 10 seconds
target: "fxa-toolbar-menu-button",
},
// Never accessed the FxA panel && doesn't use Firefox sync & has FxA enabled
targeting: `isFxABadgeEnabled && !hasAccessedFxAPanel && !usesFirefoxSync && isFxAEnabled == true`,
trigger: { id: "toolbarBadgeUpdate" },
},
{
id: "PROTECTIONS_PANEL_1",
template: "protections_panel",
content: {
title: { string_id: "cfr-protections-panel-header" },
body: { string_id: "cfr-protections-panel-body" },
link_text: { string_id: "cfr-protections-panel-link-text" },
cta_url: `${Services.urlFormatter.formatURLPref(
"app.support.baseURL"
)}etp-promotions?as=u&utm_source=inproduct`,
cta_type: "OPEN_URL",
},
trigger: { id: "protectionsPanelOpen" },
},
];
const OnboardingMessageProvider = {
async getExtraAttributes() {
const [header, button_label] = await L10N.formatMessages([
{ id: "onboarding-welcome-header" },
{ id: "onboarding-start-browsing-button-label" },
]);
return { header: header.value, button_label: button_label.value };
},
async getMessages() {
const messages = await this.translateMessages(await ONBOARDING_MESSAGES());
return messages;
},
async getUntranslatedMessages() {
// This is helpful for jsonSchema testing - since we are localizing in the provider
const messages = await ONBOARDING_MESSAGES();
return messages;
},
async translateMessages(messages) {
let translatedMessages = [];
for (const msg of messages) {
let translatedMessage = { ...msg };
// If the message has no content, do not attempt to translate it
if (!translatedMessage.content) {
translatedMessages.push(translatedMessage);
continue;
}
// We need some addon info if we are showing return to amo overlay, so fetch
// that, and update the message accordingly
if (msg.template === "return_to_amo_overlay") {
try {
const { name, iconURL, url } = await this.getAddonInfo();
// If we do not have all the data from the AMO api to indicate to the user
// what they are installing we don't want to show the message
if (!name || !iconURL || !url) {
continue;
}
msg.content.text.args["addon-name"] = name;
msg.content.addon_icon = iconURL;
msg.content.primary_button.action.data.url = url;
} catch (e) {
continue;
}
// We know we want to show this message, so translate message strings
const [
primary_button_string,
title_string,
text_string,
] = await L10N.formatMessages([
{ id: msg.content.primary_button.label.string_id },
{ id: msg.content.title.string_id },
{ id: msg.content.text.string_id, args: msg.content.text.args },
]);
translatedMessage.content.primary_button.label =
primary_button_string.value;
translatedMessage.content.title = title_string.value;
translatedMessage.content.text = text_string.value;
}
// Translate any secondary buttons separately
if (msg.content.secondary_button) {
const [secondary_button_string] = await L10N.formatMessages([
{ id: msg.content.secondary_button.label.string_id },
]);
translatedMessage.content.secondary_button.label =
secondary_button_string.value;
}
if (msg.content.header) {
const [header_string] = await L10N.formatMessages([
{ id: msg.content.header.string_id },
]);
translatedMessage.content.header = header_string.value;
}
translatedMessages.push(translatedMessage);
}
return translatedMessages;
},
async getAddonInfo() {
try {
let { content, source } = await AttributionCode.getAttrDataAsync();
if (!content || source !== "addons.mozilla.org") {
return null;
}
// Attribution data can be double encoded
while (content.includes("%")) {
try {
const result = decodeURIComponent(content);
if (result === content) {
break;
}
content = result;
} catch (e) {
break;
}
}
const [addon] = await AddonRepository.getAddonsByIDs([content]);
if (addon.sourceURI.scheme !== "https") {
return null;
}
return {
name: addon.name,
url: addon.sourceURI.spec,
iconURL: addon.icons["64"] || addon.icons["32"],
};
} catch (e) {
Cu.reportError(
"Failed to get the latest add-on version for Return to AMO"
);
return null;
}
},
};
this.OnboardingMessageProvider = OnboardingMessageProvider;
const EXPORTED_SYMBOLS = ["OnboardingMessageProvider"];
================================================
FILE: lib/PanelTestProvider.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 TWO_DAYS = 2 * 24 * 3600 * 1000;
const MESSAGES = () => [
{
id: "SIMPLE_FXA_BOOKMARK_TEST_FLUENT",
template: "fxa_bookmark_panel",
content: {
title: { string_id: "cfr-doorhanger-bookmark-fxa-header" },
text: { string_id: "cfr-doorhanger-bookmark-fxa-body" },
cta: { string_id: "cfr-doorhanger-bookmark-fxa-link-text" },
color: "white",
background_color_1: "#7d31ae",
background_color_2: "#5033be",
info_icon: {
tooltiptext: {
string_id: "cfr-doorhanger-bookmark-fxa-info-icon-tooltip",
},
},
close_button: {
tooltiptext: {
string_id: "cfr-doorhanger-bookmark-fxa-close-btn-tooltip",
},
},
},
trigger: { id: "bookmark-panel" },
},
{
id: "SIMPLE_FXA_BOOKMARK_TEST_NON_FLUENT",
template: "fxa_bookmark_panel",
content: {
title: "Bookmark Message Title",
text: "Bookmark Message Body",
cta: "Sync bookmarks now",
color: "white",
background_color_1: "#7d31ae",
background_color_2: "#5033be",
info_icon: {
tooltiptext: "Toggle tooltip",
},
close_button: {
tooltiptext: "Close tooltip",
},
},
trigger: { id: "bookmark-panel" },
},
{
id: "WNP_THANK_YOU",
template: "update_action",
content: {
action: {
id: "moments-wnp",
data: {
url:
"https://www.mozilla.org/%LOCALE%/etc/firefox/retention/thank-you-a/",
expireDelta: TWO_DAYS,
},
},
},
trigger: { id: "momentsUpdate" },
},
{
id: "WHATS_NEW_PIP_72",
template: "whatsnew_panel_message",
order: 4,
content: {
bucket_id: "WHATS_NEW_72",
published_date: 1574776601000,
title: { string_id: "cfr-whatsnew-pip-header" },
icon_url:
"resource://activity-stream/data/content/assets/remote/pip-message-icon.svg",
icon_alt: "",
body: { string_id: "cfr-whatsnew-pip-body" },
cta_url:
"https://support.mozilla.org/1/firefox/%VERSION%/%OS%/%LOCALE%/whats-new-notifications",
cta_type: "OPEN_URL",
link_text: { string_id: "cfr-whatsnew-pip-cta" },
},
targeting: `firefoxVersion >= 72`,
trigger: { id: "whatsNewPanelOpened" },
},
{
id: "WHATS_NEW_PERMISSION_PROMPT_72",
template: "whatsnew_panel_message",
order: 5,
content: {
bucket_id: "WHATS_NEW_72",
published_date: 1574776601000,
title: { string_id: "cfr-whatsnew-permission-prompt-header" },
body: { string_id: "cfr-whatsnew-permission-prompt-body" },
cta_url:
"https://support.mozilla.org/1/firefox/%VERSION%/%OS%/%LOCALE%/whats-new-notifications",
cta_type: "OPEN_URL",
link_text: { string_id: "cfr-whatsnew-permission-prompt-cta" },
},
targeting: `firefoxVersion >= 72`,
trigger: { id: "whatsNewPanelOpened" },
},
{
id: "WHATS_NEW_FINGERPRINTER_COUNTER_72",
template: "whatsnew_panel_message",
order: 6,
content: {
bucket_id: "WHATS_NEW_72",
published_date: 1574776601000,
layout: "tracking-protections",
layout_title_content_variable: "fingerprinterCount",
title: { string_id: "cfr-whatsnew-fingerprinter-counter-header" },
subtitle: { string_id: "cfr-whatsnew-tracking-blocked-subtitle" },
icon_url:
"resource://activity-stream/data/content/assets/protection-report-icon.png",
icon_alt: "",
body: { string_id: "cfr-whatsnew-fingerprinter-counter-body" },
link_text: { string_id: "cfr-whatsnew-tracking-blocked-link-text" },
cta_url: "protections",
cta_type: "OPEN_ABOUT_PAGE",
},
targeting: `firefoxVersion >= 72`,
trigger: { id: "whatsNewPanelOpened" },
},
{
id: "WHATS_NEW_FINGERPRINTER_COUNTER_ALT",
template: "whatsnew_panel_message",
order: 6,
content: {
bucket_id: "WHATS_NEW_72",
published_date: 1574776601000,
title: { string_id: "cfr-whatsnew-fingerprinter-counter-header-alt" },
icon_url:
"resource://activity-stream/data/content/assets/protection-report-icon.png",
icon_alt: "",
body: { string_id: "cfr-whatsnew-fingerprinter-counter-body-alt" },
link_text: { string_id: "cfr-whatsnew-tracking-blocked-link-text" },
cta_url: "protections",
cta_type: "OPEN_ABOUT_PAGE",
},
targeting: `firefoxVersion >= 72`,
trigger: { id: "whatsNewPanelOpened" },
},
{
id: "WHATS_NEW_70_1",
template: "whatsnew_panel_message",
order: 3,
content: {
bucket_id: "WHATS_NEW_70_1",
published_date: 1560969794394,
title: "Protection Is Our Focus",
icon_url:
"resource://activity-stream/data/content/assets/whatsnew-send-icon.png",
icon_alt: "Firefox Send Logo",
body:
"The New Enhanced Tracking Protection, gives you the best level of protection and performance. Discover how this version is the safest version of firefox ever made.",
cta_url: "https://blog.mozilla.org/",
cta_type: "OPEN_URL",
},
targeting: `firefoxVersion > 69`,
trigger: { id: "whatsNewPanelOpened" },
},
{
id: "WHATS_NEW_70_2",
template: "whatsnew_panel_message",
order: 1,
content: {
bucket_id: "WHATS_NEW_70_1",
published_date: 1560969794394,
title: "Another thing new in Firefox 70",
body:
"The New Enhanced Tracking Protection, gives you the best level of protection and performance. Discover how this version is the safest version of firefox ever made.",
link_text: "Learn more on our blog",
cta_url: "https://blog.mozilla.org/",
cta_type: "OPEN_URL",
},
targeting: `firefoxVersion > 69`,
trigger: { id: "whatsNewPanelOpened" },
},
{
id: "WHATS_NEW_69_1",
template: "whatsnew_panel_message",
order: 1,
content: {
bucket_id: "WHATS_NEW_69_1",
published_date: 1557346235089,
title: "Something new in Firefox 69",
body:
"The New Enhanced Tracking Protection, gives you the best level of protection and performance. Discover how this version is the safest version of firefox ever made.",
link_text: "Learn more on our blog",
cta_url: "https://blog.mozilla.org/",
cta_type: "OPEN_URL",
},
targeting: `firefoxVersion > 68`,
trigger: { id: "whatsNewPanelOpened" },
},
{
id: "WHATS_NEW_70_3",
template: "whatsnew_panel_message",
order: 2,
content: {
bucket_id: "WHATS_NEW_70_3",
published_date: 1560969794394,
layout: "tracking-protections",
layout_title_content_variable: "blockedCount",
title: { string_id: "cfr-whatsnew-tracking-blocked-title" },
subtitle: { string_id: "cfr-whatsnew-tracking-blocked-subtitle" },
icon_url:
"resource://activity-stream/data/content/assets/protection-report-icon.png",
icon_alt: "Protection Report icon",
body: { string_id: "cfr-whatsnew-tracking-protect-body" },
link_text: { string_id: "cfr-whatsnew-tracking-blocked-link-text" },
cta_url: "protections",
cta_type: "OPEN_ABOUT_PAGE",
},
targeting: `firefoxVersion > 69 && totalBlockedCount > 0`,
trigger: { id: "whatsNewPanelOpened" },
},
{
id: "BOOKMARK_CFR",
template: "cfr_doorhanger",
content: {
layout: "icon_and_message",
category: "cfrFeatures",
notification_text: { string_id: "cfr-doorhanger-feature-notification" },
heading_text: { string_id: "cfr-doorhanger-sync-bookmarks-header" },
info_icon: {
label: { string_id: "cfr-doorhanger-extension-sumo-link" },
sumo_path: "https://example.com",
},
text: { string_id: "cfr-doorhanger-sync-bookmarks-body" },
icon: "chrome://branding/content/icon64.png",
buttons: {
primary: {
label: { string_id: "cfr-doorhanger-sync-bookmarks-ok-button" },
action: {
type: "OPEN_PREFERENCES_PAGE",
data: { category: "sync" },
},
},
secondary: [
{
label: { string_id: "cfr-doorhanger-extension-cancel-button" },
action: { type: "CANCEL" },
},
{
label: {
string_id: "cfr-doorhanger-extension-never-show-recommendation",
},
},
{
label: {
string_id: "cfr-doorhanger-extension-manage-settings-button",
},
action: {
type: "OPEN_PREFERENCES_PAGE",
data: { category: "general-cfrfeatures" },
},
},
],
},
},
targeting: "true",
trigger: {
id: "openBookmarkedURL",
},
},
{
id: "PDF_URL_FFX_SEND",
template: "cfr_doorhanger",
content: {
layout: "icon_and_message",
category: "cfrFeatures",
notification_text: { string_id: "cfr-doorhanger-extension-notification" },
heading_text: { string_id: "cfr-doorhanger-firefox-send-header" },
info_icon: {
label: { string_id: "cfr-doorhanger-extension-sumo-link" },
sumo_path: "https://example.com",
},
text: { string_id: "cfr-doorhanger-firefox-send-body" },
icon: "chrome://branding/content/icon64.png",
buttons: {
primary: {
label: { string_id: "cfr-doorhanger-firefox-send-ok-button" },
action: {
type: "OPEN_URL",
data: {
args:
"https://send.firefox.com/login/?utm_source=activity-stream&entrypoint=activity-stream-cfr-pdf",
where: "tabshifted",
},
},
},
secondary: [
{
label: { string_id: "cfr-doorhanger-extension-cancel-button" },
action: { type: "CANCEL" },
},
{
label: {
string_id: "cfr-doorhanger-extension-never-show-recommendation",
},
},
{
label: {
string_id: "cfr-doorhanger-extension-manage-settings-button",
},
action: {
type: "OPEN_PREFERENCES_PAGE",
data: { category: "general-cfrfeatures" },
},
},
],
},
},
targeting: "true",
trigger: {
id: "openURL",
patterns: ["*://*/*.pdf"],
},
},
{
id: "SEND_TAB_CFR",
template: "cfr_doorhanger",
content: {
layout: "icon_and_message",
category: "cfrFeatures",
notification_text: { string_id: "cfr-doorhanger-extension-notification" },
heading_text: { string_id: "cfr-doorhanger-send-tab-header" },
info_icon: {
label: { string_id: "cfr-doorhanger-extension-sumo-link" },
sumo_path: "https://example.com",
},
text: { string_id: "cfr-doorhanger-send-tab-body" },
icon: "chrome://branding/content/icon64.png",
buttons: {
primary: {
label: { string_id: "cfr-doorhanger-send-tab-ok-button" },
action: {
type: "HIGHLIGHT_FEATURE",
data: { args: "pageAction-sendToDevice" },
},
},
secondary: [
{
label: { string_id: "cfr-doorhanger-extension-cancel-button" },
action: { type: "CANCEL" },
},
{
label: {
string_id: "cfr-doorhanger-extension-never-show-recommendation",
},
},
{
label: {
string_id: "cfr-doorhanger-extension-manage-settings-button",
},
action: {
type: "OPEN_PREFERENCES_PAGE",
data: { category: "general-cfrfeatures" },
},
},
],
},
},
targeting: "true",
trigger: {
// Match any URL that has a Reader Mode icon
id: "openArticleURL",
patterns: ["*://*/*"],
},
},
{
id: "SEND_RECIPE_TAB_CFR",
template: "cfr_doorhanger",
// Higher priority because this has the same targeting rules as
// SEND_TAB_CFR but is more specific
priority: 1,
content: {
layout: "icon_and_message",
category: "cfrFeatures",
notification_text: { string_id: "cfr-doorhanger-extension-notification" },
heading_text: { string_id: "cfr-doorhanger-send-tab-recipe-header" },
info_icon: {
label: { string_id: "cfr-doorhanger-extension-sumo-link" },
sumo_path: "https://example.com",
},
text: { string_id: "cfr-doorhanger-send-tab-body" },
icon: "chrome://branding/content/icon64.png",
buttons: {
primary: {
label: { string_id: "cfr-doorhanger-send-tab-ok-button" },
action: {
type: "HIGHLIGHT_FEATURE",
data: { args: "pageAction-sendToDevice" },
},
},
secondary: [
{
label: { string_id: "cfr-doorhanger-extension-cancel-button" },
action: { type: "CANCEL" },
},
{
label: {
string_id: "cfr-doorhanger-extension-never-show-recommendation",
},
},
{
label: {
string_id: "cfr-doorhanger-extension-manage-settings-button",
},
action: {
type: "OPEN_PREFERENCES_PAGE",
data: { category: "general-cfrfeatures" },
},
},
],
},
},
targeting: "true",
trigger: {
id: "openArticleURL",
params: ["www.allrecipes.com", "allrecipes.com"],
},
},
{
id: "PERSONALIZED_CFR_MESSAGE",
template: "cfr_doorhanger",
content: {
layout: "icon_and_message",
category: "cfrFeatures",
notification_text: "Personalized CFR Recommendation",
heading_text: { string_id: "cfr-doorhanger-firefox-send-header" },
info_icon: {
label: { string_id: "cfr-doorhanger-extension-sumo-link" },
sumo_path: "https://example.com",
},
text: { string_id: "cfr-doorhanger-firefox-send-body" },
icon: "chrome://branding/content/icon64.png",
buttons: {
primary: {
label: { string_id: "cfr-doorhanger-firefox-send-ok-button" },
action: {
type: "OPEN_URL",
data: {
args:
"https://send.firefox.com/login/?utm_source=activity-stream&entrypoint=activity-stream-cfr-pdf",
where: "tabshifted",
},
},
},
secondary: [
{
label: { string_id: "cfr-doorhanger-extension-cancel-button" },
action: { type: "CANCEL" },
},
{
label: {
string_id: "cfr-doorhanger-extension-never-show-recommendation",
},
},
{
label: {
string_id: "cfr-doorhanger-extension-manage-settings-button",
},
action: {
type: "OPEN_PREFERENCES_PAGE",
data: { category: "general-cfrfeatures" },
},
},
],
},
},
targeting: "scores.PERSONALIZED_CFR_MESSAGE.score > scoreThreshold",
trigger: {
id: "openURL",
patterns: ["*://*/*.pdf"],
},
},
];
const PanelTestProvider = {
getMessages() {
return MESSAGES().map(message => ({
...message,
targeting: `providerCohorts.panel_local_testing == "SHOW_TEST"`,
}));
},
};
this.PanelTestProvider = PanelTestProvider;
const EXPORTED_SYMBOLS = ["PanelTestProvider"];
================================================
FILE: lib/PersistentCache.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 { XPCOMUtils } = ChromeUtils.import(
"resource://gre/modules/XPCOMUtils.jsm"
);
ChromeUtils.defineModuleGetter(this, "OS", "resource://gre/modules/osfile.jsm");
XPCOMUtils.defineLazyGlobalGetters(this, ["fetch"]);
/**
* A file (disk) based persistent cache of a JSON serializable object.
*/
this.PersistentCache = class PersistentCache {
/**
* Create a cache object based on a name.
*
* @param {string} name Name of the cache. It will be used to create the filename.
* @param {boolean} preload (optional). Whether the cache should be preloaded from file. Defaults to false.
*/
constructor(name, preload = false) {
this.name = name;
this._filename = `activity-stream.${name}.json`;
if (preload) {
this._load();
}
}
/**
* Set a value to be cached with the specified key.
*
* @param {string} key The cache key.
* @param {object} value The data to be cached.
*/
async set(key, value) {
const data = await this._load();
data[key] = value;
await this._persist(data);
}
/**
* Get a value from the cache.
*
* @param {string} key (optional) The cache key. If not provided, we return the full cache.
* @returns {object} The cached data.
*/
async get(key) {
const data = await this._load();
return key ? data[key] : data;
}
/**
* Load the cache into memory if it isn't already.
*/
_load() {
return (
this._cache ||
// eslint-disable-next-line no-async-promise-executor
(this._cache = new Promise(async (resolve, reject) => {
let filepath;
try {
filepath = OS.Path.join(
OS.Constants.Path.localProfileDir,
this._filename
);
} catch (error) {
reject(error);
return;
}
let file;
try {
file = await fetch(`file://${filepath}`);
} catch (error) {} // Cache file doesn't exist yet.
let data = {};
if (file) {
try {
data = await file.json();
} catch (error) {
Cu.reportError(
`Failed to parse ${this._filename}: ${error.message}`
);
}
}
resolve(data);
}))
);
}
/**
* Persist the cache to file.
*/
_persist(data) {
const filepath = OS.Path.join(
OS.Constants.Path.localProfileDir,
this._filename
);
return OS.File.writeAtomic(filepath, JSON.stringify(data), {
tmpPath: `${filepath}.tmp`,
});
}
};
const EXPORTED_SYMBOLS = ["PersistentCache"];
================================================
FILE: lib/PersonalityProvider.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 { RemoteSettings } = ChromeUtils.import(
"resource://services-settings/remote-settings.js"
);
const { actionCreators: ac } = ChromeUtils.import(
"resource://activity-stream/common/Actions.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"perfService",
"resource://activity-stream/common/PerfService.jsm"
);
const { NaiveBayesTextTagger } = ChromeUtils.import(
"resource://activity-stream/lib/NaiveBayesTextTagger.jsm"
);
const { NmfTextTagger } = ChromeUtils.import(
"resource://activity-stream/lib/NmfTextTagger.jsm"
);
const { RecipeExecutor } = ChromeUtils.import(
"resource://activity-stream/lib/RecipeExecutor.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"NewTabUtils",
"resource://gre/modules/NewTabUtils.jsm"
);
const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
const { XPCOMUtils } = ChromeUtils.import(
"resource://gre/modules/XPCOMUtils.jsm"
);
ChromeUtils.defineModuleGetter(this, "OS", "resource://gre/modules/osfile.jsm");
XPCOMUtils.defineLazyGlobalGetters(this, ["fetch"]);
XPCOMUtils.defineLazyGetter(this, "gTextDecoder", () => new TextDecoder());
XPCOMUtils.defineLazyGetter(this, "baseAttachmentsURL", async () => {
const server = Services.prefs.getCharPref("services.settings.server");
const serverInfo = await (await fetch(`${server}/`, {
credentials: "omit",
})).json();
const {
capabilities: {
attachments: { base_url },
},
} = serverInfo;
return base_url;
});
const PERSONALITY_PROVIDER_DIR = OS.Path.join(
OS.Constants.Path.localProfileDir,
"personality-provider"
);
const RECIPE_NAME = "personality-provider-recipe";
const MODELS_NAME = "personality-provider-models";
function getHash(aStr) {
// return the two-digit hexadecimal code for a byte
let toHexString = charCode => `0${charCode.toString(16)}`.slice(-2);
let hasher = Cc["@mozilla.org/security/hash;1"].createInstance(
Ci.nsICryptoHash
);
hasher.init(Ci.nsICryptoHash.SHA256);
let stringStream = Cc["@mozilla.org/io/string-input-stream;1"].createInstance(
Ci.nsIStringInputStream
);
stringStream.data = aStr;
hasher.updateFromStream(stringStream, -1);
// convert the binary hash data to a hex string.
let binary = hasher.finish(false);
return Array.from(binary, (c, i) => toHexString(binary.charCodeAt(i)))
.join("")
.toLowerCase();
}
/**
* V2 provider builds and ranks an interest profile (also called an “interest vector”) off the browse history.
* This allows Firefox to classify pages into topics, by examining the text found on the page.
* It does this by looking at the history text content, title, and description.
*/
this.PersonalityProvider = class PersonalityProvider {
constructor(
timeSegments,
parameterSets,
maxHistoryQueryResults,
version,
scores,
v2Params
) {
this.v2Params = v2Params || {};
this.dispatch = this.v2Params.dispatch || (() => {});
this.modelKeys = this.v2Params.modelKeys;
this.timeSegments = timeSegments;
this.parameterSets = parameterSets;
this.maxHistoryQueryResults = maxHistoryQueryResults;
this.version = version;
this.scores = scores || {};
this.interestConfig = this.scores.interestConfig;
this.interestVector = this.scores.interestVector;
this.onSync = this.onSync.bind(this);
this.setupSyncAttachment(RECIPE_NAME);
this.setupSyncAttachment(MODELS_NAME);
}
async onSync(event) {
const {
data: { created, updated, deleted },
} = event;
// Remove every removed attachment.
const toRemove = deleted.concat(updated.map(u => u.old));
await Promise.all(toRemove.map(record => this.deleteAttachment(record)));
// Download every new/updated attachment.
const toDownload = created.concat(updated.map(u => u.new));
await Promise.all(
toDownload.map(record => this.maybeDownloadAttachment(record))
);
}
setupSyncAttachment(collection) {
RemoteSettings(collection).on("sync", this.onSync);
}
/**
* Downloads the attachment to disk assuming the dir already exists
* and any existing files matching the filename are clobbered.
*/
async _downloadAttachment(record) {
const {
attachment: { location, filename },
} = record;
const remoteFilePath = (await baseAttachmentsURL) + location;
const localFilePath = OS.Path.join(PERSONALITY_PROVIDER_DIR, filename);
const headers = new Headers();
headers.set("Accept-Encoding", "gzip");
const resp = await fetch(remoteFilePath, { headers, credentials: "omit" });
if (!resp.ok) {
Cu.reportError(`Failed to fetch ${remoteFilePath}: ${resp.status}`);
return;
}
const buffer = await resp.arrayBuffer();
const bytes = new Uint8Array(buffer);
await OS.File.writeAtomic(localFilePath, bytes, {
tmpPath: `${localFilePath}.tmp`,
});
}
/**
* Attempts to download the attachment, but only if it doesn't already exist.
*/
async maybeDownloadAttachment(record, retries = 3) {
const {
attachment: { filename, hash, size },
} = record;
await OS.File.makeDir(PERSONALITY_PROVIDER_DIR);
const localFilePath = OS.Path.join(PERSONALITY_PROVIDER_DIR, filename);
let retry = 0;
while (
retry++ < retries &&
(!(await OS.File.exists(localFilePath)) ||
(await OS.File.stat(localFilePath)).size !== size ||
getHash(await this._getFileStr(localFilePath)) !== hash)
) {
await this._downloadAttachment(record);
}
}
async deleteAttachment(record) {
const {
attachment: { filename },
} = record;
await OS.File.makeDir(PERSONALITY_PROVIDER_DIR);
const path = OS.Path.join(PERSONALITY_PROVIDER_DIR, filename);
await OS.File.remove(path, { ignoreAbsent: true });
return OS.File.removeEmptyDir(PERSONALITY_PROVIDER_DIR, {
ignoreAbsent: true,
});
}
/**
* Gets contents of the attachment if it already exists on file,
* and if not attempts to download it.
*/
async getAttachment(record) {
const {
attachment: { filename },
} = record;
const filepath = OS.Path.join(PERSONALITY_PROVIDER_DIR, filename);
try {
await this.maybeDownloadAttachment(record);
return JSON.parse(await this._getFileStr(filepath));
} catch (error) {
Cu.reportError(`Failed to load ${filepath}: ${error.message}`);
}
return {};
}
// A helper function to read and decode a file, it isn't a stand alone function.
// If you use this, ensure you check the file exists and you have a try catch.
async _getFileStr(filepath) {
const binaryData = await OS.File.read(filepath);
return gTextDecoder.decode(binaryData);
}
async init(callback) {
const perfStart = perfService.absNow();
this.interestConfig = this.interestConfig || (await this.getRecipe());
if (!this.interestConfig) {
this.dispatch(
ac.PerfEvent({ event: "PERSONALIZATION_V2_GET_RECIPE_ERROR" })
);
return;
}
this.recipeExecutor = await this.generateRecipeExecutor();
if (!this.recipeExecutor) {
this.dispatch(
ac.PerfEvent({
event: "PERSONALIZATION_V2_GENERATE_RECIPE_EXECUTOR_ERROR",
})
);
return;
}
this.interestVector =
this.interestVector || (await this.createInterestVector());
if (!this.interestVector) {
this.dispatch(
ac.PerfEvent({
event: "PERSONALIZATION_V2_CREATE_INTEREST_VECTOR_ERROR",
})
);
return;
}
this.dispatch(
ac.PerfEvent({
event: "PERSONALIZATION_V2_TOTAL_DURATION",
value: Math.round(perfService.absNow() - perfStart),
})
);
this.initialized = true;
if (callback) {
callback();
}
}
async getFromRemoteSettings(name) {
const result = await RemoteSettings(name).get();
return Promise.all(
result.map(async record => ({
...(await this.getAttachment(record)),
recordKey: record.key,
}))
);
}
/**
* Returns a Recipe from remote settings to be consumed by a RecipeExecutor.
* A Recipe is a set of instructions on how to processes a RecipeExecutor.
*/
async getRecipe() {
if (!this.recipes || !this.recipes.length) {
const start = perfService.absNow();
this.recipes = await this.getFromRemoteSettings(RECIPE_NAME);
this.dispatch(
ac.PerfEvent({
event: "PERSONALIZATION_V2_GET_RECIPE_DURATION",
value: Math.round(perfService.absNow() - start),
})
);
}
return this.recipes[0];
}
/**
* Returns a Recipe Executor.
* A Recipe Executor is a set of actions that can be consumed by a Recipe.
* The Recipe determines the order and specifics of which the actions are called.
*/
async generateRecipeExecutor() {
if (!this.taggers) {
const startTaggers = perfService.absNow();
let nbTaggers = [];
let nmfTaggers = {};
const models = await this.getFromRemoteSettings(MODELS_NAME);
if (models.length === 0) {
return null;
}
for (let model of models) {
if (!this.modelKeys.includes(model.recordKey)) {
continue;
}
if (model.model_type === "nb") {
nbTaggers.push(new NaiveBayesTextTagger(model));
} else if (model.model_type === "nmf") {
nmfTaggers[model.parent_tag] = new NmfTextTagger(model);
}
}
this.dispatch(
ac.PerfEvent({
event: "PERSONALIZATION_V2_TAGGERS_DURATION",
value: Math.round(perfService.absNow() - startTaggers),
})
);
this.taggers = { nbTaggers, nmfTaggers };
}
const startRecipeExecutor = perfService.absNow();
const recipeExecutor = new RecipeExecutor(
this.taggers.nbTaggers,
this.taggers.nmfTaggers
);
this.dispatch(
ac.PerfEvent({
event: "PERSONALIZATION_V2_RECIPE_EXECUTOR_DURATION",
value: Math.round(perfService.absNow() - startRecipeExecutor),
})
);
return recipeExecutor;
}
/**
* Grabs a slice of browse history for building a interest vector
*/
async fetchHistory(columns, beginTimeSecs, endTimeSecs) {
let sql = `SELECT url, title, visit_count, frecency, last_visit_date, description
FROM moz_places
WHERE last_visit_date >= ${beginTimeSecs * 1000000}
AND last_visit_date < ${endTimeSecs * 1000000}`;
columns.forEach(requiredColumn => {
sql += ` AND IFNULL(${requiredColumn}, '') <> ''`;
});
sql += " LIMIT 30000";
const { activityStreamProvider } = NewTabUtils;
const history = await activityStreamProvider.executePlacesQuery(sql, {
columns,
params: {},
});
return history;
}
/**
* Examines the user's browse history and returns an interest vector that
* describes the topics the user frequently browses.
*/
async createInterestVector() {
let interestVector = {};
let endTimeSecs = new Date().getTime() / 1000;
let beginTimeSecs = endTimeSecs - this.interestConfig.history_limit_secs;
let history = await this.fetchHistory(
this.interestConfig.history_required_fields,
beginTimeSecs,
endTimeSecs
);
this.dispatch(
ac.PerfEvent({
event: "PERSONALIZATION_V2_HISTORY_SIZE",
value: history.length,
})
);
const start = perfService.absNow();
for (let historyRec of history) {
let ivItem = this.recipeExecutor.executeRecipe(
historyRec,
this.interestConfig.history_item_builder
);
if (ivItem === null) {
continue;
}
interestVector = this.recipeExecutor.executeCombinerRecipe(
interestVector,
ivItem,
this.interestConfig.interest_combiner
);
if (interestVector === null) {
return null;
}
}
const finalResult = this.recipeExecutor.executeRecipe(
interestVector,
this.interestConfig.interest_finalizer
);
this.dispatch(
ac.PerfEvent({
event: "PERSONALIZATION_V2_CREATE_INTEREST_VECTOR_DURATION",
value: Math.round(perfService.absNow() - start),
})
);
return finalResult;
}
/**
* Calculates a score of a Pocket item when compared to the user's interest
* vector. Returns the score. Higher scores are better. Assumes this.interestVector
* is populated.
*/
calculateItemRelevanceScore(pocketItem) {
if (!this.initialized) {
return pocketItem.item_score || 1;
}
let scorableItem = this.recipeExecutor.executeRecipe(
pocketItem,
this.interestConfig.item_to_rank_builder
);
if (scorableItem === null) {
return -1;
}
let rankingVector = JSON.parse(JSON.stringify(this.interestVector));
Object.keys(scorableItem).forEach(key => {
rankingVector[key] = scorableItem[key];
});
rankingVector = this.recipeExecutor.executeRecipe(
rankingVector,
this.interestConfig.item_ranker
);
if (rankingVector === null) {
return -1;
}
return rankingVector.score;
}
/**
* Returns an object holding the settings and affinity scores of this provider instance.
*/
getAffinities() {
return {
timeSegments: this.timeSegments,
parameterSets: this.parameterSets,
maxHistoryQueryResults: this.maxHistoryQueryResults,
version: this.version,
scores: {
interestConfig: this.interestConfig,
interestVector: this.interestVector,
taggers: this.taggers,
},
};
}
};
const EXPORTED_SYMBOLS = ["PersonalityProvider"];
================================================
FILE: lib/PlacesFeed.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 { actionCreators: ac, actionTypes: at } = ChromeUtils.import(
"resource://activity-stream/common/Actions.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"NewTabUtils",
"resource://gre/modules/NewTabUtils.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"PlacesUtils",
"resource://gre/modules/PlacesUtils.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"PrivateBrowsingUtils",
"resource://gre/modules/PrivateBrowsingUtils.jsm"
);
const LINK_BLOCKED_EVENT = "newtab-linkBlocked";
const PLACES_LINKS_CHANGED_DELAY_TIME = 1000; // time in ms to delay timer for places links changed events
/**
* Observer - a wrapper around history/bookmark observers to add the QueryInterface.
*/
class Observer {
constructor(dispatch, observerInterface) {
this.dispatch = dispatch;
this.QueryInterface = ChromeUtils.generateQI([
observerInterface,
Ci.nsISupportsWeakReference,
]);
}
}
/**
* HistoryObserver - observes events from PlacesUtils.history
*/
class HistoryObserver extends Observer {
constructor(dispatch) {
super(dispatch, Ci.nsINavHistoryObserver);
}
/**
* onDeleteURI - Called when an link is deleted from history.
*
* @param {obj} uri A URI object representing the link's url
* {str} uri.spec The URI as a string
*/
onDeleteURI(uri) {
this.dispatch({ type: at.PLACES_LINKS_CHANGED });
this.dispatch({
type: at.PLACES_LINK_DELETED,
data: { url: uri.spec },
});
}
/**
* onClearHistory - Called when the user clears their entire history.
*/
onClearHistory() {
this.dispatch({ type: at.PLACES_HISTORY_CLEARED });
}
// Empty functions to make xpconnect happy
onBeginUpdateBatch() {}
onEndUpdateBatch() {}
onTitleChanged() {}
onFrecencyChanged() {}
onManyFrecenciesChanged() {}
onPageChanged() {}
onDeleteVisits() {}
}
/**
* BookmarksObserver - observes events from PlacesUtils.bookmarks
*/
class BookmarksObserver extends Observer {
constructor(dispatch) {
super(dispatch, Ci.nsINavBookmarkObserver);
this.skipTags = true;
}
/**
* onItemRemoved - Called when a bookmark is removed
*
* @param {str} id
* @param {str} folderId
* @param {int} index
* @param {int} type Indicates if the bookmark is an actual bookmark,
* a folder, or a separator.
* @param {str} uri
* @param {str} guid The unique id of the bookmark
*/
// eslint-disable-next-line max-params
onItemRemoved(id, folderId, index, type, uri, guid, parentGuid, source) {
if (
type === PlacesUtils.bookmarks.TYPE_BOOKMARK &&
source !== PlacesUtils.bookmarks.SOURCES.IMPORT &&
source !== PlacesUtils.bookmarks.SOURCES.RESTORE &&
source !== PlacesUtils.bookmarks.SOURCES.RESTORE_ON_STARTUP &&
source !== PlacesUtils.bookmarks.SOURCES.SYNC
) {
this.dispatch({ type: at.PLACES_LINKS_CHANGED });
this.dispatch({
type: at.PLACES_BOOKMARK_REMOVED,
data: { url: uri.spec, bookmarkGuid: guid },
});
}
}
// Empty functions to make xpconnect happy
onBeginUpdateBatch() {}
onEndUpdateBatch() {}
onItemVisited() {}
onItemMoved() {}
// Disabled due to performance cost, see Issue 3203 /
// https://bugzilla.mozilla.org/show_bug.cgi?id=1392267.
onItemChanged() {}
}
/**
* PlacesObserver - observes events from PlacesUtils.observers
*/
class PlacesObserver extends Observer {
constructor(dispatch) {
super(dispatch, Ci.nsINavBookmarkObserver);
this.handlePlacesEvent = this.handlePlacesEvent.bind(this);
}
handlePlacesEvent(events) {
for (let {
itemType,
source,
dateAdded,
guid,
title,
url,
isTagging,
} of events) {
// Skips items that are not bookmarks (like folders), about:* pages or
// default bookmarks, added when the profile is created.
if (
isTagging ||
itemType !== PlacesUtils.bookmarks.TYPE_BOOKMARK ||
source === PlacesUtils.bookmarks.SOURCES.IMPORT ||
source === PlacesUtils.bookmarks.SOURCES.RESTORE ||
source === PlacesUtils.bookmarks.SOURCES.RESTORE_ON_STARTUP ||
source === PlacesUtils.bookmarks.SOURCES.SYNC ||
(!url.startsWith("http://") && !url.startsWith("https://"))
) {
return;
}
this.dispatch({ type: at.PLACES_LINKS_CHANGED });
this.dispatch({
type: at.PLACES_BOOKMARK_ADDED,
data: {
bookmarkGuid: guid,
bookmarkTitle: title,
dateAdded: dateAdded * 1000,
url,
},
});
}
}
}
class PlacesFeed {
constructor() {
this.placesChangedTimer = null;
this.customDispatch = this.customDispatch.bind(this);
this.historyObserver = new HistoryObserver(this.customDispatch);
this.bookmarksObserver = new BookmarksObserver(this.customDispatch);
this.placesObserver = new PlacesObserver(this.customDispatch);
}
addObservers() {
// NB: Directly get services without importing the *BIG* PlacesUtils module
Cc["@mozilla.org/browser/nav-history-service;1"]
.getService(Ci.nsINavHistoryService)
.addObserver(this.historyObserver, true);
Cc["@mozilla.org/browser/nav-bookmarks-service;1"]
.getService(Ci.nsINavBookmarksService)
.addObserver(this.bookmarksObserver, true);
PlacesUtils.observers.addListener(
["bookmark-added"],
this.placesObserver.handlePlacesEvent
);
Services.obs.addObserver(this, LINK_BLOCKED_EVENT);
}
/**
* setTimeout - A custom function that creates an nsITimer that can be cancelled
*
* @param {func} callback A function to be executed after the timer expires
* @param {int} delay The time (in ms) the timer should wait before the function is executed
*/
setTimeout(callback, delay) {
let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
timer.initWithCallback(callback, delay, Ci.nsITimer.TYPE_ONE_SHOT);
return timer;
}
customDispatch(action) {
// If we are changing many links at once, delay this action and only dispatch
// one action at the end
if (action.type === at.PLACES_LINKS_CHANGED) {
if (this.placesChangedTimer) {
this.placesChangedTimer.delay = PLACES_LINKS_CHANGED_DELAY_TIME;
} else {
this.placesChangedTimer = this.setTimeout(() => {
this.placesChangedTimer = null;
this.store.dispatch(ac.OnlyToMain(action));
}, PLACES_LINKS_CHANGED_DELAY_TIME);
}
} else {
this.store.dispatch(ac.BroadcastToContent(action));
}
}
removeObservers() {
if (this.placesChangedTimer) {
this.placesChangedTimer.cancel();
this.placesChangedTimer = null;
}
PlacesUtils.history.removeObserver(this.historyObserver);
PlacesUtils.bookmarks.removeObserver(this.bookmarksObserver);
PlacesUtils.observers.removeListener(
["bookmark-added"],
this.placesObserver.handlePlacesEvent
);
Services.obs.removeObserver(this, LINK_BLOCKED_EVENT);
}
/**
* observe - An observer for the LINK_BLOCKED_EVENT.
* Called when a link is blocked.
* Links can be blocked outside of newtab,
* which is why we need to listen to this
* on such a generic level.
*
* @param {null} subject
* @param {str} topic The name of the event
* @param {str} value The data associated with the event
*/
observe(subject, topic, value) {
if (topic === LINK_BLOCKED_EVENT) {
this.store.dispatch(
ac.BroadcastToContent({
type: at.PLACES_LINK_BLOCKED,
data: { url: value },
})
);
}
}
/**
* Open a link in a desired destination defaulting to action's event.
*/
openLink(action, where = "", isPrivate = false) {
const params = {
private: isPrivate,
triggeringPrincipal: Services.scriptSecurityManager.createNullPrincipal(
{}
),
};
// Always include the referrer (even for http links) if we have one
const { event, referrer, typedBonus } = action.data;
if (referrer) {
const ReferrerInfo = Components.Constructor(
"@mozilla.org/referrer-info;1",
"nsIReferrerInfo",
"init"
);
params.referrerInfo = new ReferrerInfo(
Ci.nsIReferrerInfo.UNSAFE_URL,
true,
Services.io.newURI(referrer)
);
}
// Pocket gives us a special reader URL to open their stories in
const urlToOpen =
action.data.type === "pocket" ? action.data.open_url : action.data.url;
// Mark the page as typed for frecency bonus before opening the link
if (typedBonus) {
PlacesUtils.history.markPageAsTyped(Services.io.newURI(urlToOpen));
}
const win = action._target.browser.ownerGlobal;
win.openLinkIn(urlToOpen, where || win.whereToOpenLink(event), params);
}
async saveToPocket(site, browser) {
const { url, title } = site;
try {
let data = await NewTabUtils.activityStreamLinks.addPocketEntry(
url,
title,
browser
);
if (data) {
this.store.dispatch(
ac.BroadcastToContent({
type: at.PLACES_SAVED_TO_POCKET,
data: {
url,
open_url: data.item.open_url,
title,
pocket_id: data.item.item_id,
},
})
);
}
} catch (err) {
Cu.reportError(err);
}
}
/**
* Deletes an item from a user's saved to Pocket feed
* @param {int} itemID
* The unique ID given by Pocket for that item; used to look the item up when deleting
*/
async deleteFromPocket(itemID) {
try {
await NewTabUtils.activityStreamLinks.deletePocketEntry(itemID);
this.store.dispatch({ type: at.POCKET_LINK_DELETED_OR_ARCHIVED });
} catch (err) {
Cu.reportError(err);
}
}
/**
* Archives an item from a user's saved to Pocket feed
* @param {int} itemID
* The unique ID given by Pocket for that item; used to look the item up when archiving
*/
async archiveFromPocket(itemID) {
try {
await NewTabUtils.activityStreamLinks.archivePocketEntry(itemID);
this.store.dispatch({ type: at.POCKET_LINK_DELETED_OR_ARCHIVED });
} catch (err) {
Cu.reportError(err);
}
}
fillSearchTopSiteTerm({ _target, data }) {
_target.browser.ownerGlobal.gURLBar.search(`${data.label} `);
}
_getSearchPrefix(isPrivateWindow) {
const searchAliases =
Services.search[
isPrivateWindow ? "defaultPrivateEngine" : "defaultEngine"
].wrappedJSObject.__internalAliases;
if (searchAliases && searchAliases.length) {
return `${searchAliases[0]} `;
}
return "";
}
handoffSearchToAwesomebar({ _target, data, meta }) {
const searchAlias = this._getSearchPrefix(
PrivateBrowsingUtils.isBrowserPrivate(_target.browser)
);
const urlBar = _target.browser.ownerGlobal.gURLBar;
let isFirstChange = true;
if (!data || !data.text) {
urlBar.setHiddenFocus();
} else {
// Pass the provided text to the awesomebar. Prepend the @engine shortcut.
urlBar.search(`${searchAlias}${data.text}`);
isFirstChange = false;
}
const checkFirstChange = () => {
// Check if this is the first change since we hidden focused. If it is,
// remove hidden focus styles, prepend the search alias and hide the
// in-content search.
if (isFirstChange) {
isFirstChange = false;
urlBar.removeHiddenFocus();
urlBar.search(searchAlias);
this.store.dispatch(
ac.OnlyToOneContent({ type: at.HIDE_SEARCH }, meta.fromTarget)
);
urlBar.removeEventListener("compositionstart", checkFirstChange);
urlBar.removeEventListener("paste", checkFirstChange);
}
};
const onKeydown = ev => {
// Check if the keydown will cause a value change.
if (ev.key.length === 1 && !ev.altKey && !ev.ctrlKey && !ev.metaKey) {
checkFirstChange();
}
// If the Esc button is pressed, we are done. Show in-content search and cleanup.
if (ev.key === "Escape") {
onDone(); // eslint-disable-line no-use-before-define
}
};
const onDone = () => {
// We are done. Show in-content search again and cleanup.
this.store.dispatch(
ac.OnlyToOneContent({ type: at.SHOW_SEARCH }, meta.fromTarget)
);
urlBar.removeHiddenFocus();
urlBar.removeEventListener("keydown", onKeydown);
urlBar.removeEventListener("mousedown", onDone);
urlBar.removeEventListener("blur", onDone);
urlBar.removeEventListener("compositionstart", checkFirstChange);
urlBar.removeEventListener("paste", checkFirstChange);
};
urlBar.addEventListener("keydown", onKeydown);
urlBar.addEventListener("mousedown", onDone);
urlBar.addEventListener("blur", onDone);
urlBar.addEventListener("compositionstart", checkFirstChange);
urlBar.addEventListener("paste", checkFirstChange);
}
onAction(action) {
switch (action.type) {
case at.INIT:
// Briefly avoid loading services for observing for better startup timing
Services.tm.dispatchToMainThread(() => this.addObservers());
break;
case at.UNINIT:
this.removeObservers();
break;
case at.BLOCK_URL: {
const { url, pocket_id } = action.data;
NewTabUtils.activityStreamLinks.blockURL({ url, pocket_id });
break;
}
case at.BOOKMARK_URL:
NewTabUtils.activityStreamLinks.addBookmark(
action.data,
action._target.browser.ownerGlobal
);
break;
case at.DELETE_BOOKMARK_BY_ID:
NewTabUtils.activityStreamLinks.deleteBookmark(action.data);
break;
case at.DELETE_HISTORY_URL: {
const { url, forceBlock, pocket_id } = action.data;
NewTabUtils.activityStreamLinks.deleteHistoryEntry(url);
if (forceBlock) {
NewTabUtils.activityStreamLinks.blockURL({ url, pocket_id });
}
break;
}
case at.OPEN_NEW_WINDOW:
this.openLink(action, "window");
break;
case at.OPEN_PRIVATE_WINDOW:
this.openLink(action, "window", true);
break;
case at.SAVE_TO_POCKET:
this.saveToPocket(action.data.site, action._target.browser);
break;
case at.DELETE_FROM_POCKET:
this.deleteFromPocket(action.data.pocket_id);
break;
case at.ARCHIVE_FROM_POCKET:
this.archiveFromPocket(action.data.pocket_id);
break;
case at.FILL_SEARCH_TERM:
this.fillSearchTopSiteTerm(action);
break;
case at.HANDOFF_SEARCH_TO_AWESOMEBAR:
this.handoffSearchToAwesomebar(action);
break;
case at.OPEN_LINK: {
this.openLink(action);
break;
}
}
}
}
this.PlacesFeed = PlacesFeed;
// Exported for testing only
PlacesFeed.HistoryObserver = HistoryObserver;
PlacesFeed.BookmarksObserver = BookmarksObserver;
PlacesFeed.PlacesObserver = PlacesObserver;
const EXPORTED_SYMBOLS = ["PlacesFeed"];
================================================
FILE: lib/PrefsFeed.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 { actionCreators: ac, actionTypes: at } = ChromeUtils.import(
"resource://activity-stream/common/Actions.jsm"
);
const { Prefs } = ChromeUtils.import(
"resource://activity-stream/lib/ActivityStreamPrefs.jsm"
);
const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
ChromeUtils.defineModuleGetter(
this,
"PrivateBrowsingUtils",
"resource://gre/modules/PrivateBrowsingUtils.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"AppConstants",
"resource://gre/modules/AppConstants.jsm"
);
this.PrefsFeed = class PrefsFeed {
constructor(prefMap) {
this._prefMap = prefMap;
this._prefs = new Prefs();
}
onPrefChanged(name, value) {
const prefItem = this._prefMap.get(name);
if (prefItem) {
this.store.dispatch(
ac[prefItem.skipBroadcast ? "OnlyToMain" : "BroadcastToContent"]({
type: at.PREF_CHANGED,
data: { name, value },
})
);
}
}
init() {
this._prefs.observeBranch(this);
this._storage = this.store.dbStorage.getDbTable("sectionPrefs");
// Get the initial value of each activity stream pref
const values = {};
for (const name of this._prefMap.keys()) {
values[name] = this._prefs.get(name);
}
// These are not prefs, but are needed to determine stuff in content that can only be
// computed in main process
values.isPrivateBrowsingEnabled = PrivateBrowsingUtils.enabled;
values.platform = AppConstants.platform;
// Get the firefox accounts url for links and to send firstrun metrics to.
values.fxa_endpoint = Services.prefs.getStringPref(
"browser.newtabpage.activity-stream.fxaccounts.endpoint",
"https://accounts.firefox.com"
);
// Get the firefox update channel with values as default, nightly, beta or release
values.appUpdateChannel = Services.prefs.getStringPref(
"app.update.channel",
""
);
// Read the pref for search shortcuts top sites experiment from firefox.js and store it
// in our interal list of prefs to watch
let searchTopSiteExperimentPrefValue = Services.prefs.getBoolPref(
"browser.newtabpage.activity-stream.improvesearch.topSiteSearchShortcuts"
);
values[
"improvesearch.topSiteSearchShortcuts"
] = searchTopSiteExperimentPrefValue;
this._prefMap.set("improvesearch.topSiteSearchShortcuts", {
value: searchTopSiteExperimentPrefValue,
});
// Read the pref for search hand-off from firefox.js and store it
// in our interal list of prefs to watch
let handoffToAwesomebarPrefValue = Services.prefs.getBoolPref(
"browser.newtabpage.activity-stream.improvesearch.handoffToAwesomebar"
);
values["improvesearch.handoffToAwesomebar"] = handoffToAwesomebarPrefValue;
this._prefMap.set("improvesearch.handoffToAwesomebar", {
value: handoffToAwesomebarPrefValue,
});
let discoveryStreamEnabled = Services.prefs.getBoolPref(
"browser.newtabpage.activity-stream.discoverystream.enabled",
false
);
let discoveryStreamHardcodedBasicLayout = Services.prefs.getBoolPref(
"browser.newtabpage.activity-stream.discoverystream.hardcoded-basic-layout",
false
);
let discoveryStreamSpocsEndpoint = Services.prefs.getStringPref(
"browser.newtabpage.activity-stream.discoverystream.spocs-endpoint",
""
);
let discoveryStreamLangLayoutConfig = Services.prefs.getStringPref(
"browser.newtabpage.activity-stream.discoverystream.lang-layout-config",
""
);
values["discoverystream.enabled"] = discoveryStreamEnabled;
this._prefMap.set("discoverystream.enabled", {
value: discoveryStreamEnabled,
});
values[
"discoverystream.hardcoded-basic-layout"
] = discoveryStreamHardcodedBasicLayout;
this._prefMap.set("discoverystream.hardcoded-basic-layout", {
value: discoveryStreamHardcodedBasicLayout,
});
values["discoverystream.spocs-endpoint"] = discoveryStreamSpocsEndpoint;
this._prefMap.set("discoverystream.spocs-endpoint", {
value: discoveryStreamSpocsEndpoint,
});
values[
"discoverystream.lang-layout-config"
] = discoveryStreamLangLayoutConfig;
this._prefMap.set("discoverystream.lang-layout-config", {
value: discoveryStreamLangLayoutConfig,
});
// Set the initial state of all prefs in redux
this.store.dispatch(
ac.BroadcastToContent({ type: at.PREFS_INITIAL_VALUES, data: values })
);
}
removeListeners() {
this._prefs.ignoreBranch(this);
}
async _setIndexedDBPref(id, value) {
const name = id === "topsites" ? id : `feeds.section.${id}`;
try {
await this._storage.set(name, value);
} catch (e) {
Cu.reportError("Could not set section preferences.");
}
}
onAction(action) {
switch (action.type) {
case at.INIT:
this.init();
break;
case at.UNINIT:
this.removeListeners();
break;
case at.CLEAR_PREF:
Services.prefs.clearUserPref(this._prefs._branchStr + action.data.name);
break;
case at.SET_PREF:
this._prefs.set(action.data.name, action.data.value);
break;
case at.UPDATE_SECTION_PREFS:
this._setIndexedDBPref(action.data.id, action.data.value);
break;
}
}
};
const EXPORTED_SYMBOLS = ["PrefsFeed"];
================================================
FILE: lib/RecipeExecutor.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 { tokenize } = ChromeUtils.import(
"resource://activity-stream/lib/Tokenize.jsm"
);
/**
* RecipeExecutor is the core feature engineering pipeline for the in-browser
* personalization work. These pipelines are called "recipes". A recipe is an
* array of objects that define a "step" in the recipe. A step is simply an
* object with a field "function" that specifies what is being done in the step
* along with other fields that are semantically defined for that step.
*
* There are two types of recipes "builder" recipes and "combiner" recipes. Builder
* recipes mutate an object until it matches some set of critera. Combiner
* recipes take two objects, (a "left" and a "right"), and specify the steps
* to merge the right object into the left object.
*
* A short nonsense example recipe is:
* [ {"function": "get_url_domain", "path_length": 1, "field": "url", "dest": "url_domain"},
* {"function": "nb_tag", "fields": ["title", "description"]},
* {"function": "conditionally_nmf_tag", "fields": ["title", "description"]} ]
*
* Recipes are sandboxed by the fact that the step functions must be explicitly
* whitelisted. Functions whitelisted for builder recipes are specifed in the
* RecipeExecutor.ITEM_BUILDER_REGISTRY, while combiner functions are whitelisted
* in RecipeExecutor.ITEM_COMBINER_REGISTRY .
*/
this.RecipeExecutor = class RecipeExecutor {
constructor(nbTaggers, nmfTaggers) {
this.ITEM_BUILDER_REGISTRY = {
nb_tag: this.naiveBayesTag,
conditionally_nmf_tag: this.conditionallyNmfTag,
accept_item_by_field_value: this.acceptItemByFieldValue,
tokenize_url: this.tokenizeUrl,
get_url_domain: this.getUrlDomain,
tokenize_field: this.tokenizeField,
copy_value: this.copyValue,
keep_top_k: this.keepTopK,
scalar_multiply: this.scalarMultiply,
elementwise_multiply: this.elementwiseMultiply,
vector_multiply: this.vectorMultiply,
scalar_add: this.scalarAdd,
vector_add: this.vectorAdd,
make_boolean: this.makeBoolean,
whitelist_fields: this.whitelistFields,
filter_by_value: this.filterByValue,
l2_normalize: this.l2Normalize,
prob_normalize: this.probNormalize,
set_default: this.setDefault,
lookup_value: this.lookupValue,
copy_to_map: this.copyToMap,
scalar_multiply_tag: this.scalarMultiplyTag,
apply_softmax_tags: this.applySoftmaxTags,
};
this.ITEM_COMBINER_REGISTRY = {
combiner_add: this.combinerAdd,
combiner_max: this.combinerMax,
combiner_collect_values: this.combinerCollectValues,
};
this.nbTaggers = nbTaggers;
this.nmfTaggers = nmfTaggers;
}
/**
* Determines the type of a field. Valid types are:
* string
* number
* array
* map (strings to anything)
*/
_typeOf(data) {
let t = typeof data;
if (t === "object") {
if (data === null) {
return "null";
}
if (Array.isArray(data)) {
return "array";
}
return "map";
}
return t;
}
/**
* Returns a scalar, either because it was a constant, or by
* looking it up from the item. Allows for a default value if the lookup
* fails.
*/
_lookupScalar(item, k, dfault) {
if (this._typeOf(k) === "number") {
return k;
} else if (
this._typeOf(k) === "string" &&
k in item &&
this._typeOf(item[k]) === "number"
) {
return item[k];
}
return dfault;
}
/**
* Simply appends all the strings from a set fields together. If the field
* is a list, then the cells of the list are append.
*/
_assembleText(item, fields) {
let textArr = [];
for (let field of fields) {
if (field in item) {
let type = this._typeOf(item[field]);
if (type === "string") {
textArr.push(item[field]);
} else if (type === "array") {
for (let ele of item[field]) {
textArr.push(String(ele));
}
} else {
textArr.push(String(item[field]));
}
}
}
return textArr.join(" ");
}
/**
* Runs the naive bayes text taggers over a set of text fields. Stores the
* results in new fields:
* nb_tags: a map of text strings to probabilites
* nb_tokens: the tokenized text that was tagged
*
* Config:
* fields: an array containing a list of fields to concatenate and tag
*/
naiveBayesTag(item, config) {
let text = this._assembleText(item, config.fields);
let tokens = tokenize(text);
let tags = {};
let extended_tags = {};
for (let nbTagger of this.nbTaggers) {
let result = nbTagger.tagTokens(tokens);
if (result.label !== null && result.confident) {
extended_tags[result.label] = result;
tags[result.label] = Math.exp(result.logProb);
}
}
item.nb_tags = tags;
item.nb_tags_extended = extended_tags;
item.nb_tokens = tokens;
return item;
}
/**
* Selectively runs NMF text taggers depending on which tags were found
* by the naive bayes taggers. Writes the results in into new fields:
* nmf_tags_parent_weights: map of pareent tags to probabilites of those parent tags
* nmf_tags: map of strings to maps of strings to probabilities
* nmf_tags_parent map of child tags to parent tags
*
* Config:
* Not configurable
*/
conditionallyNmfTag(item, config) {
let nestedNmfTags = {};
let parentTags = {};
let parentWeights = {};
if (!("nb_tags" in item) || !("nb_tokens" in item)) {
return null;
}
Object.keys(item.nb_tags).forEach(parentTag => {
let nmfTagger = this.nmfTaggers[parentTag];
if (nmfTagger !== undefined) {
nestedNmfTags[parentTag] = {};
parentWeights[parentTag] = item.nb_tags[parentTag];
let nmfTags = nmfTagger.tagTokens(item.nb_tokens);
Object.keys(nmfTags).forEach(nmfTag => {
nestedNmfTags[parentTag][nmfTag] = nmfTags[nmfTag];
parentTags[nmfTag] = parentTag;
});
}
});
item.nmf_tags = nestedNmfTags;
item.nmf_tags_parent = parentTags;
item.nmf_tags_parent_weights = parentWeights;
return item;
}
/**
* Checks a field's value against another value (either from another field
* or a constant). If the test passes, then the item is emitted, otherwise
* the pipeline is aborted.
*
* Config:
* field Field to read the value to test. Left side of operator.
* op one of ==, !=, <, <=, >, >=
* rhsValue Constant value to compare against. Right side of operator.
* rhsField Field to read value to compare against. Right side of operator.
*
* NOTE: rhsValue takes precidence over rhsField.
*/
acceptItemByFieldValue(item, config) {
if (!(config.field in item)) {
return null;
}
let rhs = null;
if ("rhsValue" in config) {
rhs = config.rhsValue;
} else if ("rhsField" in config && config.rhsField in item) {
rhs = item[config.rhsField];
}
if (rhs === null) {
return null;
}
if (
// eslint-disable-next-line eqeqeq
(config.op === "==" && item[config.field] == rhs) ||
// eslint-disable-next-line eqeqeq
(config.op === "!=" && item[config.field] != rhs) ||
(config.op === "<" && item[config.field] < rhs) ||
(config.op === "<=" && item[config.field] <= rhs) ||
(config.op === ">" && item[config.field] > rhs) ||
(config.op === ">=" && item[config.field] >= rhs)
) {
return item;
}
return null;
}
/**
* Splits a URL into text-like tokens.
*
* Config:
* field Field containing a URL
* dest Field to write the tokens to as an array of strings
*
* NOTE: Any initial 'www' on the hostname is removed.
*/
tokenizeUrl(item, config) {
if (!(config.field in item)) {
return null;
}
let url = new URL(item[config.field]);
let domain = url.hostname;
if (domain.startsWith("www.")) {
domain = domain.substring(4);
}
let toks = tokenize(domain);
let pathToks = tokenize(
decodeURIComponent(url.pathname.replace(/\+/g, " "))
);
for (let tok of pathToks) {
toks.push(tok);
}
for (let pair of url.searchParams.entries()) {
let k = tokenize(decodeURIComponent(pair[0].replace(/\+/g, " ")));
for (let tok of k) {
toks.push(tok);
}
if (pair[1] !== null && pair[1] !== "") {
let v = tokenize(decodeURIComponent(pair[1].replace(/\+/g, " ")));
for (let tok of v) {
toks.push(tok);
}
}
}
item[config.dest] = toks;
return item;
}
/**
* Gets the hostname (minus any initial "www." along with the left most
* directories on the path.
*
* Config:
* field Field containing the URL
* dest Field to write the array of strings to
* path_length OPTIONAL (DEFAULT: 0) Number of leftmost subdirectories to include
*/
getUrlDomain(item, config) {
if (!(config.field in item)) {
return null;
}
let url = new URL(item[config.field]);
let domain = url.hostname.toLocaleLowerCase();
if (domain.startsWith("www.")) {
domain = domain.substring(4);
}
item[config.dest] = domain;
let pathLength = 0;
if ("path_length" in config) {
pathLength = config.path_length;
}
if (pathLength > 0) {
item[config.dest] += url.pathname
.toLocaleLowerCase()
.split("/")
.slice(0, pathLength + 1)
.join("/");
}
return item;
}
/**
* Splits a field into tokens.
* Config:
* field Field containing a string to tokenize
* dest Field to write the array of strings to
*/
tokenizeField(item, config) {
if (!(config.field in item)) {
return null;
}
item[config.dest] = tokenize(item[config.field]);
return item;
}
/**
* Deep copy from one field to another.
* Config:
* src Field to read from
* dest Field to write to
*/
copyValue(item, config) {
if (!(config.src in item)) {
return null;
}
item[config.dest] = JSON.parse(JSON.stringify(item[config.src]));
return item;
}
/**
* Converts a field containing a map of strings to a map of strings
* to numbers, to a map of strings to numbers containing at most k elements.
* This operation is performed by first, promoting all the subkeys up one
* level, and then taking the top (or bottom) k values.
*
* Config:
* field Points to a map of strings to a map of strings to numbers
* k Maximum number of items to keep
* descending OPTIONAL (DEFAULT: True) Sorts score in descending order
* (i.e. keeps maximum)
*/
keepTopK(item, config) {
if (!(config.field in item)) {
return null;
}
let k = this._lookupScalar(item, config.k, 1048576);
let descending = !("descending" in config) || config.descending !== false;
// we can't sort by the values in the map, so we have to convert this
// to an array, and then sort.
let sortable = [];
Object.keys(item[config.field]).forEach(outerKey => {
let innerType = this._typeOf(item[config.field][outerKey]);
if (innerType === "map") {
Object.keys(item[config.field][outerKey]).forEach(innerKey => {
sortable.push({
key: innerKey,
value: item[config.field][outerKey][innerKey],
});
});
} else {
sortable.push({ key: outerKey, value: item[config.field][outerKey] });
}
});
sortable.sort((a, b) => {
if (descending) {
return b.value - a.value;
}
return a.value - b.value;
});
// now take the top k
let newMap = {};
let i = 0;
for (let pair of sortable) {
if (i >= k) {
break;
}
newMap[pair.key] = pair.value;
i++;
}
item[config.field] = newMap;
return item;
}
/**
* Scalar multiplies a vector by some constant
*
* Config:
* field Points to:
* a map of strings to numbers
* an array of numbers
* a number
* k Either a number, or a string. If it's a number then This
* is the scalar value to multiply by. If it's a string,
* the value in the pointed to field is used.
* default OPTIONAL (DEFAULT: 0), If k is a string, and no numeric
* value is found, then use this value.
*/
scalarMultiply(item, config) {
if (!(config.field in item)) {
return null;
}
let k = this._lookupScalar(item, config.k, config.dfault);
let fieldType = this._typeOf(item[config.field]);
if (fieldType === "number") {
item[config.field] *= k;
} else if (fieldType === "array") {
for (let i = 0; i < item[config.field].length; i++) {
item[config.field][i] *= k;
}
} else if (fieldType === "map") {
Object.keys(item[config.field]).forEach(key => {
item[config.field][key] *= k;
});
} else {
return null;
}
return item;
}
/**
* Elementwise multiplies either two maps or two arrays together, storing
* the result in left. If left and right are of the same type, results in an
* error.
*
* Maps are special case. For maps the left must be a nested map such as:
* { k1: { k11: 1, k12: 2}, k2: { k21: 3, k22: 4 } } and right needs to be
* simple map such as: { k1: 5, k2: 6} . The operation is then to mulitply
* every value of every right key, to every value every subkey where the
* parent keys match. Using the previous examples, the result would be:
* { k1: { k11: 5, k12: 10 }, k2: { k21: 18, k22: 24 } } .
*
* Config:
* left
* right
*/
elementwiseMultiply(item, config) {
if (!(config.left in item) || !(config.right in item)) {
return null;
}
let leftType = this._typeOf(item[config.left]);
if (leftType !== this._typeOf(item[config.right])) {
return null;
}
if (leftType === "array") {
if (item[config.left].length !== item[config.right].length) {
return null;
}
for (let i = 0; i < item[config.left].length; i++) {
item[config.left][i] *= item[config.right][i];
}
} else if (leftType === "map") {
Object.keys(item[config.left]).forEach(outerKey => {
let r = 0.0;
if (outerKey in item[config.right]) {
r = item[config.right][outerKey];
}
Object.keys(item[config.left][outerKey]).forEach(innerKey => {
item[config.left][outerKey][innerKey] *= r;
});
});
} else if (leftType === "number") {
item[config.left] *= item[config.right];
} else {
return null;
}
return item;
}
/**
* Vector multiplies (i.e. dot products) two vectors and stores the result in
* third field. Both vectors must either by maps, or arrays of numbers with
* the same length.
*
* Config:
* left A field pointing to either a map of strings to numbers,
* or an array of numbers
* right A field pointing to either a map of strings to numbers,
* or an array of numbers
* dest The field to store the dot product.
*/
vectorMultiply(item, config) {
if (!(config.left in item) || !(config.right in item)) {
return null;
}
let leftType = this._typeOf(item[config.left]);
if (leftType !== this._typeOf(item[config.right])) {
return null;
}
let destVal = 0.0;
if (leftType === "array") {
if (item[config.left].length !== item[config.right].length) {
return null;
}
for (let i = 0; i < item[config.left].length; i++) {
destVal += item[config.left][i] * item[config.right][i];
}
} else if (leftType === "map") {
Object.keys(item[config.left]).forEach(key => {
if (key in item[config.right]) {
destVal += item[config.left][key] * item[config.right][key];
}
});
} else {
return null;
}
item[config.dest] = destVal;
return item;
}
/**
* Adds a constant value to all elements in the field. Mathematically,
* this is the same as taking a 1-vector, scalar multiplying it by k,
* and then vector adding it to a field.
*
* Config:
* field A field pointing to either a map of strings to numbers,
* or an array of numbers
* k Either a number, or a string. If it's a number then This
* is the scalar value to multiply by. If it's a string,
* the value in the pointed to field is used.
* default OPTIONAL (DEFAULT: 0), If k is a string, and no numeric
* value is found, then use this value.
*/
scalarAdd(item, config) {
let k = this._lookupScalar(item, config.k, config.dfault);
if (!(config.field in item)) {
return null;
}
let fieldType = this._typeOf(item[config.field]);
if (fieldType === "array") {
for (let i = 0; i < item[config.field].length; i++) {
item[config.field][i] += k;
}
} else if (fieldType === "map") {
Object.keys(item[config.field]).forEach(key => {
item[config.field][key] += k;
});
} else if (fieldType === "number") {
item[config.field] += k;
} else {
return null;
}
return item;
}
/**
* Adds two vectors together and stores the result in left.
*
* Config:
* left A field pointing to either a map of strings to numbers,
* or an array of numbers
* right A field pointing to either a map of strings to numbers,
* or an array of numbers
*/
vectorAdd(item, config) {
if (!(config.left in item)) {
return this.copyValue(item, { src: config.right, dest: config.left });
}
if (!(config.right in item)) {
return null;
}
let leftType = this._typeOf(item[config.left]);
if (leftType !== this._typeOf(item[config.right])) {
return null;
}
if (leftType === "array") {
if (item[config.left].length !== item[config.right].length) {
return null;
}
for (let i = 0; i < item[config.left].length; i++) {
item[config.left][i] += item[config.right][i];
}
return item;
} else if (leftType === "map") {
Object.keys(item[config.right]).forEach(key => {
let v = 0;
if (key in item[config.left]) {
v = item[config.left][key];
}
item[config.left][key] = v + item[config.right][key];
});
return item;
}
return null;
}
/**
* Converts a vector from real values to boolean integers. (i.e. either 1/0
* or 1/-1).
*
* Config:
* field Field containing either a mpa of strings to numbers or
* an array of numbers to convert.
* threshold OPTIONAL (DEFAULT: 0) Values above this will be replaced
* with 1.0. Those below will be converted to 0.
* keep_negative OPTIONAL (DEFAULT: False) If true, values below the
* threshold will be converted to -1 instead of 0.
*/
makeBoolean(item, config) {
if (!(config.field in item)) {
return null;
}
let threshold = this._lookupScalar(item, config.threshold, 0.0);
let type = this._typeOf(item[config.field]);
if (type === "array") {
for (let i = 0; i < item[config.field].length; i++) {
if (item[config.field][i] > threshold) {
item[config.field][i] = 1.0;
} else if (config.keep_negative) {
item[config.field][i] = -1.0;
} else {
item[config.field][i] = 0.0;
}
}
} else if (type === "map") {
Object.keys(item[config.field]).forEach(key => {
let value = item[config.field][key];
if (value > threshold) {
item[config.field][key] = 1.0;
} else if (config.keep_negative) {
item[config.field][key] = -1.0;
} else {
item[config.field][key] = 0.0;
}
});
} else if (type === "number") {
let value = item[config.field];
if (value > threshold) {
item[config.field] = 1.0;
} else if (config.keep_negative) {
item[config.field] = -1.0;
} else {
item[config.field] = 0.0;
}
} else {
return null;
}
return item;
}
/**
* Removes all keys from the item except for the ones specified.
*
* fields An array of strings indicating the fields to keep
*/
whitelistFields(item, config) {
let newItem = {};
for (let ele of config.fields) {
if (ele in item) {
newItem[ele] = item[ele];
}
}
return newItem;
}
/**
* Removes all keys whose value does not exceed some threshold.
*
* Config:
* field Points to a map of strings to numbers
* threshold Values must exceed this value, otherwise they are removed.
*/
filterByValue(item, config) {
if (!(config.field in item)) {
return null;
}
let threshold = this._lookupScalar(item, config.threshold, 0.0);
let filtered = {};
Object.keys(item[config.field]).forEach(key => {
let value = item[config.field][key];
if (value > threshold) {
filtered[key] = value;
}
});
item[config.field] = filtered;
return item;
}
/**
* Rewrites a field so that its values are now L2 normed.
*
* Config:
* field Points to a map of strings to numbers, or an array of numbers
*/
l2Normalize(item, config) {
if (!(config.field in item)) {
return null;
}
let data = item[config.field];
let type = this._typeOf(data);
if (type === "array") {
let norm = 0.0;
for (let datum of data) {
norm += datum * datum;
}
norm = Math.sqrt(norm);
if (norm !== 0) {
for (let i = 0; i < data.length; i++) {
data[i] /= norm;
}
}
} else if (type === "map") {
let norm = 0.0;
Object.keys(data).forEach(key => {
norm += data[key] * data[key];
});
norm = Math.sqrt(norm);
if (norm !== 0) {
Object.keys(data).forEach(key => {
data[key] /= norm;
});
}
} else {
return null;
}
item[config.field] = data;
return item;
}
/**
* Rewrites a field so that all of its values sum to 1.0
*
* Config:
* field Points to a map of strings to numbers, or an array of numbers
*/
probNormalize(item, config) {
if (!(config.field in item)) {
return null;
}
let data = item[config.field];
let type = this._typeOf(data);
if (type === "array") {
let norm = 0.0;
for (let datum of data) {
norm += datum;
}
if (norm !== 0) {
for (let i = 0; i < data.length; i++) {
data[i] /= norm;
}
}
} else if (type === "map") {
let norm = 0.0;
Object.keys(item[config.field]).forEach(key => {
norm += item[config.field][key];
});
if (norm !== 0) {
Object.keys(item[config.field]).forEach(key => {
item[config.field][key] /= norm;
});
}
} else {
return null;
}
return item;
}
/**
* Stores a value, if it is not already present
*
* Config:
* field field to write to if it is missing
* value value to store in that field
*/
setDefault(item, config) {
let val = this._lookupScalar(item, config.value, config.value);
if (!(config.field in item)) {
item[config.field] = val;
}
return item;
}
/**
* Selctively promotes an value from an inner map up to the outer map
*
* Config:
* haystack Points to a map of strings to values
* needle Key inside the map we should promote up
* dest Where we should write the value of haystack[needle]
*/
lookupValue(item, config) {
if (config.haystack in item && config.needle in item[config.haystack]) {
item[config.dest] = item[config.haystack][config.needle];
}
return item;
}
/**
* Demotes a field into a map
*
* Config:
* src Field to copy
* dest_map Points to a map
* dest_key Key inside dest_map to copy src to
*/
copyToMap(item, config) {
if (config.src in item) {
if (!(config.dest_map in item)) {
item[config.dest_map] = {};
}
item[config.dest_map][config.dest_key] = item[config.src];
}
return item;
}
/**
* Config:
* field Points to a string to number map
* k Scalar to multiply the values by
* log_scale Boolean, if true, then the values will be transformed
* by a logrithm prior to multiplications
*/
scalarMultiplyTag(item, config) {
let EPSILON = 0.000001;
if (!(config.field in item)) {
return null;
}
let k = this._lookupScalar(item, config.k, 1);
let type = this._typeOf(item[config.field]);
if (type === "map") {
Object.keys(item[config.field]).forEach(parentKey => {
Object.keys(item[config.field][parentKey]).forEach(key => {
let v = item[config.field][parentKey][key];
if (config.log_scale) {
v = Math.log(v + EPSILON);
}
item[config.field][parentKey][key] = v * k;
});
});
} else {
return null;
}
return item;
}
/**
* Independently applies softmax across all subtags.
*
* Config:
* field Points to a map of strings with values being another map of strings
*/
applySoftmaxTags(item, config) {
let type = this._typeOf(item[config.field]);
if (type !== "map") {
return null;
}
let abort = false;
let softmaxSum = {};
Object.keys(item[config.field]).forEach(tag => {
if (this._typeOf(item[config.field][tag]) !== "map") {
abort = true;
return;
}
if (abort) {
return;
}
softmaxSum[tag] = 0;
Object.keys(item[config.field][tag]).forEach(subtag => {
if (this._typeOf(item[config.field][tag][subtag]) !== "number") {
abort = true;
return;
}
let score = item[config.field][tag][subtag];
softmaxSum[tag] += Math.exp(score);
});
});
if (abort) {
return null;
}
Object.keys(item[config.field]).forEach(tag => {
Object.keys(item[config.field][tag]).forEach(subtag => {
item[config.field][tag][subtag] =
Math.exp(item[config.field][tag][subtag]) / softmaxSum[tag];
});
});
return item;
}
/**
* Vector adds a field and stores the result in left.
*
* Config:
* field The field to vector add
*/
combinerAdd(left, right, config) {
if (!(config.field in right)) {
return left;
}
let type = this._typeOf(right[config.field]);
if (!(config.field in left)) {
if (type === "map") {
left[config.field] = {};
} else if (type === "array") {
left[config.field] = [];
} else if (type === "number") {
left[config.field] = 0;
} else {
return null;
}
}
if (type !== this._typeOf(left[config.field])) {
return null;
}
if (type === "map") {
Object.keys(right[config.field]).forEach(key => {
if (!(key in left[config.field])) {
left[config.field][key] = 0;
}
left[config.field][key] += right[config.field][key];
});
} else if (type === "array") {
for (let i = 0; i < right[config.field].length; i++) {
if (i < left[config.field].length) {
left[config.field][i] += right[config.field][i];
} else {
left[config.field].push(right[config.field][i]);
}
}
} else if (type === "number") {
left[config.field] += right[config.field];
} else {
return null;
}
return left;
}
/**
* Stores the maximum value of the field in left.
*
* Config:
* field The field to vector add
*/
combinerMax(left, right, config) {
if (!(config.field in right)) {
return left;
}
let type = this._typeOf(right[config.field]);
if (!(config.field in left)) {
if (type === "map") {
left[config.field] = {};
} else if (type === "array") {
left[config.field] = [];
} else if (type === "number") {
left[config.field] = 0;
} else {
return null;
}
}
if (type !== this._typeOf(left[config.field])) {
return null;
}
if (type === "map") {
Object.keys(right[config.field]).forEach(key => {
if (
!(key in left[config.field]) ||
right[config.field][key] > left[config.field][key]
) {
left[config.field][key] = right[config.field][key];
}
});
} else if (type === "array") {
for (let i = 0; i < right[config.field].length; i++) {
if (i < left[config.field].length) {
if (left[config.field][i] < right[config.field][i]) {
left[config.field][i] = right[config.field][i];
}
} else {
left[config.field].push(right[config.field][i]);
}
}
} else if (type === "number") {
if (left[config.field] < right[config.field]) {
left[config.field] = right[config.field];
}
} else {
return null;
}
return left;
}
/**
* Associates a value in right with another value in right. This association
* is then stored in a map in left.
*
* For example: If a sequence of rights is:
* { 'tags': {}, 'url_domain': 'maseratiusa.com/maserati', 'time': 41 }
* { 'tags': {}, 'url_domain': 'mbusa.com/mercedes', 'time': 21 }
* { 'tags': {}, 'url_domain': 'maseratiusa.com/maserati', 'time': 34 }
*
* Then assuming a 'sum' operation, left can build a map that would look like:
* {
* 'maseratiusa.com/maserati': 75,
* 'mbusa.com/mercedes': 21,
* }
*
* Fields:
* left_field field in the left to store / update the map
* right_key_field Field in the right to use as a key
* right_value_field Field in the right to use as a value
* operation One of "sum", "max", "overwrite", "count"
*/
combinerCollectValues(left, right, config) {
let op;
if (config.operation === "sum") {
op = (a, b) => a + b;
} else if (config.operation === "max") {
op = (a, b) => (a > b ? a : b);
} else if (config.operation === "overwrite") {
op = (a, b) => b;
} else if (config.operation === "count") {
op = (a, b) => a + 1;
} else {
return null;
}
if (!(config.left_field in left)) {
left[config.left_field] = {};
}
if (
!(config.right_key_field in right) ||
!(config.right_value_field in right)
) {
return left;
}
let key = right[config.right_key_field];
let rightValue = right[config.right_value_field];
let leftValue = 0.0;
if (key in left[config.left_field]) {
leftValue = left[config.left_field][key];
}
left[config.left_field][key] = op(leftValue, rightValue);
return left;
}
/**
* Executes a recipe. Returns an object on success, or null on failure.
*/
executeRecipe(item, recipe) {
let newItem = item;
for (let step of recipe) {
let op = this.ITEM_BUILDER_REGISTRY[step.function];
if (op === undefined) {
return null;
}
newItem = op.call(this, newItem, step);
if (newItem === null) {
break;
}
}
return newItem;
}
/**
* Executes a recipe. Returns an object on success, or null on failure.
*/
executeCombinerRecipe(item1, item2, recipe) {
let newItem1 = item1;
for (let step of recipe) {
let op = this.ITEM_COMBINER_REGISTRY[step.function];
if (op === undefined) {
return null;
}
newItem1 = op.call(this, newItem1, item2, step);
if (newItem1 === null) {
break;
}
}
return newItem1;
}
};
const EXPORTED_SYMBOLS = ["RecipeExecutor"];
================================================
FILE: lib/RemoteL10n.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";
/**
* The downloaded Fluent file is located in this sub-directory of the local
* profile directory.
*/
const RS_DOWNLOADED_FILE_SUBDIR = "settings/main/ms-language-packs";
const USE_REMOTE_L10N_PREF =
"browser.newtabpage.activity-stream.asrouter.useRemoteL10n";
const { XPCOMUtils } = ChromeUtils.import(
"resource://gre/modules/XPCOMUtils.jsm"
);
XPCOMUtils.defineLazyModuleGetters(this, {
L10nRegistry: "resource://gre/modules/L10nRegistry.jsm",
FileSource: "resource://gre/modules/L10nRegistry.jsm",
OS: "resource://gre/modules/osfile.jsm",
Services: "resource://gre/modules/Services.jsm",
});
class _RemoteL10n {
constructor() {
this._l10n = null;
}
/**
* Creates a new DOMLocalization instance with the Fluent file from Remote Settings.
*
* Note: it will use the local Fluent file in any of following cases:
* * the remote Fluent file is not available
* * it was told to use the local Fluent file
*/
_createDOML10n() {
/* istanbul ignore next */
async function* generateBundles(resourceIds) {
const appLocale = Services.locale.appLocaleAsBCP47;
const appLocales = Services.locale.appLocalesAsBCP47;
const l10nFluentDir = OS.Path.join(
OS.Constants.Path.localProfileDir,
RS_DOWNLOADED_FILE_SUBDIR
);
const fs = new FileSource("cfr", [appLocale], `file://${l10nFluentDir}/`);
// In the case that the Fluent file has not been downloaded from Remote Settings,
// `fetchFile` will return `false` and fall back to the packaged Fluent file.
const resource = await fs.fetchFile(appLocale, "asrouter.ftl");
for await (let bundle of L10nRegistry.generateBundles(
appLocales.slice(0, 1),
resourceIds
)) {
// Override built-in messages with the resource loaded from remote settings for
// the app locale, i.e. the first item of `appLocales`.
if (resource) {
bundle.addResource(resource, { allowOverrides: true });
}
yield bundle;
}
// Now generating bundles for the rest of locales of `appLocales`.
yield* L10nRegistry.generateBundles(appLocales.slice(1), resourceIds);
}
return new DOMLocalization(
[
"browser/newtab/asrouter.ftl",
"browser/branding/brandings.ftl",
"browser/branding/sync-brand.ftl",
"branding/brand.ftl",
],
Services.prefs.getBoolPref(USE_REMOTE_L10N_PREF, true)
? generateBundles
: undefined
);
}
get l10n() {
if (!this._l10n) {
this._l10n = this._createDOML10n();
}
return this._l10n;
}
reloadL10n() {
this._l10n = null;
}
}
this.RemoteL10n = new _RemoteL10n();
const EXPORTED_SYMBOLS = ["RemoteL10n", "_RemoteL10n"];
================================================
FILE: lib/Screenshots.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 EXPORTED_SYMBOLS = ["Screenshots"];
Cu.importGlobalProperties(["fetch"]);
ChromeUtils.defineModuleGetter(
this,
"BackgroundPageThumbs",
"resource://gre/modules/BackgroundPageThumbs.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"PageThumbs",
"resource://gre/modules/PageThumbs.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"PrivateBrowsingUtils",
"resource://gre/modules/PrivateBrowsingUtils.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"Services",
"resource://gre/modules/Services.jsm"
);
const GREY_10 = "#F9F9FA";
this.Screenshots = {
/**
* Get a screenshot / thumbnail for a url. Either returns the disk cached
* image or initiates a background request for the url.
*
* @param url {string} The url to get a thumbnail
* @return {Promise} Resolves a custom object or null if failed
*/
async getScreenshotForURL(url) {
try {
await BackgroundPageThumbs.captureIfMissing(url, {
backgroundColor: GREY_10,
});
const imgPath = PageThumbs.getThumbnailPath(url);
const filePathResponse = await fetch(`file://${imgPath}`);
const fileContents = await filePathResponse.blob();
// Check if the file is empty, which indicates there isn't actually a
// thumbnail, so callers can show a failure state.
if (fileContents.size === 0) {
return null;
}
return { path: imgPath, data: fileContents };
} catch (err) {
Cu.reportError(`getScreenshot(${url}) failed: ${err}`);
}
// We must have failed to get the screenshot, so persist the failure by
// storing an empty file. Future calls will then skip requesting and return
// failure, so do the same thing here. The empty file should not expire with
// the usual filtering process to avoid repeated background requests, which
// can cause unwanted high CPU, network and memory usage - Bug 1384094
try {
await PageThumbs._store(url, url, null, true);
} catch (err) {
// Probably failed to create the empty file, but not much more we can do.
}
return null;
},
/**
* Checks if all the open windows are private browsing windows. If so, we do not
* want to collect screenshots. If there exists at least 1 non-private window,
* we are ok to collect screenshots.
*/
_shouldGetScreenshots() {
for (let win of Services.wm.getEnumerator("navigator:browser")) {
if (!PrivateBrowsingUtils.isWindowPrivate(win)) {
// As soon as we encounter 1 non-private window, screenshots are fair game.
return true;
}
}
return false;
},
/**
* Conditionally get a screenshot for a link if there's no existing pending
* screenshot. Updates the cached link's desired property with the result.
*
* @param link {object} Link object to update
* @param url {string} Url to get a screenshot of
* @param property {string} Name of property on object to set
@ @param onScreenshot {function} Callback for when the screenshot loads
*/
async maybeCacheScreenshot(link, url, property, onScreenshot) {
// If there are only private windows open, do not collect screenshots
if (!this._shouldGetScreenshots()) {
return;
}
// Nothing to do if we already have a pending screenshot or
// if a previous request failed and returned null.
const cache = link.__sharedCache;
if (cache.fetchingScreenshot || link[property] !== undefined) {
return;
}
// Save the promise to the cache so other links get it immediately
cache.fetchingScreenshot = this.getScreenshotForURL(url);
// Clean up now that we got the screenshot
const screenshot = await cache.fetchingScreenshot;
delete cache.fetchingScreenshot;
// Update the cache for future links and call back for existing content
cache.updateLink(property, screenshot);
onScreenshot(screenshot);
},
};
================================================
FILE: lib/SearchShortcuts.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");
// List of sites we match against Topsites in order to identify sites
// that should be converted to search Topsites
const SEARCH_SHORTCUTS = [
{ keyword: "@amazon", shortURL: "amazon", url: "https://amazon.com" },
{ keyword: "@\u767E\u5EA6", shortURL: "baidu", url: "https://baidu.com" },
{ keyword: "@google", shortURL: "google", url: "https://google.com" },
{
keyword: "@\u044F\u043D\u0434\u0435\u043A\u0441",
shortURL: "yandex",
url: "https://yandex.com",
},
];
this.SEARCH_SHORTCUTS = SEARCH_SHORTCUTS;
// These can be added via the editor but will not be added organically
this.CUSTOM_SEARCH_SHORTCUTS = [
...SEARCH_SHORTCUTS,
{ keyword: "@bing", shortURL: "bing", url: "https://bing.com" },
{
keyword: "@duckduckgo",
shortURL: "duckduckgo",
url: "https://duckduckgo.com",
},
{ keyword: "@ebay", shortURL: "ebay", url: "https://ebay.com" },
{ keyword: "@twitter", shortURL: "twitter", url: "https://twitter.com" },
{
keyword: "@wikipedia",
shortURL: "wikipedia",
url: "https://wikipedia.org",
},
];
// Note: you must add the activity stream branch to the beginning of this if using outside activity stream
this.SEARCH_SHORTCUTS_EXPERIMENT = "improvesearch.topSiteSearchShortcuts";
this.SEARCH_SHORTCUTS_SEARCH_ENGINES_PREF =
"improvesearch.topSiteSearchShortcuts.searchEngines";
this.SEARCH_SHORTCUTS_HAVE_PINNED_PREF =
"improvesearch.topSiteSearchShortcuts.havePinned";
function getSearchProvider(candidateShortURL) {
return (
SEARCH_SHORTCUTS.filter(match => candidateShortURL === match.shortURL)[0] ||
null
);
}
this.getSearchProvider = getSearchProvider;
// Check topsite against predefined list of valid search engines
// https://searchfox.org/mozilla-central/rev/ca869724246f4230b272ed1c8b9944596e80d920/toolkit/components/search/nsSearchService.js#939
async function checkHasSearchEngine(keyword) {
return (await Services.search.getDefaultEngines()).find(e =>
e.wrappedJSObject._internalAliases.includes(keyword)
);
}
this.checkHasSearchEngine = checkHasSearchEngine;
const EXPORTED_SYMBOLS = [
"checkHasSearchEngine",
"getSearchProvider",
"SEARCH_SHORTCUTS",
"CUSTOM_SEARCH_SHORTCUTS",
"SEARCH_SHORTCUTS_EXPERIMENT",
"SEARCH_SHORTCUTS_SEARCH_ENGINES_PREF",
"SEARCH_SHORTCUTS_HAVE_PINNED_PREF",
];
================================================
FILE: lib/SectionsManager.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 { EventEmitter } = ChromeUtils.import(
"resource://gre/modules/EventEmitter.jsm"
);
const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
const { actionCreators: ac, actionTypes: at } = ChromeUtils.import(
"resource://activity-stream/common/Actions.jsm"
);
const { getDefaultOptions } = ChromeUtils.import(
"resource://activity-stream/lib/ActivityStreamStorage.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"PlacesUtils",
"resource://gre/modules/PlacesUtils.jsm"
);
/*
* Generators for built in sections, keyed by the pref name for their feed.
* Built in sections may depend on options stored as serialised JSON in the pref
* `${feed_pref_name}.options`.
*/
const BUILT_IN_SECTIONS = {
"feeds.section.topstories": options => ({
id: "topstories",
pref: {
titleString: {
id: "home-prefs-recommended-by-header",
values: { provider: options.provider_name },
},
descString: { id: "home-prefs-recommended-by-description" },
nestedPrefs: options.show_spocs
? [
{
name: "showSponsored",
titleString: "home-prefs-recommended-by-option-sponsored-stories",
icon: "icon-info",
},
]
: [],
learnMore: {
link: {
href: "https://getpocket.com/firefox/new_tab_learn_more",
id: "home-prefs-recommended-by-learn-more",
},
},
},
shouldHidePref: options.hidden,
eventSource: "TOP_STORIES",
icon: options.provider_icon,
title: {
id: "newtab-section-header-pocket",
values: { provider: options.provider_name },
},
learnMore: {
link: {
href: "https://getpocket.com/firefox/new_tab_learn_more",
message: { id: "newtab-pocket-whats-pocket" },
},
},
privacyNoticeURL:
"https://www.mozilla.org/privacy/firefox/#suggest-relevant-content",
compactCards: false,
rowsPref: "section.topstories.rows",
maxRows: 4,
availableLinkMenuOptions: [
"CheckBookmarkOrArchive",
"CheckSavedToPocket",
"Separator",
"OpenInNewWindow",
"OpenInPrivateWindow",
"Separator",
"BlockUrl",
],
emptyState: {
message: {
id: "newtab-empty-section-topstories",
values: { provider: options.provider_name },
},
icon: "check",
},
shouldSendImpressionStats: true,
dedupeFrom: ["highlights"],
}),
"feeds.section.highlights": options => ({
id: "highlights",
pref: {
titleString: { id: "home-prefs-highlights-header" },
descString: { id: "home-prefs-highlights-description" },
nestedPrefs: [
{
name: "section.highlights.includeVisited",
titleString: "home-prefs-highlights-option-visited-pages",
},
{
name: "section.highlights.includeBookmarks",
titleString: "home-prefs-highlights-options-bookmarks",
},
{
name: "section.highlights.includeDownloads",
titleString: "home-prefs-highlights-option-most-recent-download",
},
{
name: "section.highlights.includePocket",
titleString: "home-prefs-highlights-option-saved-to-pocket",
},
],
},
shouldHidePref: false,
eventSource: "HIGHLIGHTS",
icon: "highlights",
title: { id: "newtab-section-header-highlights" },
compactCards: true,
rowsPref: "section.highlights.rows",
maxRows: 4,
emptyState: {
message: { id: "newtab-empty-section-highlights" },
icon: "highlights",
},
shouldSendImpressionStats: false,
}),
};
const SectionsManager = {
ACTIONS_TO_PROXY: ["WEBEXT_CLICK", "WEBEXT_DISMISS"],
CONTEXT_MENU_PREFS: { CheckSavedToPocket: "extensions.pocket.enabled" },
CONTEXT_MENU_OPTIONS_FOR_HIGHLIGHT_TYPES: {
history: [
"CheckBookmark",
"CheckSavedToPocket",
"Separator",
"OpenInNewWindow",
"OpenInPrivateWindow",
"Separator",
"BlockUrl",
"DeleteUrl",
],
bookmark: [
"CheckBookmark",
"CheckSavedToPocket",
"Separator",
"OpenInNewWindow",
"OpenInPrivateWindow",
"Separator",
"BlockUrl",
"DeleteUrl",
],
pocket: [
"ArchiveFromPocket",
"CheckSavedToPocket",
"Separator",
"OpenInNewWindow",
"OpenInPrivateWindow",
"Separator",
"BlockUrl",
],
download: [
"OpenFile",
"ShowFile",
"Separator",
"GoToDownloadPage",
"CopyDownloadLink",
"Separator",
"RemoveDownload",
"BlockUrl",
],
},
initialized: false,
sections: new Map(),
async init(prefs = {}, storage) {
this._storage = storage;
for (const feedPrefName of Object.keys(BUILT_IN_SECTIONS)) {
const optionsPrefName = `${feedPrefName}.options`;
await this.addBuiltInSection(feedPrefName, prefs[optionsPrefName]);
this._dedupeConfiguration = [];
this.sections.forEach(section => {
if (section.dedupeFrom) {
this._dedupeConfiguration.push({
id: section.id,
dedupeFrom: section.dedupeFrom,
});
}
});
}
Object.keys(this.CONTEXT_MENU_PREFS).forEach(k =>
Services.prefs.addObserver(this.CONTEXT_MENU_PREFS[k], this)
);
this.initialized = true;
this.emit(this.INIT);
},
observe(subject, topic, data) {
switch (topic) {
case "nsPref:changed":
for (const pref of Object.keys(this.CONTEXT_MENU_PREFS)) {
if (data === this.CONTEXT_MENU_PREFS[pref]) {
this.updateSections();
}
}
break;
}
},
updateSectionPrefs(id, collapsed) {
const section = this.sections.get(id);
if (!section) {
return;
}
const updatedSection = Object.assign({}, section, {
pref: Object.assign({}, section.pref, collapsed),
});
this.updateSection(id, updatedSection, true);
},
async addBuiltInSection(feedPrefName, optionsPrefValue = "{}") {
let options;
let storedPrefs;
try {
options = JSON.parse(optionsPrefValue);
} catch (e) {
options = {};
Cu.reportError(`Problem parsing options pref for ${feedPrefName}`);
}
try {
storedPrefs = (await this._storage.get(feedPrefName)) || {};
} catch (e) {
storedPrefs = {};
Cu.reportError(`Problem getting stored prefs for ${feedPrefName}`);
}
const defaultSection = BUILT_IN_SECTIONS[feedPrefName](options);
const section = Object.assign({}, defaultSection, {
pref: Object.assign(
{},
defaultSection.pref,
getDefaultOptions(storedPrefs)
),
});
section.pref.feed = feedPrefName;
this.addSection(section.id, Object.assign(section, { options }));
},
addSection(id, options) {
this.updateLinkMenuOptions(options, id);
this.sections.set(id, options);
this.emit(this.ADD_SECTION, id, options);
},
removeSection(id) {
this.emit(this.REMOVE_SECTION, id);
this.sections.delete(id);
},
enableSection(id) {
this.updateSection(id, { enabled: true }, true);
this.emit(this.ENABLE_SECTION, id);
},
disableSection(id) {
this.updateSection(
id,
{ enabled: false, rows: [], initialized: false },
true
);
this.emit(this.DISABLE_SECTION, id);
},
updateSections() {
this.sections.forEach((section, id) =>
this.updateSection(id, section, true)
);
},
updateSection(id, options, shouldBroadcast) {
this.updateLinkMenuOptions(options, id);
if (this.sections.has(id)) {
const optionsWithDedupe = Object.assign({}, options, {
dedupeConfigurations: this._dedupeConfiguration,
});
this.sections.set(id, Object.assign(this.sections.get(id), options));
this.emit(this.UPDATE_SECTION, id, optionsWithDedupe, shouldBroadcast);
}
},
/**
* Save metadata to places db and add a visit for that URL.
*/
updateBookmarkMetadata({ url }) {
this.sections.forEach((section, id) => {
if (id === "highlights") {
// Skip Highlights cards, we already have that metadata.
return;
}
if (section.rows) {
section.rows.forEach(card => {
if (
card.url === url &&
card.description &&
card.title &&
card.image
) {
PlacesUtils.history.update({
url: card.url,
title: card.title,
description: card.description,
previewImageURL: card.image,
});
// Highlights query skips bookmarks with no visits.
PlacesUtils.history.insert({
url,
title: card.title,
visits: [{}],
});
}
});
}
});
},
/**
* Sets the section's context menu options. These are all available context menu
* options minus the ones that are tied to a pref (see CONTEXT_MENU_PREFS) set
* to false.
*
* @param options section options
* @param id section ID
*/
updateLinkMenuOptions(options, id) {
if (options.availableLinkMenuOptions) {
options.contextMenuOptions = options.availableLinkMenuOptions.filter(
o =>
!this.CONTEXT_MENU_PREFS[o] ||
Services.prefs.getBoolPref(this.CONTEXT_MENU_PREFS[o])
);
}
// Once we have rows, we can give each card it's own context menu based on it's type.
// We only want to do this for highlights because those have different data types.
// All other sections (built by the web extension API) will have the same context menu per section
if (options.rows && id === "highlights") {
this._addCardTypeLinkMenuOptions(options.rows);
}
},
/**
* Sets each card in highlights' context menu options based on the card's type.
* (See types.js for a list of types)
*
* @param rows section rows containing a type for each card
*/
_addCardTypeLinkMenuOptions(rows) {
for (let card of rows) {
if (!this.CONTEXT_MENU_OPTIONS_FOR_HIGHLIGHT_TYPES[card.type]) {
Cu.reportError(
`No context menu for highlight type ${card.type} is configured`
);
} else {
card.contextMenuOptions = this.CONTEXT_MENU_OPTIONS_FOR_HIGHLIGHT_TYPES[
card.type
];
// Remove any options that shouldn't be there based on CONTEXT_MENU_PREFS.
// For example: If the Pocket extension is disabled, we should remove the CheckSavedToPocket option
// for each card that has it
card.contextMenuOptions = card.contextMenuOptions.filter(
o =>
!this.CONTEXT_MENU_PREFS[o] ||
Services.prefs.getBoolPref(this.CONTEXT_MENU_PREFS[o])
);
}
}
},
/**
* Update a specific section card by its url. This allows an action to be
* broadcast to all existing pages to update a specific card without having to
* also force-update the rest of the section's cards and state on those pages.
*
* @param id The id of the section with the card to be updated
* @param url The url of the card to update
* @param options The options to update for the card
* @param shouldBroadcast Whether or not to broadcast the update
*/
updateSectionCard(id, url, options, shouldBroadcast) {
if (this.sections.has(id)) {
const card = this.sections.get(id).rows.find(elem => elem.url === url);
if (card) {
Object.assign(card, options);
}
this.emit(this.UPDATE_SECTION_CARD, id, url, options, shouldBroadcast);
}
},
removeSectionCard(sectionId, url) {
if (!this.sections.has(sectionId)) {
return;
}
const rows = this.sections
.get(sectionId)
.rows.filter(row => row.url !== url);
this.updateSection(sectionId, { rows }, true);
},
onceInitialized(callback) {
if (this.initialized) {
callback();
} else {
this.once(this.INIT, callback);
}
},
uninit() {
Object.keys(this.CONTEXT_MENU_PREFS).forEach(k =>
Services.prefs.removeObserver(this.CONTEXT_MENU_PREFS[k], this)
);
SectionsManager.initialized = false;
},
};
for (const action of [
"ACTION_DISPATCHED",
"ADD_SECTION",
"REMOVE_SECTION",
"ENABLE_SECTION",
"DISABLE_SECTION",
"UPDATE_SECTION",
"UPDATE_SECTION_CARD",
"INIT",
"UNINIT",
]) {
SectionsManager[action] = action;
}
EventEmitter.decorate(SectionsManager);
class SectionsFeed {
constructor() {
this.init = this.init.bind(this);
this.onAddSection = this.onAddSection.bind(this);
this.onRemoveSection = this.onRemoveSection.bind(this);
this.onUpdateSection = this.onUpdateSection.bind(this);
this.onUpdateSectionCard = this.onUpdateSectionCard.bind(this);
}
init() {
SectionsManager.on(SectionsManager.ADD_SECTION, this.onAddSection);
SectionsManager.on(SectionsManager.REMOVE_SECTION, this.onRemoveSection);
SectionsManager.on(SectionsManager.UPDATE_SECTION, this.onUpdateSection);
SectionsManager.on(
SectionsManager.UPDATE_SECTION_CARD,
this.onUpdateSectionCard
);
// Catch any sections that have already been added
SectionsManager.sections.forEach((section, id) =>
this.onAddSection(SectionsManager.ADD_SECTION, id, section)
);
}
uninit() {
SectionsManager.uninit();
SectionsManager.emit(SectionsManager.UNINIT);
SectionsManager.off(SectionsManager.ADD_SECTION, this.onAddSection);
SectionsManager.off(SectionsManager.REMOVE_SECTION, this.onRemoveSection);
SectionsManager.off(SectionsManager.UPDATE_SECTION, this.onUpdateSection);
SectionsManager.off(
SectionsManager.UPDATE_SECTION_CARD,
this.onUpdateSectionCard
);
}
onAddSection(event, id, options) {
if (options) {
this.store.dispatch(
ac.BroadcastToContent({
type: at.SECTION_REGISTER,
data: Object.assign({ id }, options),
})
);
// Make sure the section is in sectionOrder pref. Otherwise, prepend it.
const orderedSections = this.orderedSectionIds;
if (!orderedSections.includes(id)) {
orderedSections.unshift(id);
this.store.dispatch(
ac.SetPref("sectionOrder", orderedSections.join(","))
);
}
}
}
onRemoveSection(event, id) {
this.store.dispatch(
ac.BroadcastToContent({ type: at.SECTION_DEREGISTER, data: id })
);
}
onUpdateSection(event, id, options, shouldBroadcast = false) {
if (options) {
const action = {
type: at.SECTION_UPDATE,
data: Object.assign(options, { id }),
};
this.store.dispatch(
shouldBroadcast
? ac.BroadcastToContent(action)
: ac.AlsoToPreloaded(action)
);
}
}
onUpdateSectionCard(event, id, url, options, shouldBroadcast = false) {
if (options) {
const action = {
type: at.SECTION_UPDATE_CARD,
data: { id, url, options },
};
this.store.dispatch(
shouldBroadcast
? ac.BroadcastToContent(action)
: ac.AlsoToPreloaded(action)
);
}
}
get orderedSectionIds() {
return this.store.getState().Prefs.values.sectionOrder.split(",");
}
get enabledSectionIds() {
let sections = this.store
.getState()
.Sections.filter(section => section.enabled)
.map(s => s.id);
// Top Sites is a special case. Append if the feed is enabled.
if (this.store.getState().Prefs.values["feeds.topsites"]) {
sections.push("topsites");
}
return sections;
}
moveSection(id, direction) {
const orderedSections = this.orderedSectionIds;
const enabledSections = this.enabledSectionIds;
let index = orderedSections.indexOf(id);
orderedSections.splice(index, 1);
if (direction > 0) {
// "Move Down"
while (index < orderedSections.length) {
// If the section at the index is enabled/visible, insert moved section after.
// Otherwise, move on to the next spot and check it.
if (enabledSections.includes(orderedSections[index++])) {
break;
}
}
} else {
// "Move Up"
while (index > 0) {
// If the section at the previous index is enabled/visible, insert moved section there.
// Otherwise, move on to the previous spot and check it.
index--;
if (enabledSections.includes(orderedSections[index])) {
break;
}
}
}
orderedSections.splice(index, 0, id);
this.store.dispatch(ac.SetPref("sectionOrder", orderedSections.join(",")));
}
async onAction(action) {
switch (action.type) {
case at.INIT:
SectionsManager.onceInitialized(this.init);
break;
// Wait for pref values, as some sections have options stored in prefs
case at.PREFS_INITIAL_VALUES:
SectionsManager.init(
action.data,
this.store.dbStorage.getDbTable("sectionPrefs")
);
break;
case at.PREF_CHANGED: {
if (action.data) {
const matched = action.data.name.match(
/^(feeds.section.(\S+)).options$/i
);
if (matched) {
await SectionsManager.addBuiltInSection(
matched[1],
action.data.value
);
this.store.dispatch({
type: at.SECTION_OPTIONS_CHANGED,
data: matched[2],
});
}
}
break;
}
case at.UPDATE_SECTION_PREFS:
SectionsManager.updateSectionPrefs(action.data.id, action.data.value);
break;
case at.PLACES_BOOKMARK_ADDED:
SectionsManager.updateBookmarkMetadata(action.data);
break;
case at.WEBEXT_DISMISS:
if (action.data) {
SectionsManager.removeSectionCard(
action.data.source,
action.data.url
);
}
break;
case at.SECTION_DISABLE:
SectionsManager.disableSection(action.data);
break;
case at.SECTION_ENABLE:
SectionsManager.enableSection(action.data);
break;
case at.SECTION_MOVE:
this.moveSection(action.data.id, action.data.direction);
break;
case at.UNINIT:
this.uninit();
break;
}
if (
SectionsManager.ACTIONS_TO_PROXY.includes(action.type) &&
SectionsManager.sections.size > 0
) {
SectionsManager.emit(
SectionsManager.ACTION_DISPATCHED,
action.type,
action.data
);
}
}
}
this.SectionsFeed = SectionsFeed;
this.SectionsManager = SectionsManager;
const EXPORTED_SYMBOLS = ["SectionsFeed", "SectionsManager"];
================================================
FILE: lib/ShortURL.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/. */
const { XPCOMUtils } = ChromeUtils.import(
"resource://gre/modules/XPCOMUtils.jsm"
);
const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
XPCOMUtils.defineLazyServiceGetter(
this,
"IDNService",
"@mozilla.org/network/idn-service;1",
"nsIIDNService"
);
XPCOMUtils.defineLazyGlobalGetters(this, ["URL"]);
/**
* Properly convert internationalized domain names.
* @param {string} host Domain hostname.
* @returns {string} Hostname suitable to be displayed.
*/
function handleIDNHost(hostname) {
try {
return IDNService.convertToDisplayIDN(hostname, {});
} catch (e) {
// If something goes wrong (e.g. host is an IP address) just fail back
// to the full domain.
return hostname;
}
}
/**
* Get the effective top level domain of a host.
* @param {string} host The host to be analyzed.
* @return {str} The suffix or empty string if there's no suffix.
*/
function getETLD(host) {
try {
return Services.eTLD.getPublicSuffixFromHost(host);
} catch (err) {
return "";
}
}
/**
* shortURL - Creates a short version of a link's url, used for display purposes
* e.g. {url: http://www.foosite.com} => "foosite"
*
* @param {obj} link A link object
* {str} link.url (required)- The url of the link
* @return {str} A short url
*/
function shortURL({ url }) {
if (!url) {
return "";
}
// Make sure we have a valid / parseable url
let parsed;
try {
parsed = new URL(url);
} catch (ex) {
// Not entirely sure what we have, but just give it back
return url;
}
// Clean up the url (lowercase hostname via URL and remove www.)
const hostname = parsed.hostname.replace(/^www\./i, "");
// Remove the eTLD (e.g., com, net) and the preceding period from the hostname
const eTLD = getETLD(hostname);
const eTLDExtra = eTLD.length ? -(eTLD.length + 1) : Infinity;
// Ideally get the short eTLD-less host but fall back to longer url parts
return (
handleIDNHost(hostname.slice(0, eTLDExtra) || hostname) ||
parsed.pathname ||
parsed.href
);
}
const EXPORTED_SYMBOLS = ["shortURL", "getETLD"];
================================================
FILE: lib/SiteClassifier.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 { RemoteSettings } = ChromeUtils.import(
"resource://services-settings/remote-settings.js"
);
// Returns whether the passed in params match the criteria.
// To match, they must contain all the params specified in criteria and the values
// must match if a value is provided in criteria.
function _hasParams(criteria, params) {
for (let param of criteria) {
const val = params.get(param.key);
if (
val === null ||
(param.value && param.value !== val) ||
(param.prefix && !val.startsWith(param.prefix))
) {
return false;
}
}
return true;
}
/**
* classifySite
* Classifies a given URL into a category based on classification data from RemoteSettings.
* The data from remote settings can match a category by one of the following:
* - match the exact URL
* - match the hostname or second level domain (sld)
* - match query parameter(s), and optionally their values or prefixes
* - match both (hostname or sld) and query parameter(s)
*
* The data looks like:
* [{
* "type": "hostname-and-params-match",
* "criteria": [
* {
* "url": "https://matchurl.com",
* "hostname": "matchhostname.com",
* "sld": "secondleveldomain",
* "params": [
* {
* "key": "matchparam",
* "value": "matchvalue",
* "prefix": "matchpPrefix",
* },
* ],
* },
* ],
* "weight": 300,
* },...]
*/
async function classifySite(url, RS = RemoteSettings) {
let category = "other";
let parsedURL;
// Try to parse the url.
for (let _url of [url, `https://${url}`]) {
try {
parsedURL = new URL(_url);
break;
} catch (e) {}
}
if (parsedURL) {
// If we parsed successfully, find a match.
const hostname = parsedURL.hostname.replace(/^www\./i, "");
const params = parsedURL.searchParams;
// NOTE: there will be an initial/default local copy of the data in m-c.
// Therefore, this should never return an empty list [].
const siteTypes = await RS("sites-classification").get();
const sortedSiteTypes = siteTypes.sort(
(x, y) => (y.weight || 0) - (x.weight || 0)
);
for (let type of sortedSiteTypes) {
for (let criteria of type.criteria) {
if (criteria.url && criteria.url !== url) {
continue;
}
if (criteria.hostname && criteria.hostname !== hostname) {
continue;
}
if (criteria.sld && criteria.sld !== hostname.split(".")[0]) {
continue;
}
if (criteria.params && !_hasParams(criteria.params, params)) {
continue;
}
return type.type;
}
}
}
return category;
}
const EXPORTED_SYMBOLS = ["classifySite"];
================================================
FILE: lib/SnippetsTestMessageProvider.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 TEST_ICON = "chrome://branding/content/icon64.png";
const TEST_ICON_16 = "chrome://branding/content/icon16.png";
const TEST_ICON_BW =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAQAAAC1+jfqAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAAAmJLR0QA/4ePzL8AAAAHdElNRQfjBQ8QDifrKGc/AAABf0lEQVQoz4WRO08UUQCFvztzd1AgG9jRgGwkhEoMIYGSygYt+A00tpZGY0jYxAJKEwkNjX9AK2xACx4dhFiQQCiMMRr2kYXdnQcz7L0z91qAMVac6hTfSU7OgVsk/prtyfSNfRb7ge2cd7dmVucP/wM2lwqVqoyICahRx9Nz71+8AnAAvlTct+dSYDBYcgJ+Fj68XFu/AfamnIoWFoHFYrAUuYMSn55/fAIOxIs1t4MhQpNxRYsUD0ld7r8DCfZph4QecrqkhCREgMLSeISQkAy0UBgE0CYgIkeRA9HdsCQhpEGCxichpItHigEcPH4XJLRbTf8STY0iiiuu60Ifxexx04F0N+aCgJCAhPQmD/cp/RC5A79WvUyhUHSIidAIoESv9VfAhW9n8+XqTCoyMsz1cviMMrGz9BrjAuboYHZajyXCInEocI8yvccbC+0muABanR4/tONjQz3DzgNKtj9sfv66XD9B/3tT9g/akb7h0bJwzxqqmlRHLr4rLPwBlYWoYj77l2AAAAAldEVYdGRhdGU6Y3JlYXRlADIwMTktMDUtMTVUMTY6MTQ6MzkrMDA6MDD5/4XBAAAAJXRFWHRkYXRlOm1vZGlmeQAyMDE5LTA1LTE1VDE2OjE0OjM5KzAwOjAwiKI9fQAAAABJRU5ErkJggg==";
const MESSAGES = () => [
{
id: "SIMPLE_TEST_1",
template: "simple_snippet",
campaign: "test_campaign_blocking",
content: {
icon: TEST_ICON,
icon_dark_theme: TEST_ICON_BW,
title: "Firefox Account!",
title_icon: TEST_ICON_16,
title_icon_dark_theme: TEST_ICON_BW,
text:
"Sync it, link it, take it with you . All this and more with a Firefox Account.",
links: {
syncLink: { url: "https://www.mozilla.org/en-US/firefox/accounts" },
},
block_button_text: "Block",
},
},
{
id: "SIMPLE_TEST_1_NO_DARK_THEME",
template: "simple_snippet",
campaign: "test_campaign_blocking",
content: {
icon: TEST_ICON,
icon_dark_theme: "",
title: "Firefox Account!",
title_icon: TEST_ICON_16,
title_icon_dark_theme: "",
text:
"Sync it, link it, take it with you . All this and more with a Firefox Account.",
links: {
syncLink: { url: "https://www.mozilla.org/en-US/firefox/accounts" },
},
block_button_text: "Block",
},
},
{
id: "SIMPLE_TEST_1_SAME_CAMPAIGN",
template: "simple_snippet",
campaign: "test_campaign_blocking",
content: {
icon: TEST_ICON,
icon_dark_theme: TEST_ICON_BW,
text:
"Sync it, link it, take it with you . All this and more with a Firefox Account.",
links: {
syncLink: { url: "https://www.mozilla.org/en-US/firefox/accounts" },
},
block_button_text: "Block",
},
},
{
id: "SIMPLE_TEST_TALL",
template: "simple_snippet",
content: {
icon: TEST_ICON,
icon_dark_theme: TEST_ICON_BW,
text:
"Sync it, link it, take it with you . All this and more with a Firefox Account.",
links: {
syncLink: { url: "https://www.mozilla.org/en-US/firefox/accounts" },
},
button_label: "Get one now!",
button_url: "https://www.mozilla.org/en-US/firefox/accounts",
block_button_text: "Block",
tall: true,
},
},
{
id: "SIMPLE_TEST_BUTTON_URL_1",
template: "simple_snippet",
content: {
icon: TEST_ICON,
icon_dark_theme: TEST_ICON_BW,
button_label: "Get one now!",
button_url: "https://www.mozilla.org/en-US/firefox/accounts",
text:
"Sync it, link it, take it with you. All this and more with a Firefox Account.",
block_button_text: "Block",
},
},
{
id: "SIMPLE_TEST_BUTTON_ACTION_1",
template: "simple_snippet",
content: {
icon: TEST_ICON,
icon_dark_theme: TEST_ICON_BW,
button_label: "Open about:config",
button_action: "OPEN_ABOUT_PAGE",
button_action_args: "config",
text: "Testing the OPEN_ABOUT_PAGE action",
block_button_text: "Block",
},
},
{
id: "SIMPLE_WITH_TITLE_TEST_1",
template: "simple_snippet",
content: {
icon: TEST_ICON,
icon_dark_theme: TEST_ICON_BW,
title: "Ready to sync?",
text: "Get connected with a Firefox account .",
links: {
syncLink: { url: "https://www.mozilla.org/en-US/firefox/accounts" },
},
block_button_text: "Block",
},
},
{
id: "NEWSLETTER_TEST_DEFAULTS",
template: "newsletter_snippet",
content: {
scene1_icon: TEST_ICON,
scene1_icon_dark_theme: TEST_ICON_BW,
scene1_title: "Be a part of a movement.",
scene1_title_icon: TEST_ICON_16,
scene1_title_icon_dark_theme: TEST_ICON_BW,
scene1_text:
"Internet shutdowns, hackers, harassment – the health of the internet is on the line. Sign up and Mozilla will keep you updated on how you can help.",
scene1_button_label: "Continue",
scene1_button_color: "#712b00",
scene1_button_background_color: "#ff9400",
scene2_title: "Let's do this!",
locale: "en-CA",
scene2_dismiss_button_text: "Dismiss",
scene2_text:
"Sign up for the Mozilla newsletter and we will keep you updated on how you can help.",
scene2_privacy_html:
"I'm okay with Mozilla handling my info as explained in this Privacy Notice .",
scene2_newsletter: "mozilla-foundation",
success_text: "Check your inbox for the confirmation!",
error_text: "Error!",
retry_button_label: "Try again?",
links: {
privacyLink: {
url:
"https://www.mozilla.org/privacy/websites/?sample_rate=0.001&snippet_name=7894",
},
},
},
},
{
id: "NEWSLETTER_TEST_1",
template: "newsletter_snippet",
content: {
scene1_icon: TEST_ICON,
scene1_icon_dark_theme: TEST_ICON_BW,
scene1_title: "Be a part of a movement.",
scene1_title_icon: "",
scene1_text:
"Internet shutdowns, hackers, harassment – the health of the internet is on the line. Sign up and Mozilla will keep you updated on how you can help.",
scene1_button_label: "Continue",
scene1_button_color: "#712b00",
scene1_button_background_color: "#ff9400",
scene2_title: "Let's do this!",
locale: "en-CA",
scene2_dismiss_button_text: "Dismiss",
scene2_text:
"Sign up for the Mozilla newsletter and we will keep you updated on how you can help.",
scene2_privacy_html:
"I'm okay with Mozilla handling my info as explained in this Privacy Notice .",
scene2_button_label: "Sign Me up",
scene2_email_placeholder_text: "Your email here",
scene2_newsletter: "mozilla-foundation",
success_text: "Check your inbox for the confirmation!",
error_text: "Error!",
links: {
privacyLink: {
url:
"https://www.mozilla.org/privacy/websites/?sample_rate=0.001&snippet_name=7894",
},
},
},
},
{
id: "NEWSLETTER_TEST_SCENE1_SECTION_TITLE_ICON",
template: "newsletter_snippet",
content: {
scene1_icon: TEST_ICON,
scene1_icon_dark_theme: TEST_ICON_BW,
scene1_title: "Be a part of a movement.",
scene1_title_icon: "",
scene1_text:
"Internet shutdowns, hackers, harassment – the health of the internet is on the line. Sign up and Mozilla will keep you updated on how you can help.",
scene1_button_label: "Continue",
scene1_button_color: "#712b00",
scene1_button_background_color: "#ff9400",
scene1_section_title_icon:
"resource://activity-stream/data/content/assets/glyph-pocket-16.svg",
scene1_section_title_text:
"All the Firefox news that's fit to Firefox print!",
scene2_title: "Let's do this!",
locale: "en-CA",
scene2_dismiss_button_text: "Dismiss",
scene2_text:
"Sign up for the Mozilla newsletter and we will keep you updated on how you can help.",
scene2_privacy_html:
"I'm okay with Mozilla handling my info as explained in this Privacy Notice .",
scene2_button_label: "Sign Me up",
scene2_email_placeholder_text: "Your email here",
scene2_newsletter: "mozilla-foundation",
success_text: "Check your inbox for the confirmation!",
error_text: "Error!",
links: {
privacyLink: {
url:
"https://www.mozilla.org/privacy/websites/?sample_rate=0.001&snippet_name=7894",
},
},
},
},
{
id: "FXA_SNIPPET_TEST_1",
template: "fxa_signup_snippet",
content: {
scene1_icon: TEST_ICON,
scene1_icon_dark_theme: TEST_ICON_BW,
scene1_button_label: "Get connected with sync!",
scene1_button_color: "#712b00",
scene1_button_background_color: "#ff9400",
scene1_text:
"Connect to Firefox by securely syncing passwords, bookmarks, and open tabs.",
scene1_title: "Browser better.",
scene1_title_icon: TEST_ICON_16,
scene1_title_icon_dark_theme: TEST_ICON_BW,
scene2_text:
"Connect to your Firefox account to securely sync passwords, bookmarks, and open tabs.",
scene2_title: "Title 123",
scene2_email_placeholder_text: "Your email",
scene2_button_label: "Continue",
scene2_dismiss_button_text: "Dismiss",
},
},
{
id: "FXA_SNIPPET_TEST_TITLE_ICON",
template: "fxa_signup_snippet",
content: {
scene1_icon: TEST_ICON,
scene1_icon_dark_theme: TEST_ICON_BW,
scene1_button_label: "Get connected with sync!",
scene1_button_color: "#712b00",
scene1_button_background_color: "#ff9400",
scene1_text:
"Connect to Firefox by securely syncing passwords, bookmarks, and open tabs.",
scene1_title: "Browser better.",
scene1_title_icon: TEST_ICON_16,
scene1_title_icon_dark_theme: TEST_ICON_BW,
scene1_section_title_icon:
"resource://activity-stream/data/content/assets/glyph-pocket-16.svg",
scene1_section_title_text: "Firefox Accounts: Receivable benefits",
scene2_text:
"Connect to your Firefox account to securely sync passwords, bookmarks, and open tabs.",
scene2_title: "Title 123",
scene2_email_placeholder_text: "Your email",
scene2_button_label: "Continue",
scene2_dismiss_button_text: "Dismiss",
},
},
{
id: "SNIPPETS_SEND_TO_DEVICE_TEST",
template: "send_to_device_snippet",
content: {
include_sms: true,
locale: "en-CA",
country: "us",
message_id_sms: "ff-mobilesn-download",
message_id_email: "download-firefox-mobile",
scene1_button_background_color: "#6200a4",
scene1_button_color: "#FFFFFF",
scene1_button_label: "Install now",
scene1_icon: TEST_ICON,
scene1_icon_dark_theme: TEST_ICON_BW,
scene1_text: "Browse without compromise with Firefox Mobile.",
scene1_title: "Full-featured. Customizable. Lightning fast",
scene1_title_icon: TEST_ICON_16,
scene1_title_icon_dark_theme: TEST_ICON_BW,
scene2_button_label: "Send",
scene2_disclaimer_html:
"The intended recipient of the email must have consented. Learn more .",
scene2_dismiss_button_text: "Dismiss",
scene2_icon: TEST_ICON,
scene2_icon_dark_theme: TEST_ICON_BW,
scene2_input_placeholder: "Your email address or phone number",
scene2_text:
"Send Firefox to your phone and take a powerful independent browser with you.",
scene2_title: "Let's do this!",
error_text: "Oops, there was a problem.",
success_title: "Your download link was sent.",
success_text: "Check your device for the email message!",
links: {
privacyLink: {
url:
"https://www.mozilla.org/privacy/websites/?sample_rate=0.001&snippet_name=7894",
},
},
},
},
{
id: "SNIPPETS_SEND_TO_DEVICE_TEST_NO_DARK_THEME",
template: "send_to_device_snippet",
content: {
include_sms: true,
locale: "en-CA",
country: "us",
message_id_sms: "ff-mobilesn-download",
message_id_email: "download-firefox-mobile",
scene1_button_background_color: "#6200a4",
scene1_button_color: "#FFFFFF",
scene1_button_label: "Install now",
scene1_icon: TEST_ICON,
scene1_icon_dark_theme: "",
scene1_text: "Browse without compromise with Firefox Mobile.",
scene1_title: "Full-featured. Customizable. Lightning fast",
scene1_title_icon: TEST_ICON_16,
scene1_title_icon_dark_theme: "",
scene2_button_label: "Send",
scene2_disclaimer_html:
"The intended recipient of the email must have consented. Learn more .",
scene2_dismiss_button_text: "Dismiss",
scene2_icon: TEST_ICON,
scene2_icon_dark_theme: "",
scene2_input_placeholder: "Your email address or phone number",
scene2_text:
"Send Firefox to your phone and take a powerful independent browser with you.",
scene2_title: "Let's do this!",
error_text: "Oops, there was a problem.",
success_title: "Your download link was sent.",
success_text: "Check your device for the email message!",
links: {
privacyLink: {
url:
"https://www.mozilla.org/privacy/websites/?sample_rate=0.001&snippet_name=7894",
},
},
},
},
{
id: "SNIPPETS_SEND_TO_DEVICE_TEST_SECTION_TITLE_ICON",
template: "send_to_device_snippet",
content: {
include_sms: true,
locale: "en-CA",
country: "us",
message_id_sms: "ff-mobilesn-download",
message_id_email: "download-firefox-mobile",
scene1_button_background_color: "#6200a4",
scene1_button_color: "#FFFFFF",
scene1_button_label: "Install now",
scene1_icon: TEST_ICON,
scene1_icon_dark_theme: TEST_ICON_BW,
scene1_text: "Browse without compromise with Firefox Mobile.",
scene1_title: "Full-featured. Customizable. Lightning fast",
scene1_title_icon: TEST_ICON_16,
scene1_title_icon_dark_theme: TEST_ICON_BW,
scene1_section_title_icon:
"resource://activity-stream/data/content/assets/glyph-pocket-16.svg",
scene1_section_title_text: "Send Firefox to your mobile device!",
scene2_button_label: "Send",
scene2_disclaimer_html:
"The intended recipient of the email must have consented. Learn more .",
scene2_dismiss_button_text: "Dismiss",
scene2_icon: TEST_ICON,
scene2_icon_dark_theme: TEST_ICON_BW,
scene2_input_placeholder: "Your email address or phone number",
scene2_text:
"Send Firefox to your phone and take a powerful independent browser with you.",
scene2_title: "Let's do this!",
error_text: "Oops, there was a problem.",
success_title: "Your download link was sent.",
success_text: "Check your device for the email message!",
links: {
privacyLink: {
url:
"https://www.mozilla.org/privacy/websites/?sample_rate=0.001&snippet_name=7894",
},
},
},
},
{
id: "EOY_TEST_1",
template: "eoy_snippet",
content: {
highlight_color: "#f05",
background_color: "#ddd",
text_color: "yellow",
selected_button: "donation_amount_first",
icon: TEST_ICON,
icon_dark_theme: TEST_ICON_BW,
button_label: "Donate",
monthly_checkbox_label_text: "Make my donation monthly",
currency_code: "usd",
donation_amount_first: 50,
donation_amount_second: 25,
donation_amount_third: 10,
donation_amount_fourth: 5,
donation_form_url:
"https://donate.mozilla.org/pl/?utm_source=desktop-snippet&utm_medium=snippet&utm_campaign=donate&utm_term=7556",
text:
"Big corporations want to restrict how we access the web. Fake news is making it harder for us to find the truth. Online bullies are silencing inspired voices. The not-for-profit Mozilla Foundation fights for a healthy internet with programs like our Tech Policy Fellowships and Internet Health Report; will you donate today ?",
},
},
{
id: "EOY_BOLD_TEST_1",
template: "eoy_snippet",
content: {
icon: TEST_ICON,
icon_dark_theme: TEST_ICON_BW,
selected_button: "donation_amount_second",
button_label: "Donate",
monthly_checkbox_label_text: "Make my donation monthly",
currency_code: "usd",
donation_amount_first: 50,
donation_amount_second: 25,
donation_amount_third: 10,
donation_amount_fourth: 5,
donation_form_url: "https://donate.mozilla.org",
text:
"Big corporations want to restrict how we access the web. Fake news is making it harder for us to find the truth. Online bullies are silencing inspired voices. The not-for-profit Mozilla Foundation fights for a healthy internet with programs like our Tech Policy Fellowships and Internet Health Report; will you donate today ?",
test: "bold",
},
},
{
id: "EOY_TAKEOVER_TEST_1",
template: "eoy_snippet",
content: {
icon: TEST_ICON,
icon_dark_theme: TEST_ICON_BW,
button_label: "Donate",
monthly_checkbox_label_text: "Make my donation monthly",
currency_code: "usd",
donation_amount_first: 50,
donation_amount_second: 25,
donation_amount_third: 10,
donation_amount_fourth: 5,
donation_form_url: "https://donate.mozilla.org",
text:
"Big corporations want to restrict how we access the web. Fake news is making it harder for us to find the truth. Online bullies are silencing inspired voices. The not-for-profit Mozilla Foundation fights for a healthy internet with programs like our Tech Policy Fellowships and Internet Health Report; will you donate today ?",
test: "takeover",
},
},
{
id: "SIMPLE_TEST_WITH_SECTION_HEADING",
template: "simple_snippet",
content: {
button_label: "Get one now!",
button_url: "https://www.mozilla.org/en-US/firefox/accounts",
icon: TEST_ICON,
icon_dark_theme: TEST_ICON_BW,
title: "Firefox Account!",
text:
"Sync it, link it, take it with you . All this and more with a Firefox Account.",
links: {
syncLink: { url: "https://www.mozilla.org/en-US/firefox/accounts" },
},
block_button_text: "Block",
section_title_icon:
"resource://activity-stream/data/content/assets/glyph-pocket-16.svg",
section_title_text: "Messages from Mozilla",
},
},
{
id: "SIMPLE_TEST_WITH_SECTION_HEADING_AND_LINK",
template: "simple_snippet",
content: {
icon: TEST_ICON,
icon_dark_theme: TEST_ICON_BW,
title: "Firefox Account!",
text:
"Sync it, link it, take it with you. All this and more with a Firefox Account.",
block_button_text: "Block",
section_title_icon:
"resource://activity-stream/data/content/assets/glyph-pocket-16.svg",
section_title_text: "Messages from Mozilla (click for info)",
section_title_url: "https://www.mozilla.org/about",
},
},
{
id: "SIMPLE_BELOW_SEARCH_TEST_1",
template: "simple_below_search_snippet",
content: {
icon: TEST_ICON,
icon_dark_theme: TEST_ICON_BW,
text:
"Securely store passwords, bookmarks, and more with a Firefox Account. Sign up ",
links: {
syncLink: { url: "https://www.mozilla.org/en-US/firefox/accounts" },
},
block_button_text: "Block",
},
},
{
id: "SIMPLE_BELOW_SEARCH_TEST_2",
template: "simple_below_search_snippet",
content: {
icon: TEST_ICON,
icon_dark_theme: TEST_ICON_BW,
text:
"Connect your Firefox Account to Sync your protected passwords, open tabs and bookmarks, and they'll always be available to you - on all of your devices.",
links: {
syncLink: { url: "https://www.mozilla.org/en-US/firefox/accounts" },
},
block_button_text: "Block",
},
},
{
id: "SIMPLE_BELOW_SEARCH_TEST_TITLE",
template: "simple_below_search_snippet",
content: {
icon: TEST_ICON,
icon_dark_theme: TEST_ICON_BW,
title: "See if you've been part of an online data breach.",
text:
"Securely store passwords, bookmarks, and more with a Firefox Account. Sign up ",
links: {
syncLink: { url: "https://www.mozilla.org/en-US/firefox/accounts" },
},
block_button_text: "Block",
},
},
{
id: "SPECIAL_SNIPPET_BUTTON_1",
template: "simple_below_search_snippet",
content: {
icon: TEST_ICON,
icon_dark_theme: TEST_ICON_BW,
button_label: "Find Out Now",
button_url: "https://www.mozilla.org/en-US/firefox/accounts",
title: "See if you've been part of an online data breach.",
text: "Firefox Monitor tells you what hackers already know about you.",
block_button_text: "Block",
},
},
{
id: "SPECIAL_SNIPPET_LONG_CONTENT",
template: "simple_below_search_snippet",
content: {
icon: TEST_ICON,
icon_dark_theme: TEST_ICON_BW,
button_label: "Find Out Now",
button_url: "https://www.mozilla.org/en-US/firefox/accounts",
title: "See if you've been part of an online data breach.",
text:
"Firefox Monitor tells you what hackers already know about you. Here's some extra text to make the content really long.",
block_button_text: "Block",
},
},
{
id: "SPECIAL_SNIPPET_NO_TITLE",
template: "simple_below_search_snippet",
content: {
icon: TEST_ICON,
icon_dark_theme: TEST_ICON_BW,
button_label: "Find Out Now",
button_url: "https://www.mozilla.org/en-US/firefox/accounts",
text: "Firefox Monitor tells you what hackers already know about you.",
block_button_text: "Block",
},
},
{
id: "SPECIAL_SNIPPET_MONITOR",
template: "simple_below_search_snippet",
content: {
icon: TEST_ICON,
title: "See if you've been part of an online data breach.",
text: "Firefox Monitor tells you what hackers already know about you.",
button_label: "Get monitor",
button_action: "ENABLE_FIREFOX_MONITOR",
button_action_args: {
url:
"https://monitor.firefox.com/oauth/init?utm_source=snippets&utm_campaign=monitor-snippet-test&form_type=email&entrypoint=newtab",
flowRequestParams: {
entrypoint: "snippets",
utm_term: "monitor",
form_type: "email",
},
},
block_button_text: "Block",
},
},
];
const SnippetsTestMessageProvider = {
getMessages() {
return (
MESSAGES()
// Ensures we never actually show test except when triggered by debug tools
.map(message => ({
...message,
targeting: `providerCohorts.snippets_local_testing == "SHOW_TEST"`,
}))
);
},
};
this.SnippetsTestMessageProvider = SnippetsTestMessageProvider;
const EXPORTED_SYMBOLS = ["SnippetsTestMessageProvider"];
================================================
FILE: lib/Store.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 { ActivityStreamMessageChannel } = ChromeUtils.import(
"resource://activity-stream/lib/ActivityStreamMessageChannel.jsm"
);
const { ActivityStreamStorage } = ChromeUtils.import(
"resource://activity-stream/lib/ActivityStreamStorage.jsm"
);
const { Prefs } = ChromeUtils.import(
"resource://activity-stream/lib/ActivityStreamPrefs.jsm"
);
const { reducers } = ChromeUtils.import(
"resource://activity-stream/common/Reducers.jsm"
);
const { redux } = ChromeUtils.import(
"resource://activity-stream/vendor/Redux.jsm"
);
/**
* Store - This has a similar structure to a redux store, but includes some extra
* functionality to allow for routing of actions between the Main processes
* and child processes via a ActivityStreamMessageChannel.
* It also accepts an array of "Feeds" on inititalization, which
* can listen for any action that is dispatched through the store.
*/
this.Store = class Store {
/**
* constructor - The redux store and message manager are created here,
* but no listeners are added until "init" is called.
*/
constructor() {
this._middleware = this._middleware.bind(this);
// Bind each redux method so we can call it directly from the Store. E.g.,
// store.dispatch() will call store._store.dispatch();
for (const method of ["dispatch", "getState", "subscribe"]) {
this[method] = (...args) => this._store[method](...args);
}
this.feeds = new Map();
this._prefs = new Prefs();
this._messageChannel = new ActivityStreamMessageChannel({
dispatch: this.dispatch,
});
this._store = redux.createStore(
redux.combineReducers(reducers),
redux.applyMiddleware(this._middleware, this._messageChannel.middleware)
);
this.storage = null;
}
/**
* _middleware - This is redux middleware consumed by redux.createStore.
* it calls each feed's .onAction method, if one
* is defined.
*/
_middleware() {
return next => action => {
next(action);
for (const store of this.feeds.values()) {
if (store.onAction) {
store.onAction(action);
}
}
};
}
/**
* initFeed - Initializes a feed by calling its constructor function
*
* @param {string} feedName The name of a feed, as defined in the object
* passed to Store.init
* @param {Action} initAction An optional action to initialize the feed
*/
initFeed(feedName, initAction) {
const feed = this._feedFactories.get(feedName)();
feed.store = this;
this.feeds.set(feedName, feed);
if (initAction && feed.onAction) {
feed.onAction(initAction);
}
}
/**
* uninitFeed - Removes a feed and calls its uninit function if defined
*
* @param {string} feedName The name of a feed, as defined in the object
* passed to Store.init
* @param {Action} uninitAction An optional action to uninitialize the feed
*/
uninitFeed(feedName, uninitAction) {
const feed = this.feeds.get(feedName);
if (!feed) {
return;
}
if (uninitAction && feed.onAction) {
feed.onAction(uninitAction);
}
this.feeds.delete(feedName);
}
/**
* onPrefChanged - Listener for handling feed changes.
*/
onPrefChanged(name, value) {
if (this._feedFactories.has(name)) {
if (value) {
this.initFeed(name, this._initAction);
} else {
this.uninitFeed(name, this._uninitAction);
}
}
}
/**
* init - Initializes the ActivityStreamMessageChannel channel, and adds feeds.
*
* Note that it intentionally initializes the TelemetryFeed first so that the
* addon is able to report the init errors from other feeds.
*
* @param {Map} feedFactories A Map of feeds with the name of the pref for
* the feed as the key and a function that
* constructs an instance of the feed.
* @param {Action} initAction An optional action that will be dispatched
* to feeds when they're created.
* @param {Action} uninitAction An optional action for when feeds uninit.
*/
async init(feedFactories, initAction, uninitAction) {
this._feedFactories = feedFactories;
this._initAction = initAction;
this._uninitAction = uninitAction;
const telemetryKey = "feeds.telemetry";
if (feedFactories.has(telemetryKey) && this._prefs.get(telemetryKey)) {
this.initFeed(telemetryKey);
}
await this._initIndexedDB(telemetryKey);
for (const pref of feedFactories.keys()) {
if (pref !== telemetryKey && this._prefs.get(pref)) {
this.initFeed(pref);
}
}
this._prefs.observeBranch(this);
this._messageChannel.createChannel();
// Dispatch an initial action after all enabled feeds are ready
if (initAction) {
this.dispatch(initAction);
}
// Dispatch NEW_TAB_INIT/NEW_TAB_LOAD events after INIT event.
this._messageChannel.simulateMessagesForExistingTabs();
}
async _initIndexedDB(telemetryKey) {
this.dbStorage = new ActivityStreamStorage({
storeNames: ["sectionPrefs", "snippets"],
telemetry: this.feeds.get(telemetryKey),
});
// Accessing the db causes the object stores to be created / migrated.
// This needs to happen before other instances try to access the db, which
// would update only a subset of the stores to the latest version.
try {
await this.dbStorage.db; // eslint-disable-line no-unused-expressions
} catch (e) {
this.dbStorage.telemetry = null;
}
}
/**
* uninit - Uninitalizes each feed, clears them, and destroys the message
* manager channel.
*
* @return {type} description
*/
uninit() {
if (this._uninitAction) {
this.dispatch(this._uninitAction);
}
this._prefs.ignoreBranch(this);
this.feeds.clear();
this._feedFactories = null;
this._messageChannel.destroyChannel();
}
};
const EXPORTED_SYMBOLS = ["Store"];
================================================
FILE: lib/SystemTickFeed.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"
);
ChromeUtils.defineModuleGetter(
this,
"setInterval",
"resource://gre/modules/Timer.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"clearInterval",
"resource://gre/modules/Timer.jsm"
);
// Frequency at which SYSTEM_TICK events are fired
const SYSTEM_TICK_INTERVAL = 5 * 60 * 1000;
this.SystemTickFeed = class SystemTickFeed {
init() {
this.intervalId = setInterval(
() => this.store.dispatch({ type: at.SYSTEM_TICK }),
SYSTEM_TICK_INTERVAL
);
}
onAction(action) {
switch (action.type) {
case at.INIT:
this.init();
break;
case at.UNINIT:
clearInterval(this.intervalId);
break;
}
}
};
this.SYSTEM_TICK_INTERVAL = SYSTEM_TICK_INTERVAL;
const EXPORTED_SYMBOLS = ["SystemTickFeed", "SYSTEM_TICK_INTERVAL"];
================================================
FILE: lib/TelemetryFeed.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 { XPCOMUtils } = ChromeUtils.import(
"resource://gre/modules/XPCOMUtils.jsm"
);
const { actionTypes: at, actionUtils: au } = ChromeUtils.import(
"resource://activity-stream/common/Actions.jsm"
);
const { Prefs } = ChromeUtils.import(
"resource://activity-stream/lib/ActivityStreamPrefs.jsm"
);
const { classifySite } = ChromeUtils.import(
"resource://activity-stream/lib/SiteClassifier.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"ASRouterPreferences",
"resource://activity-stream/lib/ASRouterPreferences.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"perfService",
"resource://activity-stream/common/PerfService.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"PingCentre",
"resource:///modules/PingCentre.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"UTEventReporting",
"resource://activity-stream/lib/UTEventReporting.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"UpdateUtils",
"resource://gre/modules/UpdateUtils.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"HomePage",
"resource:///modules/HomePage.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"ExtensionSettingsStore",
"resource://gre/modules/ExtensionSettingsStore.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"PrivateBrowsingUtils",
"resource://gre/modules/PrivateBrowsingUtils.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"ClientID",
"resource://gre/modules/ClientID.jsm"
);
XPCOMUtils.defineLazyServiceGetters(this, {
gUUIDGenerator: ["@mozilla.org/uuid-generator;1", "nsIUUIDGenerator"],
aboutNewTabService: [
"@mozilla.org/browser/aboutnewtab-service;1",
"nsIAboutNewTabService",
],
});
const ACTIVITY_STREAM_ID = "activity-stream";
const DOMWINDOW_OPENED_TOPIC = "domwindowopened";
const DOMWINDOW_UNLOAD_TOPIC = "unload";
const TAB_PINNED_EVENT = "TabPinned";
// This is a mapping table between the user preferences and its encoding code
const USER_PREFS_ENCODING = {
showSearch: 1 << 0,
"feeds.topsites": 1 << 1,
"feeds.section.topstories": 1 << 2,
"feeds.section.highlights": 1 << 3,
"feeds.snippets": 1 << 4,
showSponsored: 1 << 5,
"asrouter.userprefs.cfr.addons": 1 << 6,
"asrouter.userprefs.cfr.features": 1 << 7,
};
const PREF_IMPRESSION_ID = "impressionId";
const TELEMETRY_PREF = "telemetry";
const EVENTS_TELEMETRY_PREF = "telemetry.ut.events";
const STRUCTURED_INGESTION_TELEMETRY_PREF = "telemetry.structuredIngestion";
const STRUCTURED_INGESTION_ENDPOINT_PREF =
"telemetry.structuredIngestion.endpoint";
// List of namespaces for the structured ingestion system.
// They are defined in https://github.com/mozilla-services/mozilla-pipeline-schemas
const STRUCTURED_INGESTION_NAMESPACE_AS = "activity-stream";
const STRUCTURED_INGESTION_NAMESPACE_MS = "messaging-system";
this.TelemetryFeed = class TelemetryFeed {
constructor(options) {
this.sessions = new Map();
this._prefs = new Prefs();
this._impressionId = this.getOrCreateImpressionId();
this._aboutHomeSeen = false;
this._classifySite = classifySite;
this._addWindowListeners = this._addWindowListeners.bind(this);
this.handleEvent = this.handleEvent.bind(this);
}
get telemetryEnabled() {
return this._prefs.get(TELEMETRY_PREF);
}
get eventTelemetryEnabled() {
return this._prefs.get(EVENTS_TELEMETRY_PREF);
}
get structuredIngestionTelemetryEnabled() {
return this._prefs.get(STRUCTURED_INGESTION_TELEMETRY_PREF);
}
get structuredIngestionEndpointBase() {
return this._prefs.get(STRUCTURED_INGESTION_ENDPOINT_PREF);
}
get telemetryClientId() {
Object.defineProperty(this, "telemetryClientId", {
value: ClientID.getClientID(),
});
return this.telemetryClientId;
}
init() {
Services.obs.addObserver(
this.browserOpenNewtabStart,
"browser-open-newtab-start"
);
// Add pin tab event listeners on future windows
Services.obs.addObserver(this._addWindowListeners, DOMWINDOW_OPENED_TOPIC);
// Listen for pin tab events on all open windows
for (let win of Services.wm.getEnumerator("navigator:browser")) {
this._addWindowListeners(win);
}
}
handleEvent(event) {
switch (event.type) {
case TAB_PINNED_EVENT:
this.countPinnedTab(event.target);
break;
case DOMWINDOW_UNLOAD_TOPIC:
this._removeWindowListeners(event.target);
break;
}
}
_removeWindowListeners(win) {
win.removeEventListener(DOMWINDOW_UNLOAD_TOPIC, this.handleEvent);
win.removeEventListener(TAB_PINNED_EVENT, this.handleEvent);
}
_addWindowListeners(win) {
win.addEventListener(DOMWINDOW_UNLOAD_TOPIC, this.handleEvent);
win.addEventListener(TAB_PINNED_EVENT, this.handleEvent);
}
countPinnedTab(target, source = "TAB_CONTEXT_MENU") {
const win = target.ownerGlobal;
if (PrivateBrowsingUtils.isWindowPrivate(win)) {
return;
}
const event = Object.assign(this.createPing(), {
action: "activity_stream_user_event",
event: TAB_PINNED_EVENT.toUpperCase(),
value: { total_pinned_tabs: this.countTotalPinnedTabs() },
source,
// These fields are required but not relevant for this ping
page: "n/a",
session_id: "n/a",
});
this.sendEvent(event);
}
countTotalPinnedTabs() {
let pinnedTabs = 0;
for (let win of Services.wm.getEnumerator("navigator:browser")) {
if (win.closed || PrivateBrowsingUtils.isWindowPrivate(win)) {
continue;
}
for (let tab of win.gBrowser.tabs) {
pinnedTabs += tab.pinned ? 1 : 0;
}
}
return pinnedTabs;
}
getOrCreateImpressionId() {
let impressionId = this._prefs.get(PREF_IMPRESSION_ID);
if (!impressionId) {
impressionId = String(gUUIDGenerator.generateUUID());
this._prefs.set(PREF_IMPRESSION_ID, impressionId);
}
return impressionId;
}
browserOpenNewtabStart() {
perfService.mark("browser-open-newtab-start");
}
setLoadTriggerInfo(port) {
// XXX note that there is a race condition here; we're assuming that no
// other tab will be interleaving calls to browserOpenNewtabStart and
// when at.NEW_TAB_INIT gets triggered by RemotePages and calls this
// method. For manually created windows, it's hard to imagine us hitting
// this race condition.
//
// However, for session restore, where multiple windows with multiple tabs
// might be restored much closer together in time, it's somewhat less hard,
// though it should still be pretty rare.
//
// The fix to this would be making all of the load-trigger notifications
// return some data with their notifications, and somehow propagate that
// data through closures into the tab itself so that we could match them
//
// As of this writing (very early days of system add-on perf telemetry),
// the hypothesis is that hitting this race should be so rare that makes
// more sense to live with the slight data inaccuracy that it would
// introduce, rather than doing the correct but complicated thing. It may
// well be worth reexamining this hypothesis after we have more experience
// with the data.
let data_to_save;
try {
data_to_save = {
load_trigger_ts: perfService.getMostRecentAbsMarkStartByName(
"browser-open-newtab-start"
),
load_trigger_type: "menu_plus_or_keyboard",
};
} catch (e) {
// if no mark was returned, we have nothing to save
return;
}
this.saveSessionPerfData(port, data_to_save);
}
/**
* Lazily initialize PingCentre for Activity Stream to send pings
*/
get pingCentre() {
Object.defineProperty(this, "pingCentre", {
value: new PingCentre({ topic: ACTIVITY_STREAM_ID }),
});
return this.pingCentre;
}
/**
* Lazily initialize UTEventReporting to send pings
*/
get utEvents() {
Object.defineProperty(this, "utEvents", { value: new UTEventReporting() });
return this.utEvents;
}
/**
* Get encoded user preferences, multiple prefs will be combined via bitwise OR operator
*/
get userPreferences() {
let prefs = 0;
for (const pref of Object.keys(USER_PREFS_ENCODING)) {
if (this._prefs.get(pref)) {
prefs |= USER_PREFS_ENCODING[pref];
}
}
return prefs;
}
/**
* Check if it is in the CFR experiment cohort. ASRouterPreferences lazily parses AS router pref.
*/
get isInCFRCohort() {
for (let provider of ASRouterPreferences.providers) {
if (provider.id === "cfr" && provider.enabled && provider.cohort) {
return true;
}
}
return false;
}
/**
* addSession - Start tracking a new session
*
* @param {string} id the portID of the open session
* @param {string} the URL being loaded for this session (optional)
* @return {obj} Session object
*/
addSession(id, url) {
// XXX refactor to use setLoadTriggerInfo or saveSessionPerfData
// "unexpected" will be overwritten when appropriate
let load_trigger_type = "unexpected";
let load_trigger_ts;
if (!this._aboutHomeSeen && url === "about:home") {
this._aboutHomeSeen = true;
// XXX note that this will be incorrectly set in the following cases:
// session_restore following by clicking on the toolbar button,
// or someone who has changed their default home page preference to
// something else and later clicks the toolbar. It will also be
// incorrectly unset if someone changes their "Home Page" preference to
// about:newtab.
//
// That said, the ratio of these mistakes to correct cases should
// be very small, and these issues should follow away as we implement
// the remaining load_trigger_type values for about:home in issue 3556.
//
// XXX file a bug to implement remaining about:home cases so this
// problem will go away and link to it here.
load_trigger_type = "first_window_opened";
// The real perceived trigger of first_window_opened is the OS-level
// clicking of the icon. We use perfService.timeOrigin because it's the
// earliest number on this time scale that's easy to get.; We could
// actually use 0, but maybe that could be before the browser started?
// [bug 1401406](https://bugzilla.mozilla.org/show_bug.cgi?id=1401406)
// getting sorted out may help clarify. Even better, presumably, would be
// to use the process creation time for the main process, which is
// available, but somewhat harder to get. However, these are all more or
// less proxies for the same thing, so it's not clear how much the better
// numbers really matter, since we (activity stream) only control a
// relatively small amount of the code that's executing between the
// OS-click and when the first element starts loading. That
// said, it's conceivable that it could help us catch regressions in the
// number of cycles early chrome code takes to execute, but it's likely
// that there are more direct ways to measure that.
load_trigger_ts = perfService.timeOrigin;
}
const session = {
session_id: String(gUUIDGenerator.generateUUID()),
// "unknown" will be overwritten when appropriate
page: url ? url : "unknown",
perf: {
load_trigger_type,
is_preloaded: false,
},
};
if (load_trigger_ts) {
session.perf.load_trigger_ts = load_trigger_ts;
}
this.sessions.set(id, session);
return session;
}
/**
* endSession - Stop tracking a session
*
* @param {string} portID the portID of the session that just closed
*/
endSession(portID) {
const session = this.sessions.get(portID);
if (!session) {
// It's possible the tab was never visible – in which case, there was no user session.
return;
}
this.sendDiscoveryStreamLoadedContent(portID, session);
this.sendDiscoveryStreamImpressions(portID, session);
if (session.perf.visibility_event_rcvd_ts) {
session.session_duration = Math.round(
perfService.absNow() - session.perf.visibility_event_rcvd_ts
);
} else {
// This session was never shown (i.e. the hidden preloaded newtab), there was no user session either.
this.sessions.delete(portID);
return;
}
let sessionEndEvent = this.createSessionEndEvent(session);
this.sendEvent(sessionEndEvent);
this.sendUTEvent(sessionEndEvent, this.utEvents.sendSessionEndEvent);
this.sessions.delete(portID);
}
/**
* Send impression pings for Discovery Stream for a given session.
*
* @note the impression reports are stored in session.impressionSets for different
* sources, and will be sent separately accordingly.
*
* @param {String} port The session port with which this is associated
* @param {Object} session The session object
*/
sendDiscoveryStreamImpressions(port, session) {
const { impressionSets } = session;
if (!impressionSets) {
return;
}
Object.keys(impressionSets).forEach(source => {
const payload = this.createImpressionStats(port, {
source,
tiles: impressionSets[source],
});
this.sendStructuredIngestionEvent(
payload,
STRUCTURED_INGESTION_NAMESPACE_AS,
"impression-stats",
"1"
);
});
}
/**
* Send loaded content pings for Discovery Stream for a given session.
*
* @note the loaded content reports are stored in session.loadedContentSets for different
* sources, and will be sent separately accordingly.
*
* @param {String} port The session port with which this is associated
* @param {Object} session The session object
*/
sendDiscoveryStreamLoadedContent(port, session) {
const { loadedContentSets } = session;
if (!loadedContentSets) {
return;
}
Object.keys(loadedContentSets).forEach(source => {
const tiles = loadedContentSets[source];
const payload = this.createImpressionStats(port, {
source,
tiles,
loaded: tiles.length,
});
this.sendStructuredIngestionEvent(
payload,
STRUCTURED_INGESTION_NAMESPACE_AS,
"impression-stats",
"1"
);
});
}
/**
* handleNewTabInit - Handle NEW_TAB_INIT, which creates a new session and sets the a flag
* for session.perf based on whether or not this new tab is preloaded
*
* @param {obj} action the Action object
*/
handleNewTabInit(action) {
const session = this.addSession(
au.getPortIdOfSender(action),
action.data.url
);
session.perf.is_preloaded =
action.data.browser.getAttribute("preloadedState") === "preloaded";
}
/**
* createPing - Create a ping with common properties
*
* @param {string} id The portID of the session, if a session is relevant (optional)
* @return {obj} A telemetry ping
*/
createPing(portID) {
const ping = {
addon_version: Services.appinfo.appBuildID,
locale: Services.locale.appLocaleAsLangTag,
user_prefs: this.userPreferences,
};
// If the ping is part of a user session, add session-related info
if (portID) {
const session = this.sessions.get(portID) || this.addSession(portID);
Object.assign(ping, { session_id: session.session_id });
if (session.page) {
Object.assign(ping, { page: session.page });
}
}
return ping;
}
/**
* createImpressionStats - Create a ping for an impression stats
*
* @param {string} portID The portID of the open session
* @param {ob} data The data object to be included in the ping.
* @return {obj} A telemetry ping
*/
createImpressionStats(portID, data) {
return Object.assign(this.createPing(portID), data, {
action: "activity_stream_impression_stats",
impression_id: this._impressionId,
client_id: "n/a",
session_id: "n/a",
});
}
createSpocsFillPing(data) {
return Object.assign(this.createPing(null), data, {
impression_id: this._impressionId,
session_id: "n/a",
});
}
createUserEvent(action) {
return Object.assign(
this.createPing(au.getPortIdOfSender(action)),
action.data,
{ action: "activity_stream_user_event" }
);
}
createUndesiredEvent(action) {
return Object.assign(
this.createPing(au.getPortIdOfSender(action)),
{ value: 0 }, // Default value
action.data,
{ action: "activity_stream_undesired_event" }
);
}
createPerformanceEvent(action) {
return Object.assign(this.createPing(), action.data, {
action: "activity_stream_performance_event",
});
}
createSessionEndEvent(session) {
return Object.assign(this.createPing(), {
session_id: session.session_id,
page: session.page,
session_duration: session.session_duration,
action: "activity_stream_session",
perf: session.perf,
});
}
/**
* Create a ping for AS router event. The client_id is set to "n/a" by default,
* different component can override this by its own telemetry collection policy.
*/
async createASRouterEvent(action) {
let event = {
...action.data,
addon_version: Services.appinfo.appBuildID,
locale: Services.locale.appLocaleAsLangTag,
};
if (event.event_context && typeof event.event_context === "object") {
event.event_context = JSON.stringify(event.event_context);
}
switch (event.action) {
case "cfr_user_event":
event = await this.applyCFRPolicy(event);
break;
case "snippets_user_event":
event = await this.applySnippetsPolicy(event);
break;
// Bug 1594125 added a new onboarding-like provider called `whats-new-panel`.
case "whats-new-panel_user_event":
case "onboarding_user_event":
event = await this.applyOnboardingPolicy(event);
break;
case "asrouter_undesired_event":
event = this.applyUndesiredEventPolicy(event);
break;
default:
event = { ping: event };
break;
}
return event;
}
/**
* Per Bug 1484035, CFR metrics comply with following policies:
* 1). In release, it collects impression_id, and treats bucket_id as message_id
* 2). In prerelease, it collects client_id and message_id
* 3). In shield experiments conducted in release, it collects client_id and message_id
*/
async applyCFRPolicy(ping) {
if (
UpdateUtils.getUpdateChannel(true) === "release" &&
!this.isInCFRCohort
) {
ping.message_id = "n/a";
ping.impression_id = this._impressionId;
} else {
ping.client_id = await this.telemetryClientId;
}
delete ping.action;
return { ping, pingType: "cfr" };
}
/**
* Per Bug 1485069, all the metrics for Snippets in AS router use client_id in
* all the release channels
*/
async applySnippetsPolicy(ping) {
ping.client_id = await this.telemetryClientId;
delete ping.action;
return { ping, pingType: "snippets" };
}
/**
* Per Bug 1482134, all the metrics for Onboarding in AS router use client_id in
* all the release channels
*/
async applyOnboardingPolicy(ping) {
ping.client_id = await this.telemetryClientId;
delete ping.action;
return { ping, pingType: "onboarding" };
}
applyUndesiredEventPolicy(ping) {
ping.impression_id = this._impressionId;
delete ping.action;
return { ping, pingType: "undesired-events" };
}
sendEvent(event_object) {
switch (event_object.action) {
case "activity_stream_user_event":
this.sendEventPing(event_object);
break;
}
}
async sendEventPing(ping) {
delete ping.action;
ping.client_id = await this.telemetryClientId;
if (ping.value && typeof ping.value === "object") {
ping.value = JSON.stringify(ping.value);
}
this.sendStructuredIngestionEvent(
ping,
STRUCTURED_INGESTION_NAMESPACE_AS,
"events",
1
);
}
sendUTEvent(event_object, eventFunction) {
if (this.telemetryEnabled && this.eventTelemetryEnabled) {
eventFunction(event_object);
}
}
/**
* Generates an endpoint for Structured Ingestion telemetry pipeline. Note that
* Structured Ingestion requires a different endpoint for each ping. See more
* details about endpoint schema at:
* https://github.com/mozilla/gcp-ingestion/blob/master/docs/edge.md#postput-request
*
* @param {String} namespace Namespace of the ping, such as "activity-stream" or "messaging-system".
* @param {String} pingType Type of the ping, such as "impression-stats".
* @param {String} version Endpoint version for this ping type.
*/
_generateStructuredIngestionEndpoint(namespace, pingType, version) {
const uuid = gUUIDGenerator.generateUUID().toString();
// Structured Ingestion does not support the UUID generated by gUUIDGenerator,
// because it contains leading and trailing braces. Need to trim them first.
const docID = uuid.slice(1, -1);
const extension = `${namespace}/${pingType}/${version}/${docID}`;
return `${this.structuredIngestionEndpointBase}/${extension}`;
}
sendStructuredIngestionEvent(eventObject, namespace, pingType, version) {
if (this.telemetryEnabled && this.structuredIngestionTelemetryEnabled) {
this.pingCentre.sendStructuredIngestionPing(
eventObject,
this._generateStructuredIngestionEndpoint(namespace, pingType, version),
{ filter: ACTIVITY_STREAM_ID }
);
}
}
handleImpressionStats(action) {
const payload = this.createImpressionStats(
au.getPortIdOfSender(action),
action.data
);
this.sendStructuredIngestionEvent(
payload,
STRUCTURED_INGESTION_NAMESPACE_AS,
"impression-stats",
"1"
);
}
handleUserEvent(action) {
let userEvent = this.createUserEvent(action);
this.sendEvent(userEvent);
this.sendUTEvent(userEvent, this.utEvents.sendUserEvent);
}
async handleASRouterUserEvent(action) {
const { ping, pingType } = await this.createASRouterEvent(action);
if (!pingType) {
Cu.reportError("Unknown ping type for ASRouter telemetry");
return;
}
this.sendStructuredIngestionEvent(
ping,
STRUCTURED_INGESTION_NAMESPACE_MS,
pingType,
"1"
);
}
handleUndesiredEvent(action) {
this.sendEvent(this.createUndesiredEvent(action));
}
handleTrailheadEnrollEvent(action) {
// Unlike `sendUTEvent`, we always send the event if AS's telemetry is enabled
// regardless of `this.eventTelemetryEnabled`.
if (this.telemetryEnabled) {
this.utEvents.sendTrailheadEnrollEvent(action.data);
}
}
async sendPageTakeoverData() {
if (this.telemetryEnabled) {
const value = {};
let newtabAffected = false;
let homeAffected = false;
// Check whether or not about:home and about:newtab are set to a custom URL.
// If so, classify them.
if (
Services.prefs.getBoolPref("browser.newtabpage.enabled") &&
aboutNewTabService.overridden &&
!aboutNewTabService.newTabURL.startsWith("moz-extension://")
) {
value.newtab_url_category = await this._classifySite(
aboutNewTabService.newTabURL
);
newtabAffected = true;
}
// Check if the newtab page setting is controlled by an extension.
await ExtensionSettingsStore.initialize();
const newtabExtensionInfo = ExtensionSettingsStore.getSetting(
"url_overrides",
"newTabURL"
);
if (newtabExtensionInfo && newtabExtensionInfo.id) {
value.newtab_extension_id = newtabExtensionInfo.id;
newtabAffected = true;
}
const homePageURL = HomePage.get();
if (
!["about:home", "about:blank"].includes(homePageURL) &&
!homePageURL.startsWith("moz-extension://")
) {
value.home_url_category = await this._classifySite(homePageURL);
homeAffected = true;
}
const homeExtensionInfo = ExtensionSettingsStore.getSetting(
"prefs",
"homepage_override"
);
if (homeExtensionInfo && homeExtensionInfo.id) {
value.home_extension_id = homeExtensionInfo.id;
homeAffected = true;
}
let page;
if (newtabAffected && homeAffected) {
page = "both";
} else if (newtabAffected) {
page = "about:newtab";
} else if (homeAffected) {
page = "about:home";
}
if (page) {
const event = Object.assign(this.createPing(), {
action: "activity_stream_user_event",
event: "PAGE_TAKEOVER_DATA",
value,
page,
session_id: "n/a",
});
this.sendEvent(event);
}
}
}
onAction(action) {
switch (action.type) {
case at.INIT:
this.init();
this.sendPageTakeoverData();
break;
case at.NEW_TAB_INIT:
this.handleNewTabInit(action);
break;
case at.NEW_TAB_UNLOAD:
this.endSession(au.getPortIdOfSender(action));
break;
case at.SAVE_SESSION_PERF_DATA:
this.saveSessionPerfData(au.getPortIdOfSender(action), action.data);
break;
case at.TELEMETRY_IMPRESSION_STATS:
this.handleImpressionStats(action);
break;
case at.DISCOVERY_STREAM_IMPRESSION_STATS:
this.handleDiscoveryStreamImpressionStats(
au.getPortIdOfSender(action),
action.data
);
break;
case at.DISCOVERY_STREAM_LOADED_CONTENT:
this.handleDiscoveryStreamLoadedContent(
au.getPortIdOfSender(action),
action.data
);
break;
case at.DISCOVERY_STREAM_SPOCS_FILL:
this.handleDiscoveryStreamSpocsFill(action.data);
break;
case at.TELEMETRY_UNDESIRED_EVENT:
this.handleUndesiredEvent(action);
break;
case at.TELEMETRY_USER_EVENT:
this.handleUserEvent(action);
break;
case at.AS_ROUTER_TELEMETRY_USER_EVENT:
this.handleASRouterUserEvent(action);
break;
case at.TELEMETRY_PERFORMANCE_EVENT:
this.sendEvent(this.createPerformanceEvent(action));
break;
case at.TRAILHEAD_ENROLL_EVENT:
this.handleTrailheadEnrollEvent(action);
break;
case at.UNINIT:
this.uninit();
break;
}
}
/**
* Handle impression stats actions from Discovery Stream. The data will be
* stored into the session.impressionSets object for the given port, so that
* it is sent to the server when the session ends.
*
* @note session.impressionSets will be keyed on `source` of the `data`,
* all the data will be appended to an array for the same source.
*
* @param {String} port The session port with which this is associated
* @param {Object} data The impression data structured as {source: "SOURCE", tiles: [{id: 123}]}
*
*/
handleDiscoveryStreamImpressionStats(port, data) {
let session = this.sessions.get(port);
if (!session) {
throw new Error("Session does not exist.");
}
const impressionSets = session.impressionSets || {};
const impressions = impressionSets[data.source] || [];
// The payload might contain other properties, we need `id`, `pos` and potentially `shim` here.
data.tiles.forEach(tile =>
impressions.push({
id: tile.id,
pos: tile.pos,
...(tile.shim ? { shim: tile.shim } : {}),
})
);
impressionSets[data.source] = impressions;
session.impressionSets = impressionSets;
}
/**
* Handle loaded content actions from Discovery Stream. The data will be
* stored into the session.loadedContentSets object for the given port, so that
* it is sent to the server when the session ends.
*
* @note session.loadedContentSets will be keyed on `source` of the `data`,
* all the data will be appended to an array for the same source.
*
* @param {String} port The session port with which this is associated
* @param {Object} data The loaded content structured as {source: "SOURCE", tiles: [{id: 123}]}
*
*/
handleDiscoveryStreamLoadedContent(port, data) {
let session = this.sessions.get(port);
if (!session) {
throw new Error("Session does not exist.");
}
const loadedContentSets = session.loadedContentSets || {};
const loadedContents = loadedContentSets[data.source] || [];
// The payload might contain other properties, we need `id` and `pos` here.
data.tiles.forEach(tile =>
loadedContents.push({ id: tile.id, pos: tile.pos })
);
loadedContentSets[data.source] = loadedContents;
session.loadedContentSets = loadedContentSets;
}
/**
* Handl SPOCS Fill actions from Discovery Stream.
*
* @param {Object} data
* The SPOCS Fill event structured as:
* {
* spoc_fills: [
* {
* id: 123,
* displayed: 0,
* reason: "frequency_cap",
* full_recalc: 1
* },
* {
* id: 124,
* displayed: 1,
* reason: "n/a",
* full_recalc: 1
* }
* ]
* }
*/
handleDiscoveryStreamSpocsFill(data) {
const payload = this.createSpocsFillPing(data);
this.sendStructuredIngestionEvent(
payload,
STRUCTURED_INGESTION_NAMESPACE_AS,
"spoc-fills",
"1"
);
}
/**
* Take all enumerable members of the data object and merge them into
* the session.perf object for the given port, so that it is sent to the
* server when the session ends. All members of the data object should
* be valid values of the perf object, as defined in pings.js and the
* data*.md documentation.
*
* @note Any existing keys with the same names already in the
* session perf object will be overwritten by values passed in here.
*
* @param {String} port The session with which this is associated
* @param {Object} data The perf data to be
*/
saveSessionPerfData(port, data) {
// XXX should use try/catch and send a bad state indicator if this
// get blows up.
let session = this.sessions.get(port);
// XXX Partial workaround for #3118; avoids the worst incorrect associations
// of times with browsers, by associating the load trigger with the
// visibility event as the user is most likely associating the trigger to
// the tab just shown. This helps avoid associating with a preloaded
// browser as those don't get the event until shown. Better fix for more
// cases forthcoming.
//
// XXX the about:home check (and the corresponding test) should go away
// once the load_trigger stuff in addSession is refactored into
// setLoadTriggerInfo.
//
if (data.visibility_event_rcvd_ts && session.page !== "about:home") {
this.setLoadTriggerInfo(port);
}
let timestamp = data.topsites_first_painted_ts;
if (
timestamp &&
session.page === "about:home" &&
!HomePage.overridden &&
Services.prefs.getIntPref("browser.startup.page") === 1
) {
aboutNewTabService.maybeRecordTopsitesPainted(timestamp);
}
Object.assign(session.perf, data);
}
uninit() {
try {
Services.obs.removeObserver(
this.browserOpenNewtabStart,
"browser-open-newtab-start"
);
Services.obs.removeObserver(
this._addWindowListeners,
DOMWINDOW_OPENED_TOPIC
);
} catch (e) {
// Operation can fail when uninit is called before
// init has finished setting up the observer
}
// Only uninit if the getter has initialized it
if (Object.prototype.hasOwnProperty.call(this, "pingCentre")) {
this.pingCentre.uninit();
}
if (Object.prototype.hasOwnProperty.call(this, "utEvents")) {
this.utEvents.uninit();
}
// TODO: Send any unfinished sessions
}
};
const EXPORTED_SYMBOLS = [
"TelemetryFeed",
"USER_PREFS_ENCODING",
"PREF_IMPRESSION_ID",
"TELEMETRY_PREF",
"EVENTS_TELEMETRY_PREF",
"STRUCTURED_INGESTION_TELEMETRY_PREF",
"STRUCTURED_INGESTION_ENDPOINT_PREF",
];
================================================
FILE: lib/TippyTopProvider.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/. */
const { XPCOMUtils } = ChromeUtils.import(
"resource://gre/modules/XPCOMUtils.jsm"
);
XPCOMUtils.defineLazyGlobalGetters(this, ["fetch", "URL"]);
const TIPPYTOP_JSON_PATH =
"resource://activity-stream/data/content/tippytop/top_sites.json";
const TIPPYTOP_URL_PREFIX =
"resource://activity-stream/data/content/tippytop/images/";
function getDomain(url) {
let domain;
try {
domain = new URL(url).hostname;
} catch (ex) {}
if (domain && domain.startsWith("www.")) {
domain = domain.slice(4);
}
return domain;
}
this.TippyTopProvider = class TippyTopProvider {
constructor() {
this._sitesByDomain = new Map();
this.initialized = false;
}
async init() {
// Load the Tippy Top sites from the json manifest.
try {
for (const site of await (await fetch(TIPPYTOP_JSON_PATH, {
credentials: "omit",
})).json()) {
// The tippy top manifest can have a url property (string) or a
// urls property (array of strings)
for (const url of site.url ? [site.url] : site.urls || []) {
this._sitesByDomain.set(getDomain(url), site);
}
}
this.initialized = true;
} catch (error) {
Cu.reportError("Failed to load tippy top manifest.");
}
}
processSite(site) {
const tippyTop = this._sitesByDomain.get(getDomain(site.url));
if (tippyTop) {
site.tippyTopIcon = TIPPYTOP_URL_PREFIX + tippyTop.image_url;
site.backgroundColor = tippyTop.background_color;
}
return site;
}
};
const EXPORTED_SYMBOLS = ["TippyTopProvider", "getDomain"];
================================================
FILE: lib/Tokenize.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";
// Unicode specifies certain mnemonics for code pages and character classes.
// They call them "character properties" https://en.wikipedia.org/wiki/Unicode_character_property .
// These mnemonics are have been adopted by many regular expression libraries,
// however the standard Javascript regexp system doesn't support unicode
// character properties, so we have to define these ourself.
//
// Each of these sections contains the characters values / ranges for specific
// character property: Whitespace, Symbol (S), Punctuation (P), Number (N),
// Mark (M), and Letter (L).
const UNICODE_SPACE =
"\x20\xA0\u1680\u2000-\u200A\u2028\u2029\u202F\u205F\u3000";
const UNICODE_SYMBOL =
"\\x24\\x2B\x3C-\x3E\\x5E\x60\\x7C\x7E\xA2-\xA6\xA8\xA9\xAC\xAE-\xB1\xB4\xB8\xD7\xF7\u02C2-\u02C5\u02D2-\u02DF\u02E5-\u02EB\u02ED\u02EF-\u02FF\u0375\u0384\u0385\u03F6\u0482\u058D-\u058F\u0606-\u0608\u060B\u060E\u060F\u06DE\u06E9\u06FD\u06FE\u07F6\u09F2\u09F3\u09FA\u09FB\u0AF1\u0B70\u0BF3-\u0BFA\u0C7F\u0D4F\u0D79\u0E3F\u0F01-\u0F03\u0F13\u0F15-\u0F17\u0F1A-\u0F1F\u0F34\u0F36\u0F38\u0FBE-\u0FC5\u0FC7-\u0FCC\u0FCE\u0FCF\u0FD5-\u0FD8\u109E\u109F\u1390-\u1399\u17DB\u1940\u19DE-\u19FF\u1B61-\u1B6A\u1B74-\u1B7C\u1FBD\u1FBF-\u1FC1\u1FCD-\u1FCF\u1FDD-\u1FDF\u1FED-\u1FEF\u1FFD\u1FFE\u2044\u2052\u207A-\u207C\u208A-\u208C\u20A0-\u20BE\u2100\u2101\u2103-\u2106\u2108\u2109\u2114\u2116-\u2118\u211E-\u2123\u2125\u2127\u2129\u212E\u213A\u213B\u2140-\u2144\u214A-\u214D\u214F\u218A\u218B\u2190-\u2307\u230C-\u2328\u232B-\u23FE\u2400-\u2426\u2440-\u244A\u249C-\u24E9\u2500-\u2767\u2794-\u27C4\u27C7-\u27E5\u27F0-\u2982\u2999-\u29D7\u29DC-\u29FB\u29FE-\u2B73\u2B76-\u2B95\u2B98-\u2BB9\u2BBD-\u2BC8\u2BCA-\u2BD1\u2BEC-\u2BEF\u2CE5-\u2CEA\u2E80-\u2E99\u2E9B-\u2EF3\u2F00-\u2FD5\u2FF0-\u2FFB\u3004\u3012\u3013\u3020\u3036\u3037\u303E\u303F\u309B\u309C\u3190\u3191\u3196-\u319F\u31C0-\u31E3\u3200-\u321E\u322A-\u3247\u3250\u3260-\u327F\u328A-\u32B0\u32C0-\u32FE\u3300-\u33FF\u4DC0-\u4DFF\uA490-\uA4C6\uA700-\uA716\uA720\uA721\uA789\uA78A\uA828-\uA82B\uA836-\uA839\uAA77-\uAA79\uAB5B\uFB29\uFBB2-\uFBC1\uFDFC\uFDFD\uFE62\uFE64-\uFE66\uFE69\uFF04\uFF0B\uFF1C-\uFF1E\uFF3E\uFF40\uFF5C\uFF5E\uFFE0-\uFFE6\uFFE8-\uFFEE\uFFFC\uFFFD";
const UNICODE_PUNCT =
"\x21-\x23\x25-\\x2A\x2C-\x2F\x3A\x3B\\x3F\x40\\x5B-\\x5D\x5F\\x7B\x7D\xA1\xA7\xAB\xB6\xB7\xBB\xBF\u037E\u0387\u055A-\u055F\u0589\u058A\u05BE\u05C0\u05C3\u05C6\u05F3\u05F4\u0609\u060A\u060C\u060D\u061B\u061E\u061F\u066A-\u066D\u06D4\u0700-\u070D\u07F7-\u07F9\u0830-\u083E\u085E\u0964\u0965\u0970\u0AF0\u0DF4\u0E4F\u0E5A\u0E5B\u0F04-\u0F12\u0F14\u0F3A-\u0F3D\u0F85\u0FD0-\u0FD4\u0FD9\u0FDA\u104A-\u104F\u10FB\u1360-\u1368\u1400\u166D\u166E\u169B\u169C\u16EB-\u16ED\u1735\u1736\u17D4-\u17D6\u17D8-\u17DA\u1800-\u180A\u1944\u1945\u1A1E\u1A1F\u1AA0-\u1AA6\u1AA8-\u1AAD\u1B5A-\u1B60\u1BFC-\u1BFF\u1C3B-\u1C3F\u1C7E\u1C7F\u1CC0-\u1CC7\u1CD3\u2010-\u2027\u2030-\u2043\u2045-\u2051\u2053-\u205E\u207D\u207E\u208D\u208E\u2308-\u230B\u2329\u232A\u2768-\u2775\u27C5\u27C6\u27E6-\u27EF\u2983-\u2998\u29D8-\u29DB\u29FC\u29FD\u2CF9-\u2CFC\u2CFE\u2CFF\u2D70\u2E00-\u2E2E\u2E30-\u2E44\u3001-\u3003\u3008-\u3011\u3014-\u301F\u3030\u303D\u30A0\u30FB\uA4FE\uA4FF\uA60D-\uA60F\uA673\uA67E\uA6F2-\uA6F7\uA874-\uA877\uA8CE\uA8CF\uA8F8-\uA8FA\uA8FC\uA92E\uA92F\uA95F\uA9C1-\uA9CD\uA9DE\uA9DF\uAA5C-\uAA5F\uAADE\uAADF\uAAF0\uAAF1\uABEB\uFD3E\uFD3F\uFE10-\uFE19\uFE30-\uFE52\uFE54-\uFE61\uFE63\uFE68\uFE6A\uFE6B\uFF01-\uFF03\uFF05-\uFF0A\uFF0C-\uFF0F\uFF1A\uFF1B\uFF1F\uFF20\uFF3B-\uFF3D\uFF3F\uFF5B\uFF5D\uFF5F-\uFF65";
const UNICODE_NUMBER =
"0-9\xB2\xB3\xB9\xBC-\xBE\u0660-\u0669\u06F0-\u06F9\u07C0-\u07C9\u0966-\u096F\u09E6-\u09EF\u09F4-\u09F9\u0A66-\u0A6F\u0AE6-\u0AEF\u0B66-\u0B6F\u0B72-\u0B77\u0BE6-\u0BF2\u0C66-\u0C6F\u0C78-\u0C7E\u0CE6-\u0CEF\u0D58-\u0D5E\u0D66-\u0D78\u0DE6-\u0DEF\u0E50-\u0E59\u0ED0-\u0ED9\u0F20-\u0F33\u1040-\u1049\u1090-\u1099\u1369-\u137C\u16EE-\u16F0\u17E0-\u17E9\u17F0-\u17F9\u1810-\u1819\u1946-\u194F\u19D0-\u19DA\u1A80-\u1A89\u1A90-\u1A99\u1B50-\u1B59\u1BB0-\u1BB9\u1C40-\u1C49\u1C50-\u1C59\u2070\u2074-\u2079\u2080-\u2089\u2150-\u2182\u2185-\u2189\u2460-\u249B\u24EA-\u24FF\u2776-\u2793\u2CFD\u3007\u3021-\u3029\u3038-\u303A\u3192-\u3195\u3220-\u3229\u3248-\u324F\u3251-\u325F\u3280-\u3289\u32B1-\u32BF\uA620-\uA629\uA6E6-\uA6EF\uA830-\uA835\uA8D0-\uA8D9\uA900-\uA909\uA9D0-\uA9D9\uA9F0-\uA9F9\uAA50-\uAA59\uABF0-\uABF9\uFF10-\uFF19";
const UNICODE_MARK =
"\u0300-\u036F\u0483-\u0489\u0591-\u05BD\u05BF\u05C1\u05C2\u05C4\u05C5\u05C7\u0610-\u061A\u064B-\u065F\u0670\u06D6-\u06DC\u06DF-\u06E4\u06E7\u06E8\u06EA-\u06ED\u0711\u0730-\u074A\u07A6-\u07B0\u07EB-\u07F3\u0816-\u0819\u081B-\u0823\u0825-\u0827\u0829-\u082D\u0859-\u085B\u08D4-\u08E1\u08E3-\u0903\u093A-\u093C\u093E-\u094F\u0951-\u0957\u0962\u0963\u0981-\u0983\u09BC\u09BE-\u09C4\u09C7\u09C8\u09CB-\u09CD\u09D7\u09E2\u09E3\u0A01-\u0A03\u0A3C\u0A3E-\u0A42\u0A47\u0A48\u0A4B-\u0A4D\u0A51\u0A70\u0A71\u0A75\u0A81-\u0A83\u0ABC\u0ABE-\u0AC5\u0AC7-\u0AC9\u0ACB-\u0ACD\u0AE2\u0AE3\u0B01-\u0B03\u0B3C\u0B3E-\u0B44\u0B47\u0B48\u0B4B-\u0B4D\u0B56\u0B57\u0B62\u0B63\u0B82\u0BBE-\u0BC2\u0BC6-\u0BC8\u0BCA-\u0BCD\u0BD7\u0C00-\u0C03\u0C3E-\u0C44\u0C46-\u0C48\u0C4A-\u0C4D\u0C55\u0C56\u0C62\u0C63\u0C81-\u0C83\u0CBC\u0CBE-\u0CC4\u0CC6-\u0CC8\u0CCA-\u0CCD\u0CD5\u0CD6\u0CE2\u0CE3\u0D01-\u0D03\u0D3E-\u0D44\u0D46-\u0D48\u0D4A-\u0D4D\u0D57\u0D62\u0D63\u0D82\u0D83\u0DCA\u0DCF-\u0DD4\u0DD6\u0DD8-\u0DDF\u0DF2\u0DF3\u0E31\u0E34-\u0E3A\u0E47-\u0E4E\u0EB1\u0EB4-\u0EB9\u0EBB\u0EBC\u0EC8-\u0ECD\u0F18\u0F19\u0F35\u0F37\u0F39\u0F3E\u0F3F\u0F71-\u0F84\u0F86\u0F87\u0F8D-\u0F97\u0F99-\u0FBC\u0FC6\u102B-\u103E\u1056-\u1059\u105E-\u1060\u1062-\u1064\u1067-\u106D\u1071-\u1074\u1082-\u108D\u108F\u109A-\u109D\u135D-\u135F\u1712-\u1714\u1732-\u1734\u1752\u1753\u1772\u1773\u17B4-\u17D3\u17DD\u180B-\u180D\u1885\u1886\u18A9\u1920-\u192B\u1930-\u193B\u1A17-\u1A1B\u1A55-\u1A5E\u1A60-\u1A7C\u1A7F\u1AB0-\u1ABE\u1B00-\u1B04\u1B34-\u1B44\u1B6B-\u1B73\u1B80-\u1B82\u1BA1-\u1BAD\u1BE6-\u1BF3\u1C24-\u1C37\u1CD0-\u1CD2\u1CD4-\u1CE8\u1CED\u1CF2-\u1CF4\u1CF8\u1CF9\u1DC0-\u1DF5\u1DFB-\u1DFF\u20D0-\u20F0\u2CEF-\u2CF1\u2D7F\u2DE0-\u2DFF\u302A-\u302F\u3099\u309A\uA66F-\uA672\uA674-\uA67D\uA69E\uA69F\uA6F0\uA6F1\uA802\uA806\uA80B\uA823-\uA827\uA880\uA881\uA8B4-\uA8C5\uA8E0-\uA8F1\uA926-\uA92D\uA947-\uA953\uA980-\uA983\uA9B3-\uA9C0\uA9E5\uAA29-\uAA36\uAA43\uAA4C\uAA4D\uAA7B-\uAA7D\uAAB0\uAAB2-\uAAB4\uAAB7\uAAB8\uAABE\uAABF\uAAC1\uAAEB-\uAAEF\uAAF5\uAAF6\uABE3-\uABEA\uABEC\uABED\uFB1E\uFE00-\uFE0F\uFE20-\uFE2F";
const UNICODE_LETTER =
"A-Za-z\xAA\xB5\xBA\xC0-\xD6\xD8-\xF6\xF8-\u02C1\u02C6-\u02D1\u02E0-\u02E4\u02EC\u02EE\u0370-\u0374\u0376\u0377\u037A-\u037D\u037F\u0386\u0388-\u038A\u038C\u038E-\u03A1\u03A3-\u03F5\u03F7-\u0481\u048A-\u052F\u0531-\u0556\u0559\u0561-\u0587\u05D0-\u05EA\u05F0-\u05F2\u0620-\u064A\u066E\u066F\u0671-\u06D3\u06D5\u06E5\u06E6\u06EE\u06EF\u06FA-\u06FC\u06FF\u0710\u0712-\u072F\u074D-\u07A5\u07B1\u07CA-\u07EA\u07F4\u07F5\u07FA\u0800-\u0815\u081A\u0824\u0828\u0840-\u0858\u08A0-\u08B4\u08B6-\u08BD\u0904-\u0939\u093D\u0950\u0958-\u0961\u0971-\u0980\u0985-\u098C\u098F\u0990\u0993-\u09A8\u09AA-\u09B0\u09B2\u09B6-\u09B9\u09BD\u09CE\u09DC\u09DD\u09DF-\u09E1\u09F0\u09F1\u0A05-\u0A0A\u0A0F\u0A10\u0A13-\u0A28\u0A2A-\u0A30\u0A32\u0A33\u0A35\u0A36\u0A38\u0A39\u0A59-\u0A5C\u0A5E\u0A72-\u0A74\u0A85-\u0A8D\u0A8F-\u0A91\u0A93-\u0AA8\u0AAA-\u0AB0\u0AB2\u0AB3\u0AB5-\u0AB9\u0ABD\u0AD0\u0AE0\u0AE1\u0AF9\u0B05-\u0B0C\u0B0F\u0B10\u0B13-\u0B28\u0B2A-\u0B30\u0B32\u0B33\u0B35-\u0B39\u0B3D\u0B5C\u0B5D\u0B5F-\u0B61\u0B71\u0B83\u0B85-\u0B8A\u0B8E-\u0B90\u0B92-\u0B95\u0B99\u0B9A\u0B9C\u0B9E\u0B9F\u0BA3\u0BA4\u0BA8-\u0BAA\u0BAE-\u0BB9\u0BD0\u0C05-\u0C0C\u0C0E-\u0C10\u0C12-\u0C28\u0C2A-\u0C39\u0C3D\u0C58-\u0C5A\u0C60\u0C61\u0C80\u0C85-\u0C8C\u0C8E-\u0C90\u0C92-\u0CA8\u0CAA-\u0CB3\u0CB5-\u0CB9\u0CBD\u0CDE\u0CE0\u0CE1\u0CF1\u0CF2\u0D05-\u0D0C\u0D0E-\u0D10\u0D12-\u0D3A\u0D3D\u0D4E\u0D54-\u0D56\u0D5F-\u0D61\u0D7A-\u0D7F\u0D85-\u0D96\u0D9A-\u0DB1\u0DB3-\u0DBB\u0DBD\u0DC0-\u0DC6\u0E01-\u0E30\u0E32\u0E33\u0E40-\u0E46\u0E81\u0E82\u0E84\u0E87\u0E88\u0E8A\u0E8D\u0E94-\u0E97\u0E99-\u0E9F\u0EA1-\u0EA3\u0EA5\u0EA7\u0EAA\u0EAB\u0EAD-\u0EB0\u0EB2\u0EB3\u0EBD\u0EC0-\u0EC4\u0EC6\u0EDC-\u0EDF\u0F00\u0F40-\u0F47\u0F49-\u0F6C\u0F88-\u0F8C\u1000-\u102A\u103F\u1050-\u1055\u105A-\u105D\u1061\u1065\u1066\u106E-\u1070\u1075-\u1081\u108E\u10A0-\u10C5\u10C7\u10CD\u10D0-\u10FA\u10FC-\u1248\u124A-\u124D\u1250-\u1256\u1258\u125A-\u125D\u1260-\u1288\u128A-\u128D\u1290-\u12B0\u12B2-\u12B5\u12B8-\u12BE\u12C0\u12C2-\u12C5\u12C8-\u12D6\u12D8-\u1310\u1312-\u1315\u1318-\u135A\u1380-\u138F\u13A0-\u13F5\u13F8-\u13FD\u1401-\u166C\u166F-\u167F\u1681-\u169A\u16A0-\u16EA\u16F1-\u16F8\u1700-\u170C\u170E-\u1711\u1720-\u1731\u1740-\u1751\u1760-\u176C\u176E-\u1770\u1780-\u17B3\u17D7\u17DC\u1820-\u1877\u1880-\u1884\u1887-\u18A8\u18AA\u18B0-\u18F5\u1900-\u191E\u1950-\u196D\u1970-\u1974\u1980-\u19AB\u19B0-\u19C9\u1A00-\u1A16\u1A20-\u1A54\u1AA7\u1B05-\u1B33\u1B45-\u1B4B\u1B83-\u1BA0\u1BAE\u1BAF\u1BBA-\u1BE5\u1C00-\u1C23\u1C4D-\u1C4F\u1C5A-\u1C7D\u1C80-\u1C88\u1CE9-\u1CEC\u1CEE-\u1CF1\u1CF5\u1CF6\u1D00-\u1DBF\u1E00-\u1F15\u1F18-\u1F1D\u1F20-\u1F45\u1F48-\u1F4D\u1F50-\u1F57\u1F59\u1F5B\u1F5D\u1F5F-\u1F7D\u1F80-\u1FB4\u1FB6-\u1FBC\u1FBE\u1FC2-\u1FC4\u1FC6-\u1FCC\u1FD0-\u1FD3\u1FD6-\u1FDB\u1FE0-\u1FEC\u1FF2-\u1FF4\u1FF6-\u1FFC\u2071\u207F\u2090-\u209C\u2102\u2107\u210A-\u2113\u2115\u2119-\u211D\u2124\u2126\u2128\u212A-\u212D\u212F-\u2139\u213C-\u213F\u2145-\u2149\u214E\u2183\u2184\u2C00-\u2C2E\u2C30-\u2C5E\u2C60-\u2CE4\u2CEB-\u2CEE\u2CF2\u2CF3\u2D00-\u2D25\u2D27\u2D2D\u2D30-\u2D67\u2D6F\u2D80-\u2D96\u2DA0-\u2DA6\u2DA8-\u2DAE\u2DB0-\u2DB6\u2DB8-\u2DBE\u2DC0-\u2DC6\u2DC8-\u2DCE\u2DD0-\u2DD6\u2DD8-\u2DDE\u2E2F\u3005\u3006\u3031-\u3035\u303B\u303C\u3041-\u3096\u309D-\u309F\u30A1-\u30FA\u30FC-\u30FF\u3105-\u312D\u3131-\u318E\u31A0-\u31BA\u31F0-\u31FF\u3400-\u4DB5\u4E00-\u9FD5\uA000-\uA48C\uA4D0-\uA4FD\uA500-\uA60C\uA610-\uA61F\uA62A\uA62B\uA640-\uA66E\uA67F-\uA69D\uA6A0-\uA6E5\uA717-\uA71F\uA722-\uA788\uA78B-\uA7AE\uA7B0-\uA7B7\uA7F7-\uA801\uA803-\uA805\uA807-\uA80A\uA80C-\uA822\uA840-\uA873\uA882-\uA8B3\uA8F2-\uA8F7\uA8FB\uA8FD\uA90A-\uA925\uA930-\uA946\uA960-\uA97C\uA984-\uA9B2\uA9CF\uA9E0-\uA9E4\uA9E6-\uA9EF\uA9FA-\uA9FE\uAA00-\uAA28\uAA40-\uAA42\uAA44-\uAA4B\uAA60-\uAA76\uAA7A\uAA7E-\uAAAF\uAAB1\uAAB5\uAAB6\uAAB9-\uAABD\uAAC0\uAAC2\uAADB-\uAADD\uAAE0-\uAAEA\uAAF2-\uAAF4\uAB01-\uAB06\uAB09-\uAB0E\uAB11-\uAB16\uAB20-\uAB26\uAB28-\uAB2E\uAB30-\uAB5A\uAB5C-\uAB65\uAB70-\uABE2\uAC00-\uD7A3\uD7B0-\uD7C6\uD7CB-\uD7FB\uF900-\uFA6D\uFA70-\uFAD9\uFB00-\uFB06\uFB13-\uFB17\uFB1D\uFB1F-\uFB28\uFB2A-\uFB36\uFB38-\uFB3C\uFB3E\uFB40\uFB41\uFB43\uFB44\uFB46-\uFBB1\uFBD3-\uFD3D\uFD50-\uFD8F\uFD92-\uFDC7\uFDF0-\uFDFB\uFE70-\uFE74\uFE76-\uFEFC\uFF21-\uFF3A\uFF41-\uFF5A\uFF66-\uFFBE\uFFC2-\uFFC7\uFFCA-\uFFCF\uFFD2-\uFFD7\uFFDA-\uFFDC";
const REGEXP_SPLITS = new RegExp(
`[${UNICODE_SPACE}${UNICODE_SYMBOL}${UNICODE_PUNCT}]+`
);
// Match all token characters, so okay for regex to split multiple code points
// eslint-disable-next-line no-misleading-character-class
const REGEXP_ALPHANUMS = new RegExp(
`^[${UNICODE_NUMBER}${UNICODE_MARK}${UNICODE_LETTER}]+$`
);
/**
* Downcases the text, and splits it into consecutive alphanumeric characters.
* This is locale aware, and so will not strip accents. This uses "word
* breaks", and os is not appropriate for languages without them
* (e.g. Chinese).
*/
function tokenize(text) {
return text
.toLocaleLowerCase()
.split(REGEXP_SPLITS)
.filter(tok => tok.match(REGEXP_ALPHANUMS));
}
/**
* Converts a sequence of tokens into an L2 normed TF-IDF. Any terms that are
* not preindexed (i.e. does have a computed inverse document frequency) will
* be dropped.
*/
function toksToTfIdfVector(tokens, vocab_idfs) {
let tfidfs = {};
// calcualte the term frequencies
for (let tok of tokens) {
if (!(tok in vocab_idfs)) {
continue;
}
if (!(tok in tfidfs)) {
tfidfs[tok] = [vocab_idfs[tok][0], 1];
} else {
tfidfs[tok][1]++;
}
}
// now multiply by the log inverse document frequencies, then take
// the L2 norm of this.
let l2Norm = 0.0;
Object.keys(tfidfs).forEach(tok => {
tfidfs[tok][1] *= vocab_idfs[tok][1];
l2Norm += tfidfs[tok][1] * tfidfs[tok][1];
});
l2Norm = Math.sqrt(l2Norm);
Object.keys(tfidfs).forEach(tok => {
tfidfs[tok][1] /= l2Norm;
});
return tfidfs;
}
const EXPORTED_SYMBOLS = ["tokenize", "toksToTfIdfVector"];
================================================
FILE: lib/ToolbarBadgeHub.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 { XPCOMUtils } = ChromeUtils.import(
"resource://gre/modules/XPCOMUtils.jsm"
);
XPCOMUtils.defineLazyModuleGetters(this, {
EveryWindow: "resource:///modules/EveryWindow.jsm",
ToolbarPanelHub: "resource://activity-stream/lib/ToolbarPanelHub.jsm",
Services: "resource://gre/modules/Services.jsm",
PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.jsm",
});
const {
setInterval,
clearInterval,
requestIdleCallback,
setTimeout,
clearTimeout,
} = ChromeUtils.import("resource://gre/modules/Timer.jsm");
// Frequency at which to check for new messages
const SYSTEM_TICK_INTERVAL = 5 * 60 * 1000;
let notificationsByWindow = new WeakMap();
class _ToolbarBadgeHub {
constructor() {
this.id = "toolbar-badge-hub";
this.state = null;
this.prefs = {
WHATSNEW_TOOLBAR_PANEL: "browser.messaging-system.whatsNewPanel.enabled",
HOMEPAGE_OVERRIDE_PREF: "browser.startup.homepage_override.once",
};
this.removeAllNotifications = this.removeAllNotifications.bind(this);
this.removeToolbarNotification = this.removeToolbarNotification.bind(this);
this.addToolbarNotification = this.addToolbarNotification.bind(this);
this.registerBadgeToAllWindows = this.registerBadgeToAllWindows.bind(this);
this._sendTelemetry = this._sendTelemetry.bind(this);
this.sendUserEventTelemetry = this.sendUserEventTelemetry.bind(this);
this.checkHomepageOverridePref = this.checkHomepageOverridePref.bind(this);
this._handleMessageRequest = null;
this._addImpression = null;
this._blockMessageById = null;
this._dispatch = null;
}
async init(
waitForInitialized,
{
handleMessageRequest,
addImpression,
blockMessageById,
unblockMessageById,
dispatch,
}
) {
this._handleMessageRequest = handleMessageRequest;
this._blockMessageById = blockMessageById;
this._unblockMessageById = unblockMessageById;
this._addImpression = addImpression;
this._dispatch = dispatch;
// Need to wait for ASRouter to initialize before trying to fetch messages
await waitForInitialized;
this.messageRequest({
triggerId: "toolbarBadgeUpdate",
template: "toolbar_badge",
});
// Listen for pref changes that could trigger new badges
Services.prefs.addObserver(this.prefs.WHATSNEW_TOOLBAR_PANEL, this);
const _intervalId = setInterval(
() => this.checkHomepageOverridePref(),
SYSTEM_TICK_INTERVAL
);
this.state = { _intervalId };
}
/**
* Pref is set via Remote Settings message. We want to continously
* monitor new messages that come in to ensure the one with the
* highest priority is set.
*/
checkHomepageOverridePref() {
const prefValue = Services.prefs.getStringPref(
this.prefs.HOMEPAGE_OVERRIDE_PREF,
""
);
if (prefValue) {
// If the pref is set it means the user has not yet seen this message.
// We clear the pref value and re-evaluate all possible messages to ensure
// we don't have a higher priority message to show.
Services.prefs.clearUserPref(this.prefs.HOMEPAGE_OVERRIDE_PREF);
let message_id;
try {
message_id = JSON.parse(prefValue).message_id;
} catch (e) {}
if (message_id) {
this._unblockMessageById(message_id);
}
}
this.messageRequest({
triggerId: "momentsUpdate",
template: "update_action",
});
}
observe(aSubject, aTopic, aPrefName) {
switch (aPrefName) {
case this.prefs.WHATSNEW_TOOLBAR_PANEL:
this.messageRequest({
triggerId: "toolbarBadgeUpdate",
template: "toolbar_badge",
});
break;
}
}
maybeInsertFTL(win) {
win.MozXULElement.insertFTLIfNeeded("browser/newtab/asrouter.ftl");
}
executeAction({ id, data, message_id }) {
switch (id) {
case "show-whatsnew-button":
ToolbarPanelHub.enableToolbarButton();
ToolbarPanelHub.enableAppmenuButton();
break;
case "moments-wnp":
const { url, expireDelta } = data;
let { expire } = data;
if (!expire) {
expire = this.getExpirationDate(expireDelta);
}
Services.prefs.setStringPref(
this.prefs.HOMEPAGE_OVERRIDE_PREF,
JSON.stringify({ message_id, url, expire })
);
// Block immediately after taking the action
this._blockMessageById(message_id);
break;
}
}
/**
* If we don't have `expire` defined with the message it could be because
* it depends on user dependent parameters. Since the message matched
* targeting we calculate `expire` based on the current timestamp and the
* `expireDelta` which defines for how long it should be available.
* @param expireDelta {number} - Offset in milliseconds from the current date
*/
getExpirationDate(expireDelta) {
return Date.now() + expireDelta;
}
_clearBadgeTimeout() {
if (this.state.showBadgeTimeoutId) {
clearTimeout(this.state.showBadgeTimeoutId);
}
}
removeAllNotifications(event) {
if (event) {
// ignore right clicks
if (
(event.type === "mousedown" || event.type === "click") &&
event.button !== 0
) {
return;
}
// ignore keyboard access that is not one of the usual accessor keys
if (
event.type === "keypress" &&
event.key !== " " &&
event.key !== "Enter"
) {
return;
}
event.target.removeEventListener(
"mousedown",
this.removeAllNotifications
);
event.target.removeEventListener("keypress", this.removeAllNotifications);
// If we have an event it means the user interacted with the badge
// we should send telemetry
if (this.state.notification) {
this.sendUserEventTelemetry("CLICK", this.state.notification);
}
}
// Will call uninit on every window
EveryWindow.unregisterCallback(this.id);
if (this.state.notification) {
this._blockMessageById(this.state.notification.id);
}
this._clearBadgeTimeout();
this.state = {};
}
removeToolbarNotification(toolbarButton) {
// Remove it from the element that displays the badge
toolbarButton
.querySelector(".toolbarbutton-badge")
.classList.remove("feature-callout");
toolbarButton.removeAttribute("badged");
// Remove id used for for aria-label badge description
const notificationDescription = toolbarButton.querySelector(
"#toolbarbutton-notification-description"
);
if (notificationDescription) {
notificationDescription.remove();
toolbarButton.removeAttribute("aria-labelledby");
toolbarButton.removeAttribute("aria-describedby");
}
}
addToolbarNotification(win, message) {
const document = win.browser.ownerDocument;
if (message.content.action) {
this.executeAction({ ...message.content.action, message_id: message.id });
}
let toolbarbutton = document.getElementById(message.content.target);
if (toolbarbutton) {
const badge = toolbarbutton.querySelector(".toolbarbutton-badge");
badge.classList.add("feature-callout");
toolbarbutton.setAttribute("badged", true);
// If we have additional aria-label information for the notification
// we add this content to the hidden `toolbarbutton-text` node.
// We then use `aria-labelledby` to link this description to the button
// that received the notification badge.
if (message.content.badgeDescription) {
// Insert strings as soon as we know we're showing them
this.maybeInsertFTL(win);
toolbarbutton.setAttribute(
"aria-labelledby",
`toolbarbutton-notification-description ${message.content.target}`
);
// Because tooltiptext is different to the label, it gets duplicated as
// the description. Setting `describedby` to the same value as
// `labelledby` will be detected by the a11y code and the description
// will be removed.
toolbarbutton.setAttribute(
"aria-describedby",
`toolbarbutton-notification-description ${message.content.target}`
);
const descriptionEl = document.createElement("span");
descriptionEl.setAttribute(
"id",
"toolbarbutton-notification-description"
);
descriptionEl.setAttribute("hidden", true);
document.l10n.setAttributes(
descriptionEl,
message.content.badgeDescription.string_id
);
toolbarbutton.appendChild(descriptionEl);
}
// `mousedown` event required because of the `onmousedown` defined on
// the button that prevents `click` events from firing
toolbarbutton.addEventListener("mousedown", this.removeAllNotifications);
// `keypress` event required for keyboard accessibility
toolbarbutton.addEventListener("keypress", this.removeAllNotifications);
this.state = { notification: { id: message.id } };
// Impression should be added when the badge becomes visible
this._addImpression(message);
// Send a telemetry ping when adding the notification badge
this.sendUserEventTelemetry("IMPRESSION", message);
return toolbarbutton;
}
return null;
}
registerBadgeToAllWindows(message) {
if (message.template === "update_action") {
this.executeAction({ ...message.content.action, message_id: message.id });
// No badge to set only an action to execute
return;
}
EveryWindow.registerCallback(
this.id,
win => {
if (notificationsByWindow.has(win)) {
// nothing to do
return;
}
const el = this.addToolbarNotification(win, message);
notificationsByWindow.set(win, el);
},
win => {
const el = notificationsByWindow.get(win);
if (el) {
this.removeToolbarNotification(el);
}
notificationsByWindow.delete(win);
}
);
}
registerBadgeNotificationListener(message, options = {}) {
// We need to clear any existing notifications and only show
// the one set by devtools
if (options.force) {
this.removeAllNotifications();
// When debugging immediately show the badge
this.registerBadgeToAllWindows(message);
return;
}
if (message.content.delay) {
this.state.showBadgeTimeoutId = setTimeout(() => {
requestIdleCallback(() => this.registerBadgeToAllWindows(message));
}, message.content.delay);
} else {
this.registerBadgeToAllWindows(message);
}
}
async messageRequest({ triggerId, template }) {
const message = await this._handleMessageRequest({
triggerId,
template,
});
if (message) {
this.registerBadgeNotificationListener(message);
}
}
_sendTelemetry(ping) {
this._dispatch({
type: "TOOLBAR_BADGE_TELEMETRY",
data: { action: "cfr_user_event", source: "CFR", ...ping },
});
}
sendUserEventTelemetry(event, message) {
const win = Services.wm.getMostRecentWindow("navigator:browser");
// Only send pings for non private browsing windows
if (
win &&
!PrivateBrowsingUtils.isBrowserPrivate(
win.ownerGlobal.gBrowser.selectedBrowser
)
) {
this._sendTelemetry({
message_id: message.id,
bucket_id: message.id,
event,
});
}
}
uninit() {
this._clearBadgeTimeout();
clearInterval(this.state._intervalId);
this.state = null;
notificationsByWindow = new WeakMap();
Services.prefs.removeObserver(this.prefs.WHATSNEW_TOOLBAR_PANEL, this);
}
}
this._ToolbarBadgeHub = _ToolbarBadgeHub;
/**
* ToolbarBadgeHub - singleton instance of _ToolbarBadgeHub that can initiate
* message requests and render messages.
*/
this.ToolbarBadgeHub = new _ToolbarBadgeHub();
const EXPORTED_SYMBOLS = ["ToolbarBadgeHub", "_ToolbarBadgeHub"];
================================================
FILE: lib/ToolbarPanelHub.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 { XPCOMUtils } = ChromeUtils.import(
"resource://gre/modules/XPCOMUtils.jsm"
);
XPCOMUtils.defineLazyModuleGetters(this, {
Services: "resource://gre/modules/Services.jsm",
EveryWindow: "resource:///modules/EveryWindow.jsm",
PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.jsm",
RemoteL10n: "resource://activity-stream/lib/RemoteL10n.jsm",
});
XPCOMUtils.defineLazyServiceGetter(
this,
"TrackingDBService",
"@mozilla.org/tracking-db-service;1",
"nsITrackingDBService"
);
const idToTextMap = new Map([
[Ci.nsITrackingDBService.TRACKERS_ID, "trackerCount"],
[Ci.nsITrackingDBService.TRACKING_COOKIES_ID, "cookieCount"],
[Ci.nsITrackingDBService.CRYPTOMINERS_ID, "cryptominerCount"],
[Ci.nsITrackingDBService.FINGERPRINTERS_ID, "fingerprinterCount"],
[Ci.nsITrackingDBService.SOCIAL_ID, "socialCount"],
]);
const WHATSNEW_ENABLED_PREF = "browser.messaging-system.whatsNewPanel.enabled";
const PROTECTIONS_PANEL_INFOMSG_PREF =
"browser.protections_panel.infoMessage.seen";
const TOOLBAR_BUTTON_ID = "whats-new-menu-button";
const APPMENU_BUTTON_ID = "appMenu-whatsnew-button";
const BUTTON_STRING_ID = "cfr-whatsnew-button";
const WHATS_NEW_PANEL_SELECTOR = "PanelUI-whatsNew-message-container";
class _ToolbarPanelHub {
constructor() {
this.triggerId = "whatsNewPanelOpened";
this._showAppmenuButton = this._showAppmenuButton.bind(this);
this._hideAppmenuButton = this._hideAppmenuButton.bind(this);
this._showToolbarButton = this._showToolbarButton.bind(this);
this._hideToolbarButton = this._hideToolbarButton.bind(this);
this.insertProtectionPanelMessage = this.insertProtectionPanelMessage.bind(
this
);
this.state = {};
}
async init(waitForInitialized, { getMessages, dispatch, handleUserAction }) {
this._getMessages = getMessages;
this._dispatch = dispatch;
this._handleUserAction = handleUserAction;
// Wait for ASRouter messages to become available in order to know
// if we can show the What's New panel
await waitForInitialized;
if (this.whatsNewPanelEnabled) {
// Enable the application menu button so that the user can access
// the panel outside of the toolbar button
this.enableAppmenuButton();
}
// Listen for pref changes that could turn off the feature
Services.prefs.addObserver(WHATSNEW_ENABLED_PREF, this);
this.state = {
protectionPanelMessageSeen: Services.prefs.getBoolPref(
PROTECTIONS_PANEL_INFOMSG_PREF,
false
),
};
}
uninit() {
EveryWindow.unregisterCallback(TOOLBAR_BUTTON_ID);
EveryWindow.unregisterCallback(APPMENU_BUTTON_ID);
Services.prefs.removeObserver(WHATSNEW_ENABLED_PREF, this);
}
observe(aSubject, aTopic, aPrefName) {
switch (aPrefName) {
case WHATSNEW_ENABLED_PREF:
if (!this.whatsNewPanelEnabled) {
this.uninit();
}
break;
}
}
get messages() {
return this._getMessages({
template: "whatsnew_panel_message",
triggerId: "whatsNewPanelOpened",
returnAll: true,
});
}
get whatsNewPanelEnabled() {
return Services.prefs.getBoolPref(WHATSNEW_ENABLED_PREF, false);
}
maybeInsertFTL(win) {
win.MozXULElement.insertFTLIfNeeded("browser/newtab/asrouter.ftl");
win.MozXULElement.insertFTLIfNeeded("browser/branding/brandings.ftl");
win.MozXULElement.insertFTLIfNeeded("browser/branding/sync-brand.ftl");
}
// Turns on the Appmenu (hamburger menu) button for all open windows and future windows.
async enableAppmenuButton() {
if ((await this.messages).length) {
EveryWindow.registerCallback(
APPMENU_BUTTON_ID,
this._showAppmenuButton,
this._hideAppmenuButton
);
}
}
// Turns on the Toolbar button for all open windows and future windows.
async enableToolbarButton() {
if ((await this.messages).length) {
EveryWindow.registerCallback(
TOOLBAR_BUTTON_ID,
this._showToolbarButton,
this._hideToolbarButton
);
}
}
// When the panel is hidden we want to run some cleanup
_onPanelHidden(win) {
const panelContainer = win.document.getElementById(
"customizationui-widget-panel"
);
// When the panel is hidden we want to remove any toolbar buttons that
// might have been added as an entry point to the panel
const removeToolbarButton = () => {
EveryWindow.unregisterCallback(TOOLBAR_BUTTON_ID);
};
if (!panelContainer) {
return;
}
panelContainer.addEventListener("popuphidden", removeToolbarButton, {
once: true,
});
}
// Newer messages first and use `order` field to decide between messages
// with the same timestamp
_sortWhatsNewMessages(m1, m2) {
// Sort by published_date in descending order.
if (m1.content.published_date === m2.content.published_date) {
// Ascending order
return m1.order - m2.order;
}
if (m1.content.published_date > m2.content.published_date) {
return -1;
}
return 1;
}
// Render what's new messages into the panel.
async renderMessages(win, doc, containerId, options = {}) {
const messages =
(options.force && options.messages) ||
(await this.messages).sort(this._sortWhatsNewMessages);
const container = doc.getElementById(containerId);
if (messages) {
// Targeting attribute state might have changed making new messages
// available and old messages invalid, we need to refresh
for (const prevMessageEl of container.querySelectorAll(
".whatsNew-message"
)) {
container.removeChild(prevMessageEl);
}
let previousDate = 0;
// Get and store any variable part of the message content
this.state.contentArguments = await this._contentArguments();
for (let message of messages) {
container.appendChild(
await this._createMessageElements(win, doc, message, previousDate)
);
previousDate = message.content.published_date;
}
}
this._onPanelHidden(win);
// Panel impressions are not associated with one particular message
// but with a set of messages. We concatenate message ids and send them
// back for every impression.
const eventId = {
id: messages
.map(({ id }) => id)
.sort()
.join(","),
};
// Check `mainview` attribute to determine if the panel is shown as a
// subview (inside the application menu) or as a toolbar dropdown.
// https://searchfox.org/mozilla-central/rev/07f7390618692fa4f2a674a96b9b677df3a13450/browser/components/customizableui/PanelMultiView.jsm#1268
const mainview = win.PanelUI.whatsNewPanel.hasAttribute("mainview");
this.sendUserEventTelemetry(win, "IMPRESSION", eventId, {
value: { view: mainview ? "toolbar_dropdown" : "application_menu" },
});
}
removeMessages(win, containerId) {
const doc = win.document;
const messageNodes = doc
.getElementById(containerId)
.querySelectorAll(".whatsNew-message");
for (const messageNode of messageNodes) {
messageNode.remove();
}
}
/**
* Dispatch the action defined in the message and user telemetry event.
*/
_dispatchUserAction(win, message) {
let url;
try {
// Set platform specific path variables for SUMO articles
url = Services.urlFormatter.formatURL(message.content.cta_url);
} catch (e) {
Cu.reportError(e);
url = message.content.cta_url;
}
this._handleUserAction({
target: win,
data: {
type: message.content.cta_type,
data: {
args: url,
where: "tabshifted",
},
},
});
this.sendUserEventTelemetry(win, "CLICK", message);
}
/**
* Attach event listener to dispatch message defined action.
*/
_attachClickListener(win, element, message) {
// Add event listener for `mouseup` not to overlap with the
// `mousedown` & `click` events dispatched from PanelMultiView.jsm
// https://searchfox.org/mozilla-central/rev/7531325c8660cfa61bf71725f83501028178cbb9/browser/components/customizableui/PanelMultiView.jsm#1830-1837
element.addEventListener("mouseup", () => {
this._dispatchUserAction(win, message);
});
}
async _createMessageElements(win, doc, message, previousDate) {
const { content } = message;
const messageEl = await this._createElement(doc, "div");
messageEl.classList.add("whatsNew-message");
// Only render date if it is different from the one rendered before.
if (content.published_date !== previousDate) {
messageEl.appendChild(
await this._createElement(doc, "p", {
classList: "whatsNew-message-date",
content: new Date(content.published_date).toLocaleDateString(
"default",
{
month: "long",
day: "numeric",
year: "numeric",
}
),
})
);
}
const wrapperEl = await this._createElement(doc, "button");
wrapperEl.doCommand = () => this._dispatchUserAction(win, message);
wrapperEl.classList.add("whatsNew-message-body");
messageEl.appendChild(wrapperEl);
if (content.icon_url) {
wrapperEl.classList.add("has-icon");
const iconEl = await this._createElement(doc, "img");
iconEl.src = content.icon_url;
iconEl.classList.add("whatsNew-message-icon");
await this._setTextAttribute(iconEl, "alt", content.icon_alt);
wrapperEl.appendChild(iconEl);
}
wrapperEl.appendChild(await this._createMessageContent(win, doc, content));
if (content.link_text) {
const anchorEl = await this._createElement(doc, "a", {
classList: "text-link",
content: content.link_text,
});
anchorEl.doCommand = () => this._dispatchUserAction(win, message);
wrapperEl.appendChild(anchorEl);
}
// Attach event listener on entire message container
this._attachClickListener(win, messageEl, message);
return messageEl;
}
/**
* Return message title (optional subtitle) and body
*/
async _createMessageContent(win, doc, content) {
const wrapperEl = new win.DocumentFragment();
wrapperEl.appendChild(
await this._createElement(doc, "h2", {
classList: "whatsNew-message-title",
content: content.title,
})
);
switch (content.layout) {
case "tracking-protections":
await wrapperEl.appendChild(
await this._createElement(doc, "h4", {
classList: "whatsNew-message-subtitle",
content: content.subtitle,
})
);
wrapperEl.appendChild(
await this._createElement(doc, "h2", {
classList: "whatsNew-message-title-large",
content: this.state.contentArguments[
content.layout_title_content_variable
],
})
);
break;
}
wrapperEl.appendChild(
await this._createElement(doc, "p", { content: content.body })
);
return wrapperEl;
}
async _createHeroElement(win, doc, message) {
const messageEl = await this._createElement(doc, "div");
messageEl.setAttribute("id", "protections-popup-message");
messageEl.classList.add("whatsNew-hero-message");
const wrapperEl = await this._createElement(doc, "div");
wrapperEl.classList.add("whatsNew-message-body");
messageEl.appendChild(wrapperEl);
wrapperEl.appendChild(
await this._createElement(doc, "h2", {
classList: "whatsNew-message-title",
content: message.content.title,
})
);
wrapperEl.appendChild(
await this._createElement(doc, "p", { content: message.content.body })
);
if (message.content.link_text) {
let linkEl = await this._createElement(doc, "a", {
classList: "text-link",
content: message.content.link_text,
});
wrapperEl.appendChild(linkEl);
this._attachClickListener(win, linkEl, message);
} else {
this._attachClickListener(win, wrapperEl, message);
}
return messageEl;
}
async _createElement(doc, elem, options = {}) {
const node = doc.createElementNS("http://www.w3.org/1999/xhtml", elem);
if (options.classList) {
node.classList.add(options.classList);
}
if (options.content) {
await this._setString(node, options.content);
}
return node;
}
async _contentArguments() {
// Between now and 6 weeks ago
const dateTo = new Date();
const dateFrom = new Date(dateTo.getTime() - 42 * 24 * 60 * 60 * 1000);
const eventsByDate = await TrackingDBService.getEventsByDateRange(
dateFrom,
dateTo
);
// Make sure we set all types of possible values to 0 because they might
// be referenced by fluent strings
let totalEvents = { blockedCount: 0 };
for (let blockedType of idToTextMap.values()) {
totalEvents[blockedType] = 0;
}
// Count all events in the past 6 weeks. Returns an object with:
// `blockedCount` total number of blocked resources
// {tracker|cookie|social...} breakdown by event type as defined by `idToTextMap`
totalEvents = eventsByDate.reduce((acc, day) => {
const type = day.getResultByName("type");
const count = day.getResultByName("count");
acc[idToTextMap.get(type)] = (acc[idToTextMap.get(type)] || 0) + count;
acc.blockedCount += count;
return acc;
}, totalEvents);
return {
// Keys need to match variable names used in asrouter.ftl
// `earliestDate` will be either 6 weeks ago or when tracking recording
// started. Whichever is more recent.
earliestDate: Math.max(
new Date(await TrackingDBService.getEarliestRecordedDate()),
dateFrom
),
...totalEvents,
};
}
// If `string_id` is present it means we are relying on fluent for translations.
// Otherwise, we have a vanilla string.
async _setString(el, stringObj) {
if (stringObj && stringObj.string_id) {
const [{ value }] = await RemoteL10n.l10n.formatMessages([
{
id: stringObj.string_id,
// Pass all available arguments to Fluent
args: this.state.contentArguments,
},
]);
el.textContent = value;
} else {
el.textContent = stringObj;
}
}
// If `string_id` is present it means we are relying on fluent for translations.
// Otherwise, we have a vanilla string.
async _setTextAttribute(el, attr, stringObj) {
if (stringObj && stringObj.string_id) {
const [{ attributes }] = await RemoteL10n.l10n.formatMessages([
{
id: stringObj.string_id,
// Pass all available arguments to Fluent
args: this.state.contentArguments,
},
]);
if (attributes) {
const { value } = attributes.find(({ name }) => name === attr);
el.setAttribute(attr, value);
}
} else {
el.setAttribute(attr, stringObj);
}
}
async _showAppmenuButton(win) {
this.maybeInsertFTL(win);
await this._showElement(
win.browser.ownerDocument,
APPMENU_BUTTON_ID,
BUTTON_STRING_ID
);
}
_hideAppmenuButton(win) {
this._hideElement(win.browser.ownerDocument, APPMENU_BUTTON_ID);
}
_showToolbarButton(win) {
const document = win.browser.ownerDocument;
this.maybeInsertFTL(win);
return this._showElement(document, TOOLBAR_BUTTON_ID, BUTTON_STRING_ID);
}
_hideToolbarButton(win) {
this._hideElement(win.browser.ownerDocument, TOOLBAR_BUTTON_ID);
}
async _showElement(document, id, string_id) {
const el = document.getElementById(id);
await this._setTextAttribute(el, "label", { string_id });
await this._setTextAttribute(el, "tooltiptext", { string_id });
el.removeAttribute("hidden");
}
_hideElement(document, id) {
document.getElementById(id).setAttribute("hidden", true);
}
_sendTelemetry(ping) {
this._dispatch({
type: "TOOLBAR_PANEL_TELEMETRY",
data: { action: "cfr_user_event", source: "CFR", ...ping },
});
}
sendUserEventTelemetry(win, event, message, options = {}) {
// Only send pings for non private browsing windows
if (
win &&
!PrivateBrowsingUtils.isBrowserPrivate(
win.ownerGlobal.gBrowser.selectedBrowser
)
) {
this._sendTelemetry({
message_id: message.id,
bucket_id: message.id,
event,
event_context: options.value,
});
}
}
/**
* Inserts a message into the Protections Panel. The message is visible once
* and afterwards set in a collapsed state. It can be shown again using the
* info button in the panel header.
*/
async insertProtectionPanelMessage(event) {
const win = event.target.ownerGlobal;
this.maybeInsertFTL(win);
const doc = event.target.ownerDocument;
const container = doc.getElementById("messaging-system-message-container");
const infoButton = doc.getElementById("protections-popup-info-button");
const panelContainer = doc.getElementById("protections-popup");
const toggleMessage = () => {
container.toggleAttribute("disabled");
infoButton.toggleAttribute("checked");
panelContainer.toggleAttribute("infoMessageShowing");
};
if (!container.childElementCount) {
const message = await this._getMessages({
template: "protections_panel",
triggerId: "protectionsPanelOpen",
});
if (message) {
const messageEl = await this._createHeroElement(win, doc, message);
container.appendChild(messageEl);
infoButton.addEventListener("click", toggleMessage);
this.sendUserEventTelemetry(win, "IMPRESSION", message);
}
}
// Message is collapsed by default. If it was never shown before we want
// to expand it
if (
!this.state.protectionPanelMessageSeen &&
container.hasAttribute("disabled")
) {
toggleMessage();
}
// Save state that we displayed the message
if (!this.state.protectionPanelMessageSeen) {
Services.prefs.setBoolPref(PROTECTIONS_PANEL_INFOMSG_PREF, true);
this.state.protectionPanelMessageSeen = true;
}
// Collapse the message after the panel is hidden so we don't get the
// animation when opening the panel
panelContainer.addEventListener(
"popuphidden",
() => {
if (
this.state.protectionPanelMessageSeen &&
!container.hasAttribute("disabled")
) {
toggleMessage();
}
},
{
once: true,
}
);
}
/**
* @param {object} browser MessageChannel target argument as a response to a user action
* @param {object} message Message selected from devtools page
*/
forceShowMessage(browser, message) {
const win = browser.browser.ownerGlobal;
const doc = browser.browser.ownerDocument;
this.removeMessages(win, WHATS_NEW_PANEL_SELECTOR);
this.renderMessages(win, doc, WHATS_NEW_PANEL_SELECTOR, {
force: true,
messages: [message],
});
win.PanelUI.panel.addEventListener("popuphidden", event =>
this.removeMessages(event.target.ownerGlobal, WHATS_NEW_PANEL_SELECTOR)
);
}
}
this._ToolbarPanelHub = _ToolbarPanelHub;
/**
* ToolbarPanelHub - singleton instance of _ToolbarPanelHub that can initiate
* message requests and render messages.
*/
this.ToolbarPanelHub = new _ToolbarPanelHub();
const EXPORTED_SYMBOLS = ["ToolbarPanelHub", "_ToolbarPanelHub"];
================================================
FILE: lib/TopSitesFeed.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 { XPCOMUtils } = ChromeUtils.import(
"resource://gre/modules/XPCOMUtils.jsm"
);
const { actionCreators: ac, actionTypes: at } = ChromeUtils.import(
"resource://activity-stream/common/Actions.jsm"
);
const { TippyTopProvider } = ChromeUtils.import(
"resource://activity-stream/lib/TippyTopProvider.jsm"
);
const { insertPinned, TOP_SITES_MAX_SITES_PER_ROW } = ChromeUtils.import(
"resource://activity-stream/common/Reducers.jsm"
);
const { Dedupe } = ChromeUtils.import(
"resource://activity-stream/common/Dedupe.jsm"
);
const { shortURL } = ChromeUtils.import(
"resource://activity-stream/lib/ShortURL.jsm"
);
const { getDefaultOptions } = ChromeUtils.import(
"resource://activity-stream/lib/ActivityStreamStorage.jsm"
);
const {
CUSTOM_SEARCH_SHORTCUTS,
SEARCH_SHORTCUTS_EXPERIMENT,
SEARCH_SHORTCUTS_SEARCH_ENGINES_PREF,
SEARCH_SHORTCUTS_HAVE_PINNED_PREF,
checkHasSearchEngine,
getSearchProvider,
} = ChromeUtils.import("resource://activity-stream/lib/SearchShortcuts.jsm");
ChromeUtils.defineModuleGetter(
this,
"filterAdult",
"resource://activity-stream/lib/FilterAdult.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"LinksCache",
"resource://activity-stream/lib/LinksCache.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"NewTabUtils",
"resource://gre/modules/NewTabUtils.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"Screenshots",
"resource://activity-stream/lib/Screenshots.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"PageThumbs",
"resource://gre/modules/PageThumbs.jsm"
);
const DEFAULT_SITES_PREF = "default.sites";
const DEFAULT_TOP_SITES = [];
const FRECENCY_THRESHOLD = 100 + 1; // 1 visit (skip first-run/one-time pages)
const MIN_FAVICON_SIZE = 96;
const CACHED_LINK_PROPS_TO_MIGRATE = ["screenshot", "customScreenshot"];
const PINNED_FAVICON_PROPS_TO_MIGRATE = [
"favicon",
"faviconRef",
"faviconSize",
];
const SECTION_ID = "topsites";
const ROWS_PREF = "topSitesRows";
// Search experiment stuff
const FILTER_DEFAULT_SEARCH_PREF = "improvesearch.noDefaultSearchTile";
const SEARCH_FILTERS = [
"google",
"search.yahoo",
"yahoo",
"bing",
"ask",
"duckduckgo",
];
function getShortURLForCurrentSearch() {
const url = shortURL({ url: Services.search.defaultEngine.searchForm });
return url;
}
this.TopSitesFeed = class TopSitesFeed {
constructor() {
this._tippyTopProvider = new TippyTopProvider();
XPCOMUtils.defineLazyGetter(
this,
"_currentSearchHostname",
getShortURLForCurrentSearch
);
this.dedupe = new Dedupe(this._dedupeKey);
this.frecentCache = new LinksCache(
NewTabUtils.activityStreamLinks,
"getTopSites",
CACHED_LINK_PROPS_TO_MIGRATE,
(oldOptions, newOptions) =>
// Refresh if no old options or requesting more items
!(oldOptions.numItems >= newOptions.numItems)
);
this.pinnedCache = new LinksCache(NewTabUtils.pinnedLinks, "links", [
...CACHED_LINK_PROPS_TO_MIGRATE,
...PINNED_FAVICON_PROPS_TO_MIGRATE,
]);
PageThumbs.addExpirationFilter(this);
}
init() {
// If the feed was previously disabled PREFS_INITIAL_VALUES was never received
this.refreshDefaults(
this.store.getState().Prefs.values[DEFAULT_SITES_PREF]
);
this._storage = this.store.dbStorage.getDbTable("sectionPrefs");
this.refresh({ broadcast: true });
Services.obs.addObserver(this, "browser-search-engine-modified");
}
uninit() {
PageThumbs.removeExpirationFilter(this);
Services.obs.removeObserver(this, "browser-search-engine-modified");
}
observe(subj, topic, data) {
// We should update the current top sites if the search engine has been changed since
// the search engine that gets filtered out of top sites has changed.
if (
topic === "browser-search-engine-modified" &&
data === "engine-default" &&
this.store.getState().Prefs.values[FILTER_DEFAULT_SEARCH_PREF]
) {
delete this._currentSearchHostname;
this._currentSearchHostname = getShortURLForCurrentSearch();
this.refresh({ broadcast: true });
}
}
_dedupeKey(site) {
return site && site.hostname;
}
refreshDefaults(sites) {
// Clear out the array of any previous defaults
DEFAULT_TOP_SITES.length = 0;
// Add default sites if any based on the pref
if (sites) {
for (const url of sites.split(",")) {
const site = {
isDefault: true,
url,
};
site.hostname = shortURL(site);
DEFAULT_TOP_SITES.push(site);
}
}
}
filterForThumbnailExpiration(callback) {
const { rows } = this.store.getState().TopSites;
callback(
rows.reduce((acc, site) => {
acc.push(site.url);
if (site.customScreenshotURL) {
acc.push(site.customScreenshotURL);
}
return acc;
}, [])
);
}
/**
* shouldFilterSearchTile - is default filtering enabled and does a given hostname match the user's default search engine?
*
* @param {string} hostname a top site hostname, such as "amazon" or "foo"
* @returns {bool}
*/
shouldFilterSearchTile(hostname) {
if (
this.store.getState().Prefs.values[FILTER_DEFAULT_SEARCH_PREF] &&
(SEARCH_FILTERS.includes(hostname) ||
hostname === this._currentSearchHostname)
) {
return true;
}
return false;
}
/**
* _maybeInsertSearchShortcuts - if the search shortcuts experiment is running,
* insert search shortcuts if needed
* @param {Array} plainPinnedSites (from the pinnedSitesCache)
* @returns {Boolean} Did we insert any search shortcuts?
*/
async _maybeInsertSearchShortcuts(plainPinnedSites) {
// Only insert shortcuts if the experiment is running
if (this.store.getState().Prefs.values[SEARCH_SHORTCUTS_EXPERIMENT]) {
// We don't want to insert shortcuts we've previously inserted
const prevInsertedShortcuts = this.store
.getState()
.Prefs.values[SEARCH_SHORTCUTS_HAVE_PINNED_PREF].split(",")
.filter(s => s); // Filter out empty strings
const newInsertedShortcuts = [];
const shouldPin = this.store
.getState()
.Prefs.values[SEARCH_SHORTCUTS_SEARCH_ENGINES_PREF].split(",")
.map(getSearchProvider)
.filter(s => s && s.shortURL !== this._currentSearchHostname);
// If we've previously inserted all search shortcuts return early
if (
shouldPin.every(shortcut =>
prevInsertedShortcuts.includes(shortcut.shortURL)
)
) {
return false;
}
const numberOfSlots =
this.store.getState().Prefs.values[ROWS_PREF] *
TOP_SITES_MAX_SITES_PER_ROW;
// The plainPinnedSites array is populated with pinned sites at their
// respective indices, and null everywhere else, but is not always the
// right length
const emptySlots = Math.max(numberOfSlots - plainPinnedSites.length, 0);
const pinnedSites = [...plainPinnedSites].concat(
Array(emptySlots).fill(null)
);
const tryToInsertSearchShortcut = async shortcut => {
const nextAvailable = pinnedSites.indexOf(null);
// Only add a search shortcut if the site isn't already pinned, we
// haven't previously inserted it, there's space to pin it, and the
// search engine is available in Firefox
if (
!pinnedSites.find(s => s && s.hostname === shortcut.shortURL) &&
!prevInsertedShortcuts.includes(shortcut.shortURL) &&
nextAvailable > -1 &&
(await checkHasSearchEngine(shortcut.keyword))
) {
const site = await this.topSiteToSearchTopSite({ url: shortcut.url });
this._pinSiteAt(site, nextAvailable);
pinnedSites[nextAvailable] = site;
newInsertedShortcuts.push(shortcut.shortURL);
}
};
for (let shortcut of shouldPin) {
await tryToInsertSearchShortcut(shortcut);
}
if (newInsertedShortcuts.length) {
this.store.dispatch(
ac.SetPref(
SEARCH_SHORTCUTS_HAVE_PINNED_PREF,
prevInsertedShortcuts.concat(newInsertedShortcuts).join(",")
)
);
return true;
}
}
return false;
}
async getLinksWithDefaults() {
const numItems =
this.store.getState().Prefs.values[ROWS_PREF] *
TOP_SITES_MAX_SITES_PER_ROW;
const searchShortcutsExperiment = this.store.getState().Prefs.values[
SEARCH_SHORTCUTS_EXPERIMENT
];
// We must wait for search services to initialize in order to access default
// search engine properties without triggering a synchronous initialization
await Services.search.init();
// Get all frecent sites from history.
let frecent = [];
const cache = await this.frecentCache.request({
// We need to overquery due to the top 5 alexa search + default search possibly being removed
numItems: numItems + SEARCH_FILTERS.length + 1,
topsiteFrecency: FRECENCY_THRESHOLD,
});
for (let link of cache) {
const hostname = shortURL(link);
if (!this.shouldFilterSearchTile(hostname)) {
frecent.push({
...(searchShortcutsExperiment
? await this.topSiteToSearchTopSite(link)
: link),
hostname,
});
}
}
// Remove any defaults that have been blocked.
let notBlockedDefaultSites = [];
for (let link of DEFAULT_TOP_SITES) {
const searchProvider = getSearchProvider(shortURL(link));
if (NewTabUtils.blockedLinks.isBlocked({ url: link.url })) {
continue;
} else if (this.shouldFilterSearchTile(link.hostname)) {
continue;
// If we've previously blocked a search shortcut, remove the default top site
// that matches the hostname
} else if (
searchProvider &&
NewTabUtils.blockedLinks.isBlocked({ url: searchProvider.url })
) {
continue;
}
notBlockedDefaultSites.push(
searchShortcutsExperiment
? await this.topSiteToSearchTopSite(link)
: link
);
}
// Get pinned links augmented with desired properties
let plainPinned = await this.pinnedCache.request();
// Insert search shortcuts if we need to.
// _maybeInsertSearchShortcuts returns true if any search shortcuts are
// inserted, meaning we need to expire and refresh the pinnedCache
if (await this._maybeInsertSearchShortcuts(plainPinned)) {
this.pinnedCache.expire();
plainPinned = await this.pinnedCache.request();
}
const pinned = await Promise.all(
plainPinned.map(async link => {
if (!link) {
return link;
}
// Copy all properties from a frecent link and add more
const finder = other => other.url === link.url;
// Remove frecent link's screenshot if pinned link has a custom one
const frecentSite = frecent.find(finder);
if (frecentSite && link.customScreenshotURL) {
delete frecentSite.screenshot;
}
// If the link is a frecent site, do not copy over 'isDefault', else check
// if the site is a default site
const copy = Object.assign(
{},
frecentSite || { isDefault: !!notBlockedDefaultSites.find(finder) },
link,
{ hostname: shortURL(link) },
{ searchTopSite: !!link.searchTopSite }
);
// Add in favicons if we don't already have it
if (!copy.favicon) {
try {
NewTabUtils.activityStreamProvider._faviconBytesToDataURI(
await NewTabUtils.activityStreamProvider._addFavicons([copy])
);
for (const prop of PINNED_FAVICON_PROPS_TO_MIGRATE) {
copy.__sharedCache.updateLink(prop, copy[prop]);
}
} catch (e) {
// Some issue with favicon, so just continue without one
}
}
return copy;
})
);
// Remove any duplicates from frecent and default sites
const [, dedupedFrecent, dedupedDefaults] = this.dedupe.group(
pinned,
frecent,
notBlockedDefaultSites
);
const dedupedUnpinned = [...dedupedFrecent, ...dedupedDefaults];
// Remove adult sites if we need to
const checkedAdult = this.store.getState().Prefs.values.filterAdult
? filterAdult(dedupedUnpinned)
: dedupedUnpinned;
// Insert the original pinned sites into the deduped frecent and defaults
const withPinned = insertPinned(checkedAdult, pinned).slice(0, numItems);
// Now, get a tippy top icon, a rich icon, or screenshot for every item
for (const link of withPinned) {
if (link) {
// If there is a custom screenshot this is the only image we display
if (link.customScreenshotURL) {
this._fetchScreenshot(link, link.customScreenshotURL);
} else if (link.searchTopSite && !link.isDefault) {
this._tippyTopProvider.processSite(link);
} else {
this._fetchIcon(link);
}
// Remove internal properties that might be updated after dispatch
delete link.__sharedCache;
// Indicate that these links should get a frecency bonus when clicked
link.typedBonus = true;
}
}
return withPinned;
}
/**
* Refresh the top sites data for content.
* @param {bool} options.broadcast Should the update be broadcasted.
*/
async refresh(options = {}) {
if (!this._tippyTopProvider.initialized) {
await this._tippyTopProvider.init();
}
const links = await this.getLinksWithDefaults();
const newAction = { type: at.TOP_SITES_UPDATED, data: { links } };
let storedPrefs;
try {
storedPrefs = (await this._storage.get(SECTION_ID)) || {};
} catch (e) {
storedPrefs = {};
Cu.reportError("Problem getting stored prefs for TopSites");
}
newAction.data.pref = getDefaultOptions(storedPrefs);
if (options.broadcast) {
// Broadcast an update to all open content pages
this.store.dispatch(ac.BroadcastToContent(newAction));
} else {
// Don't broadcast only update the state and update the preloaded tab.
this.store.dispatch(ac.AlsoToPreloaded(newAction));
}
}
async updateCustomSearchShortcuts() {
if (!this.store.getState().Prefs.values[SEARCH_SHORTCUTS_EXPERIMENT]) {
return;
}
if (!this._tippyTopProvider.initialized) {
await this._tippyTopProvider.init();
}
// Populate the state with available search shortcuts
const searchShortcuts = (await Services.search.getDefaultEngines()).reduce(
(result, engine) => {
const shortcut = CUSTOM_SEARCH_SHORTCUTS.find(s =>
engine.wrappedJSObject._internalAliases.includes(s.keyword)
);
if (shortcut) {
result.push(this._tippyTopProvider.processSite({ ...shortcut }));
}
return result;
},
[]
);
this.store.dispatch(
ac.BroadcastToContent({
type: at.UPDATE_SEARCH_SHORTCUTS,
data: { searchShortcuts },
})
);
}
async topSiteToSearchTopSite(site) {
const searchProvider = getSearchProvider(shortURL(site));
if (
!searchProvider ||
!(await checkHasSearchEngine(searchProvider.keyword))
) {
return site;
}
return {
...site,
searchTopSite: true,
label: searchProvider.keyword,
};
}
/**
* Get an image for the link preferring tippy top, rich favicon, screenshots.
*/
async _fetchIcon(link) {
// Nothing to do if we already have a rich icon from the page
if (link.favicon && link.faviconSize >= MIN_FAVICON_SIZE) {
return;
}
// Nothing more to do if we can use a default tippy top icon
this._tippyTopProvider.processSite(link);
if (link.tippyTopIcon) {
return;
}
// Make a request for a better icon
this._requestRichIcon(link.url);
// Also request a screenshot if we don't have one yet
await this._fetchScreenshot(link, link.url);
}
/**
* Fetch, cache and broadcast a screenshot for a specific topsite.
* @param link cached topsite object
* @param url where to fetch the image from
*/
async _fetchScreenshot(link, url) {
if (link.screenshot) {
return;
}
await Screenshots.maybeCacheScreenshot(
link,
url,
"screenshot",
screenshot =>
this.store.dispatch(
ac.BroadcastToContent({
data: { screenshot, url: link.url },
type: at.SCREENSHOT_UPDATED,
})
)
);
}
/**
* Dispatch screenshot preview to target or notify if request failed.
* @param customScreenshotURL {string} The URL used to capture the screenshot
* @param target {string} Id of content process where to dispatch the result
*/
async getScreenshotPreview(url, target) {
const preview = (await Screenshots.getScreenshotForURL(url)) || "";
this.store.dispatch(
ac.OnlyToOneContent(
{
data: { url, preview },
type: at.PREVIEW_RESPONSE,
},
target
)
);
}
_requestRichIcon(url) {
this.store.dispatch({
type: at.RICH_ICON_MISSING,
data: { url },
});
}
updateSectionPrefs(collapsed) {
this.store.dispatch(
ac.BroadcastToContent({
type: at.TOP_SITES_PREFS_UPDATED,
data: { pref: collapsed },
})
);
}
/**
* Inform others that top sites data has been updated due to pinned changes.
*/
_broadcastPinnedSitesUpdated() {
// Pinned data changed, so make sure we get latest
this.pinnedCache.expire();
// Refresh to update pinned sites with screenshots, trigger deduping, etc.
this.refresh({ broadcast: true });
}
/**
* Pin a site at a specific position saving only the desired keys.
* @param customScreenshotURL {string} User set URL of preview image for site
* @param label {string} User set string of custom site name
*/
async _pinSiteAt({ customScreenshotURL, label, url, searchTopSite }, index) {
const toPin = { url };
if (label) {
toPin.label = label;
}
if (customScreenshotURL) {
toPin.customScreenshotURL = customScreenshotURL;
}
if (searchTopSite) {
toPin.searchTopSite = searchTopSite;
}
NewTabUtils.pinnedLinks.pin(toPin, index);
await this._clearLinkCustomScreenshot({ customScreenshotURL, url });
}
async _clearLinkCustomScreenshot(site) {
// If screenshot url changed or was removed we need to update the cached link obj
if (site.customScreenshotURL !== undefined) {
const pinned = await this.pinnedCache.request();
const link = pinned.find(pin => pin && pin.url === site.url);
if (link && link.customScreenshotURL !== site.customScreenshotURL) {
link.__sharedCache.updateLink("screenshot", undefined);
}
}
}
/**
* Handle a pin action of a site to a position.
*/
async pin(action) {
const { site, index } = action.data;
// If valid index provided, pin at that position
if (index >= 0) {
await this._pinSiteAt(site, index);
this._broadcastPinnedSitesUpdated();
} else {
// Bug 1458658. If the top site is being pinned from an 'Add a Top Site' option,
// then we want to make sure to unblock that link if it has previously been
// blocked. We know if the site has been added because the index will be -1.
if (index === -1) {
NewTabUtils.blockedLinks.unblock({ url: site.url });
this.frecentCache.expire();
}
this.insert(action);
}
}
/**
* Handle an unpin action of a site.
*/
unpin(action) {
const { site } = action.data;
NewTabUtils.pinnedLinks.unpin(site);
this._broadcastPinnedSitesUpdated();
}
disableSearchImprovements() {
Services.prefs.clearUserPref(
`browser.newtabpage.activity-stream.${SEARCH_SHORTCUTS_HAVE_PINNED_PREF}`
);
this.unpinAllSearchShortcuts();
}
unpinAllSearchShortcuts() {
for (let pinnedLink of NewTabUtils.pinnedLinks.links) {
if (pinnedLink && pinnedLink.searchTopSite) {
NewTabUtils.pinnedLinks.unpin(pinnedLink);
}
}
this.pinnedCache.expire();
}
/**
* Insert a site to pin at a position shifting over any other pinned sites.
*/
_insertPin(site, index, draggedFromIndex) {
// Don't insert any pins past the end of the visible top sites. Otherwise,
// we can end up with a bunch of pinned sites that can never be unpinned again
// from the UI.
const topSitesCount =
this.store.getState().Prefs.values[ROWS_PREF] *
TOP_SITES_MAX_SITES_PER_ROW;
if (index >= topSitesCount) {
return;
}
let pinned = NewTabUtils.pinnedLinks.links;
if (!pinned[index]) {
this._pinSiteAt(site, index);
} else {
pinned[draggedFromIndex] = null;
// Find the hole to shift the pinned site(s) towards. We shift towards the
// hole left by the site being dragged.
let holeIndex = index;
const indexStep = index > draggedFromIndex ? -1 : 1;
while (pinned[holeIndex]) {
holeIndex += indexStep;
}
if (holeIndex >= topSitesCount || holeIndex < 0) {
// There are no holes, so we will effectively unpin the last slot and shifting
// towards it. This only happens when adding a new top site to an already
// fully pinned grid.
holeIndex = topSitesCount - 1;
}
// Shift towards the hole.
const shiftingStep = holeIndex > index ? -1 : 1;
while (holeIndex !== index) {
const nextIndex = holeIndex + shiftingStep;
this._pinSiteAt(pinned[nextIndex], holeIndex);
holeIndex = nextIndex;
}
this._pinSiteAt(site, index);
}
}
/**
* Handle an insert (drop/add) action of a site.
*/
async insert(action) {
let { index } = action.data;
// Treat invalid pin index values (e.g., -1, undefined) as insert in the first position
if (!(index > 0)) {
index = 0;
}
// Inserting a top site pins it in the specified slot, pushing over any link already
// pinned in the slot (unless it's the last slot, then it replaces).
this._insertPin(
action.data.site,
index,
action.data.draggedFromIndex !== undefined
? action.data.draggedFromIndex
: this.store.getState().Prefs.values[ROWS_PREF] *
TOP_SITES_MAX_SITES_PER_ROW
);
await this._clearLinkCustomScreenshot(action.data.site);
this._broadcastPinnedSitesUpdated();
}
updatePinnedSearchShortcuts({ addedShortcuts, deletedShortcuts }) {
// Unpin the deletedShortcuts.
deletedShortcuts.forEach(({ url }) => {
NewTabUtils.pinnedLinks.unpin({ url });
});
// Pin the addedShortcuts.
const numberOfSlots =
this.store.getState().Prefs.values[ROWS_PREF] *
TOP_SITES_MAX_SITES_PER_ROW;
addedShortcuts.forEach(shortcut => {
// Find first hole in pinnedLinks.
let index = NewTabUtils.pinnedLinks.links.findIndex(link => !link);
if (
index < 0 &&
NewTabUtils.pinnedLinks.links.length + 1 < numberOfSlots
) {
// pinnedLinks can have less slots than the total available.
index = NewTabUtils.pinnedLinks.links.length;
}
if (index >= 0) {
NewTabUtils.pinnedLinks.pin(shortcut, index);
} else {
// No slots available, we need to do an insert in first slot and push over other pinned links.
this._insertPin(shortcut, 0, numberOfSlots);
}
});
this._broadcastPinnedSitesUpdated();
}
onAction(action) {
switch (action.type) {
case at.INIT:
this.init();
this.updateCustomSearchShortcuts();
break;
case at.SYSTEM_TICK:
this.refresh({ broadcast: false });
break;
// All these actions mean we need new top sites
case at.PLACES_HISTORY_CLEARED:
case at.PLACES_LINK_DELETED:
this.frecentCache.expire();
this.refresh({ broadcast: true });
break;
case at.PLACES_LINKS_CHANGED:
this.frecentCache.expire();
this.refresh({ broadcast: false });
break;
case at.PLACES_LINK_BLOCKED:
this.frecentCache.expire();
this.pinnedCache.expire();
this.refresh({ broadcast: true });
break;
case at.PREF_CHANGED:
switch (action.data.name) {
case DEFAULT_SITES_PREF:
this.refreshDefaults(action.data.value);
break;
case ROWS_PREF:
case FILTER_DEFAULT_SEARCH_PREF:
case SEARCH_SHORTCUTS_SEARCH_ENGINES_PREF:
this.refresh({ broadcast: true });
break;
case SEARCH_SHORTCUTS_EXPERIMENT:
if (action.data.value) {
this.updateCustomSearchShortcuts();
} else {
this.disableSearchImprovements();
}
this.refresh({ broadcast: true });
}
break;
case at.UPDATE_SECTION_PREFS:
if (action.data.id === SECTION_ID) {
this.updateSectionPrefs(action.data.value);
}
break;
case at.PREFS_INITIAL_VALUES:
this.refreshDefaults(action.data[DEFAULT_SITES_PREF]);
break;
case at.TOP_SITES_PIN:
this.pin(action);
break;
case at.TOP_SITES_UNPIN:
this.unpin(action);
break;
case at.TOP_SITES_INSERT:
this.insert(action);
break;
case at.PREVIEW_REQUEST:
this.getScreenshotPreview(action.data.url, action.meta.fromTarget);
break;
case at.UPDATE_PINNED_SEARCH_SHORTCUTS:
this.updatePinnedSearchShortcuts(action.data);
break;
case at.UNINIT:
this.uninit();
break;
}
}
};
this.DEFAULT_TOP_SITES = DEFAULT_TOP_SITES;
const EXPORTED_SYMBOLS = ["TopSitesFeed", "DEFAULT_TOP_SITES"];
================================================
FILE: lib/TopStoriesFeed.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 { XPCOMUtils } = ChromeUtils.import(
"resource://gre/modules/XPCOMUtils.jsm"
);
const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
const { NewTabUtils } = ChromeUtils.import(
"resource://gre/modules/NewTabUtils.jsm"
);
XPCOMUtils.defineLazyGlobalGetters(this, ["fetch"]);
const { actionTypes: at, actionCreators: ac } = ChromeUtils.import(
"resource://activity-stream/common/Actions.jsm"
);
const { Prefs } = ChromeUtils.import(
"resource://activity-stream/lib/ActivityStreamPrefs.jsm"
);
const { shortURL } = ChromeUtils.import(
"resource://activity-stream/lib/ShortURL.jsm"
);
const { SectionsManager } = ChromeUtils.import(
"resource://activity-stream/lib/SectionsManager.jsm"
);
const { UserDomainAffinityProvider } = ChromeUtils.import(
"resource://activity-stream/lib/UserDomainAffinityProvider.jsm"
);
const { PersonalityProvider } = ChromeUtils.import(
"resource://activity-stream/lib/PersonalityProvider.jsm"
);
const { PersistentCache } = ChromeUtils.import(
"resource://activity-stream/lib/PersistentCache.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"perfService",
"resource://activity-stream/common/PerfService.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"pktApi",
"chrome://pocket/content/pktApi.jsm"
);
const STORIES_UPDATE_TIME = 30 * 60 * 1000; // 30 minutes
const TOPICS_UPDATE_TIME = 3 * 60 * 60 * 1000; // 3 hours
const STORIES_NOW_THRESHOLD = 24 * 60 * 60 * 1000; // 24 hours
const MIN_DOMAIN_AFFINITIES_UPDATE_TIME = 12 * 60 * 60 * 1000; // 12 hours
const DEFAULT_RECS_EXPIRE_TIME = 60 * 60 * 1000; // 1 hour
const SECTION_ID = "topstories";
const IMPRESSION_SOURCE = "TOP_STORIES";
const SPOC_IMPRESSION_TRACKING_PREF =
"feeds.section.topstories.spoc.impressions";
const DISCOVERY_STREAM_PREF_ENABLED = "discoverystream.enabled";
const DISCOVERY_STREAM_PREF_ENABLED_PATH =
"browser.newtabpage.activity-stream.discoverystream.enabled";
const REC_IMPRESSION_TRACKING_PREF = "feeds.section.topstories.rec.impressions";
const OPTIONS_PREF = "feeds.section.topstories.options";
const MAX_LIFETIME_CAP = 500; // Guard against misconfiguration on the server
const DISCOVERY_STREAM_PREF = "discoverystream.config";
this.TopStoriesFeed = class TopStoriesFeed {
constructor(ds) {
// Use discoverystream config pref default values for fast path and
// if needed lazy load activity stream top stories feed based on
// actual user preference when INIT and PREF_CHANGED is invoked
this.discoveryStreamEnabled =
ds &&
ds.value &&
JSON.parse(ds.value).enabled &&
Services.prefs.getBoolPref(DISCOVERY_STREAM_PREF_ENABLED_PATH, false);
if (!this.discoveryStreamEnabled) {
this.initializeProperties();
}
}
initializeProperties() {
this.contentUpdateQueue = [];
this.spocCampaignMap = new Map();
this.cache = new PersistentCache(SECTION_ID, true);
this._prefs = new Prefs();
this.propertiesInitialized = true;
}
async onInit() {
SectionsManager.enableSection(SECTION_ID);
if (this.discoveryStreamEnabled) {
return;
}
try {
const { options } = SectionsManager.sections.get(SECTION_ID);
const apiKey = this.getApiKeyFromPref(options.api_key_pref);
this.stories_endpoint = this.produceFinalEndpointUrl(
options.stories_endpoint,
apiKey
);
this.topics_endpoint = this.produceFinalEndpointUrl(
options.topics_endpoint,
apiKey
);
this.read_more_endpoint = options.read_more_endpoint;
this.stories_referrer = options.stories_referrer;
this.personalized = options.personalized;
this.show_spocs = options.show_spocs;
this.maxHistoryQueryResults = options.maxHistoryQueryResults;
this.storiesLastUpdated = 0;
this.topicsLastUpdated = 0;
this.storiesLoaded = false;
this.domainAffinitiesLastUpdated = 0;
this.processAffinityProividerVersion(options);
this.dispatchPocketCta(this._prefs.get("pocketCta"), false);
Services.obs.addObserver(this, "idle-daily");
// Cache is used for new page loads, which shouldn't have changed data.
// If we have changed data, cache should be cleared,
// and last updated should be 0, and we can fetch.
let { stories, topics } = await this.loadCachedData();
if (this.storiesLastUpdated === 0) {
stories = await this.fetchStories();
}
if (this.topicsLastUpdated === 0) {
topics = await this.fetchTopics();
}
this.doContentUpdate({ stories, topics }, true);
this.storiesLoaded = true;
// This is filtered so an update function can return true to retry on the next run
this.contentUpdateQueue = this.contentUpdateQueue.filter(update =>
update()
);
} catch (e) {
Cu.reportError(`Problem initializing top stories feed: ${e.message}`);
}
}
init() {
SectionsManager.onceInitialized(this.onInit.bind(this));
}
observe(subject, topic, data) {
switch (topic) {
case "idle-daily":
this.updateDomainAffinityScores();
break;
}
}
async clearCache() {
await this.cache.set("stories", {});
await this.cache.set("topics", {});
await this.cache.set("spocs", {});
}
uninit() {
this.storiesLoaded = false;
try {
Services.obs.removeObserver(this, "idle-daily");
} catch (e) {
// Attempt to remove unassociated observer which is possible when discovery stream
// is enabled and user never used activity stream experience
}
SectionsManager.disableSection(SECTION_ID);
}
getPocketState(target) {
const action = { type: at.POCKET_LOGGED_IN, data: pktApi.isUserLoggedIn() };
this.store.dispatch(ac.OnlyToOneContent(action, target));
}
dispatchPocketCta(data, shouldBroadcast) {
const action = { type: at.POCKET_CTA, data: JSON.parse(data) };
this.store.dispatch(
shouldBroadcast
? ac.BroadcastToContent(action)
: ac.AlsoToPreloaded(action)
);
}
/**
* doContentUpdate - Updates topics and stories in the topstories section.
*
* Sections have one update action for the whole section.
* Redux creates a state race condition if you call the same action,
* twice, concurrently. Because of this, doContentUpdate is
* one place to update both topics and stories in a single action.
*
* Section updates used old topics if none are available,
* but clear stories if none are available. Because of this, if no
* stories are passed, we instead use the existing stories in state.
*
* @param {Object} This is an object with potential new stories or topics.
* @param {Boolean} shouldBroadcast If we should update existing tabs or not. For first page
* loads or pref changes, we want to update existing tabs,
* for system tick or other updates we do not.
*/
doContentUpdate({ stories, topics }, shouldBroadcast) {
let updateProps = {};
if (stories) {
updateProps.rows = stories;
} else {
const { Sections } = this.store.getState();
if (Sections && Sections.find) {
updateProps.rows = Sections.find(s => s.id === SECTION_ID).rows;
}
}
if (topics) {
Object.assign(updateProps, {
topics,
read_more_endpoint: this.read_more_endpoint,
});
}
// We should only be calling this once per init.
this.dispatchUpdateEvent(shouldBroadcast, updateProps);
}
async onPersonalityProviderInit() {
const data = await this.cache.get();
let stories = data.stories && data.stories.recommendations;
this.stories = this.rotate(this.transform(stories));
this.doContentUpdate({ stories: this.stories }, false);
const affinities = this.affinityProvider.getAffinities();
this.domainAffinitiesLastUpdated = Date.now();
affinities._timestamp = this.domainAffinitiesLastUpdated;
this.cache.set("domainAffinities", affinities);
}
affinityProividerSwitcher(...args) {
const { affinityProviderV2 } = this;
if (affinityProviderV2 && affinityProviderV2.use_v2) {
const provider = this.PersonalityProvider(...args, {
modelKeys: affinityProviderV2.model_keys,
dispatch: this.store.dispatch,
});
provider.init(this.onPersonalityProviderInit.bind(this));
return provider;
}
const start = perfService.absNow();
const v1Provider = this.UserDomainAffinityProvider(...args);
this.store.dispatch(
ac.PerfEvent({
event: "topstories.domain.affinity.calculation.ms",
value: Math.round(perfService.absNow() - start),
})
);
return v1Provider;
}
PersonalityProvider(...args) {
return new PersonalityProvider(...args);
}
UserDomainAffinityProvider(...args) {
return new UserDomainAffinityProvider(...args);
}
async fetchStories() {
if (!this.stories_endpoint) {
return null;
}
try {
const response = await fetch(this.stories_endpoint, {
credentials: "omit",
});
if (!response.ok) {
throw new Error(
`Stories endpoint returned unexpected status: ${response.status}`
);
}
const body = await response.json();
this.updateSettings(body.settings);
this.stories = this.rotate(this.transform(body.recommendations));
this.cleanUpTopRecImpressionPref();
if (this.show_spocs && body.spocs) {
this.spocCampaignMap = new Map(
body.spocs.map(s => [s.id, `${s.campaign_id}`])
);
this.spocs = this.transform(body.spocs).filter(
s => s.score >= s.min_score
);
this.cleanUpCampaignImpressionPref();
}
this.storiesLastUpdated = Date.now();
body._timestamp = this.storiesLastUpdated;
this.cache.set("stories", body);
} catch (error) {
Cu.reportError(`Failed to fetch content: ${error.message}`);
}
return this.stories;
}
async loadCachedData() {
const data = await this.cache.get();
let stories = data.stories && data.stories.recommendations;
let topics = data.topics && data.topics.topics;
let affinities = data.domainAffinities;
if (this.personalized && affinities && affinities.scores) {
this.affinityProvider = this.affinityProividerSwitcher(
affinities.timeSegments,
affinities.parameterSets,
affinities.maxHistoryQueryResults,
affinities.version,
affinities.scores
);
this.domainAffinitiesLastUpdated = affinities._timestamp;
}
if (stories && !!stories.length && this.storiesLastUpdated === 0) {
this.updateSettings(data.stories.settings);
this.stories = this.rotate(this.transform(stories));
this.storiesLastUpdated = data.stories._timestamp;
if (data.stories.spocs && data.stories.spocs.length) {
this.spocCampaignMap = new Map(
data.stories.spocs.map(s => [s.id, `${s.campaign_id}`])
);
this.spocs = this.transform(data.stories.spocs).filter(
s => s.score >= s.min_score
);
this.cleanUpCampaignImpressionPref();
}
}
if (topics && !!topics.length && this.topicsLastUpdated === 0) {
this.topics = topics;
this.topicsLastUpdated = data.topics._timestamp;
}
return { topics: this.topics, stories: this.stories };
}
dispatchRelevanceScore(start) {
let event = "PERSONALIZATION_V1_ITEM_RELEVANCE_SCORE_DURATION";
let initialized = true;
if (!this.personalized) {
return;
}
const { affinityProviderV2 } = this;
if (affinityProviderV2 && affinityProviderV2.use_v2) {
if (this.affinityProvider) {
initialized = this.affinityProvider.initialized;
event = "PERSONALIZATION_V2_ITEM_RELEVANCE_SCORE_DURATION";
}
}
// If v2 is not yet initialized we don't bother tracking yet.
// Before it is initialized it doesn't do any ranking.
// Once it's initialized it ensures ranking is done.
// v1 doesn't have any initialized issues around ranking,
// and should be ready right away.
if (initialized) {
this.store.dispatch(
ac.PerfEvent({
event,
value: Math.round(perfService.absNow() - start),
})
);
}
}
transform(items) {
if (!items) {
return [];
}
const scoreStart = perfService.absNow();
const calcResult = items
.filter(s => !NewTabUtils.blockedLinks.isBlocked({ url: s.url }))
.map(s => {
let mapped = {
guid: s.id,
hostname: s.domain || shortURL(Object.assign({}, s, { url: s.url })),
type:
Date.now() - s.published_timestamp * 1000 <= STORIES_NOW_THRESHOLD
? "now"
: "trending",
context: s.context,
icon: s.icon,
title: s.title,
description: s.excerpt,
image: this.normalizeUrl(s.image_src),
referrer: this.stories_referrer,
url: s.url,
min_score: s.min_score || 0,
score:
this.personalized && this.affinityProvider
? this.affinityProvider.calculateItemRelevanceScore(s)
: s.item_score || 1,
spoc_meta: this.show_spocs
? { campaign_id: s.campaign_id, caps: s.caps }
: {},
};
// Very old cached spocs may not contain an `expiration_timestamp` property
if (s.expiration_timestamp) {
mapped.expiration_timestamp = s.expiration_timestamp;
}
return mapped;
})
.sort(this.personalized ? this.compareScore : (a, b) => 0);
this.dispatchRelevanceScore(scoreStart);
return calcResult;
}
async fetchTopics() {
if (!this.topics_endpoint) {
return null;
}
try {
const response = await fetch(this.topics_endpoint, {
credentials: "omit",
});
if (!response.ok) {
throw new Error(
`Topics endpoint returned unexpected status: ${response.status}`
);
}
const body = await response.json();
const { topics } = body;
if (topics) {
this.topics = topics;
this.topicsLastUpdated = Date.now();
body._timestamp = this.topicsLastUpdated;
this.cache.set("topics", body);
}
} catch (error) {
Cu.reportError(`Failed to fetch topics: ${error.message}`);
}
return this.topics;
}
dispatchUpdateEvent(shouldBroadcast, data) {
SectionsManager.updateSection(SECTION_ID, data, shouldBroadcast);
}
compareScore(a, b) {
return b.score - a.score;
}
updateSettings(settings) {
if (!this.personalized) {
return;
}
this.spocsPerNewTabs = settings.spocsPerNewTabs; // Probability of a new tab getting a spoc [0,1]
this.timeSegments = settings.timeSegments;
this.domainAffinityParameterSets = settings.domainAffinityParameterSets;
this.recsExpireTime = settings.recsExpireTime;
this.version = settings.version;
if (
this.affinityProvider &&
this.affinityProvider.version !== this.version
) {
this.resetDomainAffinityScores();
}
}
updateDomainAffinityScores() {
if (
!this.personalized ||
!this.domainAffinityParameterSets ||
Date.now() - this.domainAffinitiesLastUpdated <
MIN_DOMAIN_AFFINITIES_UPDATE_TIME
) {
return;
}
this.affinityProvider = this.affinityProividerSwitcher(
this.timeSegments,
this.domainAffinityParameterSets,
this.maxHistoryQueryResults,
this.version,
undefined
);
const affinities = this.affinityProvider.getAffinities();
this.domainAffinitiesLastUpdated = Date.now();
affinities._timestamp = this.domainAffinitiesLastUpdated;
this.cache.set("domainAffinities", affinities);
}
resetDomainAffinityScores() {
delete this.affinityProvider;
this.cache.set("domainAffinities", {});
}
// If personalization is turned on, we have to rotate stories on the client so that
// active stories are at the front of the list, followed by stories that have expired
// impressions i.e. have been displayed for longer than recsExpireTime.
rotate(items) {
if (!this.personalized || items.length <= 3) {
return items;
}
const maxImpressionAge = Math.max(
this.recsExpireTime * 1000 || DEFAULT_RECS_EXPIRE_TIME,
DEFAULT_RECS_EXPIRE_TIME
);
const impressions = this.readImpressionsPref(REC_IMPRESSION_TRACKING_PREF);
const expired = [];
const active = [];
for (const item of items) {
if (
impressions[item.guid] &&
Date.now() - impressions[item.guid] >= maxImpressionAge
) {
expired.push(item);
} else {
active.push(item);
}
}
return active.concat(expired);
}
getApiKeyFromPref(apiKeyPref) {
if (!apiKeyPref) {
return apiKeyPref;
}
return (
this._prefs.get(apiKeyPref) || Services.prefs.getCharPref(apiKeyPref)
);
}
produceFinalEndpointUrl(url, apiKey) {
if (!url) {
return url;
}
if (url.includes("$apiKey") && !apiKey) {
throw new Error(`An API key was specified but none configured: ${url}`);
}
return url.replace("$apiKey", apiKey);
}
// Need to remove parenthesis from image URLs as React will otherwise
// fail to render them properly as part of the card template.
normalizeUrl(url) {
if (url) {
return url.replace(/\(/g, "%28").replace(/\)/g, "%29");
}
return url;
}
shouldShowSpocs() {
return this.show_spocs && this.store.getState().Prefs.values.showSponsored;
}
dispatchSpocDone(target) {
const action = { type: at.POCKET_WAITING_FOR_SPOC, data: false };
this.store.dispatch(ac.OnlyToOneContent(action, target));
}
filterSpocs() {
if (!this.shouldShowSpocs()) {
return [];
}
if (Math.random() > this.spocsPerNewTabs) {
return [];
}
if (!this.spocs || !this.spocs.length) {
// We have stories but no spocs so there's nothing to do and this update can be
// removed from the queue.
return [];
}
// Filter spocs based on frequency caps
const impressions = this.readImpressionsPref(SPOC_IMPRESSION_TRACKING_PREF);
let spocs = this.spocs.filter(s =>
this.isBelowFrequencyCap(impressions, s)
);
// Filter out expired spocs based on `expiration_timestamp`
spocs = spocs.filter(spoc => {
// If cached data is so old it doesn't contain this property, assume the spoc is ok to show
if (!(`expiration_timestamp` in spoc)) {
return true;
}
// `expiration_timestamp` is the number of seconds elapsed since January 1, 1970 00:00:00 UTC
return spoc.expiration_timestamp * 1000 > Date.now();
});
return spocs;
}
maybeAddSpoc(target) {
const updateContent = () => {
let spocs = this.filterSpocs();
if (!spocs.length) {
this.dispatchSpocDone(target);
return false;
}
// Create a new array with a spoc inserted at index 2
const section = this.store
.getState()
.Sections.find(s => s.id === SECTION_ID);
let rows = section.rows.slice(0, this.stories.length);
rows.splice(2, 0, Object.assign(spocs[0], { pinned: true }));
// Send a content update to the target tab
const action = {
type: at.SECTION_UPDATE,
data: Object.assign({ rows }, { id: SECTION_ID }),
};
this.store.dispatch(ac.OnlyToOneContent(action, target));
this.dispatchSpocDone(target);
return false;
};
if (this.storiesLoaded) {
updateContent();
} else {
// Delay updating tab content until initial data has been fetched
this.contentUpdateQueue.push(updateContent);
}
}
// Frequency caps are based on campaigns, which may include multiple spocs.
// We currently support two types of frequency caps:
// - lifetime: Indicates how many times spocs from a campaign can be shown in total
// - period: Indicates how many times spocs from a campaign can be shown within a period
//
// So, for example, the feed configuration below defines that for campaign 1 no more
// than 5 spocs can be show in total, and no more than 2 per hour.
// "campaign_id": 1,
// "caps": {
// "lifetime": 5,
// "campaign": {
// "count": 2,
// "period": 3600
// }
// }
isBelowFrequencyCap(impressions, spoc) {
const campaignImpressions = impressions[spoc.spoc_meta.campaign_id];
if (!campaignImpressions) {
return true;
}
const lifeTimeCap = Math.min(
spoc.spoc_meta.caps && spoc.spoc_meta.caps.lifetime,
MAX_LIFETIME_CAP
);
const lifeTimeCapExceeded = campaignImpressions.length >= lifeTimeCap;
if (lifeTimeCapExceeded) {
return false;
}
const campaignCap =
(spoc.spoc_meta.caps && spoc.spoc_meta.caps.campaign) || {};
const campaignCapExceeded =
campaignImpressions.filter(
i => Date.now() - i < campaignCap.period * 1000
).length >= campaignCap.count;
return !campaignCapExceeded;
}
// Clean up campaign impression pref by removing all campaigns that are no
// longer part of the response, and are therefore considered inactive.
cleanUpCampaignImpressionPref() {
const campaignIds = new Set(this.spocCampaignMap.values());
this.cleanUpImpressionPref(
id => !campaignIds.has(id),
SPOC_IMPRESSION_TRACKING_PREF
);
}
// Clean up rec impression pref by removing all stories that are no
// longer part of the response.
cleanUpTopRecImpressionPref() {
const activeStories = new Set(this.stories.map(s => `${s.guid}`));
this.cleanUpImpressionPref(
id => !activeStories.has(id),
REC_IMPRESSION_TRACKING_PREF
);
}
/**
* Cleans up the provided impression pref (spocs or recs).
*
* @param isExpired predicate (boolean-valued function) that returns whether or not
* the impression for the given key is expired.
* @param pref the impression pref to clean up.
*/
cleanUpImpressionPref(isExpired, pref) {
const impressions = this.readImpressionsPref(pref);
let changed = false;
Object.keys(impressions).forEach(id => {
if (isExpired(id)) {
changed = true;
delete impressions[id];
}
});
if (changed) {
this.writeImpressionsPref(pref, impressions);
}
}
// Sets a pref mapping campaign IDs to timestamp arrays.
// The timestamps represent impressions which are used to calculate frequency caps.
recordCampaignImpression(campaignId) {
let impressions = this.readImpressionsPref(SPOC_IMPRESSION_TRACKING_PREF);
const timeStamps = impressions[campaignId] || [];
timeStamps.push(Date.now());
impressions = Object.assign(impressions, { [campaignId]: timeStamps });
this.writeImpressionsPref(SPOC_IMPRESSION_TRACKING_PREF, impressions);
}
// Sets a pref mapping story (rec) IDs to a single timestamp (time of first impression).
// We use these timestamps to guarantee a story doesn't stay on top for longer than
// configured in the feed settings (settings.recsExpireTime).
recordTopRecImpressions(topItems) {
let impressions = this.readImpressionsPref(REC_IMPRESSION_TRACKING_PREF);
let changed = false;
topItems.forEach(t => {
if (!impressions[t]) {
changed = true;
impressions = Object.assign(impressions, { [t]: Date.now() });
}
});
if (changed) {
this.writeImpressionsPref(REC_IMPRESSION_TRACKING_PREF, impressions);
}
}
readImpressionsPref(pref) {
const prefVal = this._prefs.get(pref);
return prefVal ? JSON.parse(prefVal) : {};
}
writeImpressionsPref(pref, impressions) {
this._prefs.set(pref, JSON.stringify(impressions));
}
async removeSpocs() {
// Quick hack so that SPOCS are removed from all open and preloaded tabs when
// they are disabled. The longer term fix should probably be to remove them
// in the Reducer.
await this.clearCache();
this.uninit();
this.init();
}
/**
* Decides if we need to change the personality provider version or not.
* Changes the version if it determines we need to.
*
* @param data {object} The top stories pref, we need version and model_keys
* @return {boolean} Returns true only if the version was changed.
*/
processAffinityProividerVersion(data) {
const version2 = data.version === 2 && !this.affinityProviderV2;
const version1 = data.version === 1 && this.affinityProviderV2;
if (version2 || version1) {
if (version1) {
this.affinityProviderV2 = null;
} else {
this.affinityProviderV2 = {
use_v2: true,
model_keys: data.model_keys,
};
}
return true;
}
return false;
}
lazyLoadTopStories(dsPref) {
let _dsPref = dsPref;
if (!_dsPref) {
_dsPref = this.store.getState().Prefs.values[DISCOVERY_STREAM_PREF];
}
try {
this.discoveryStreamEnabled =
JSON.parse(_dsPref).enabled &&
this.store.getState().Prefs.values[DISCOVERY_STREAM_PREF_ENABLED];
} catch (e) {
// Load activity stream top stories if fail to determine discovery stream state
this.discoveryStreamEnabled = false;
}
// Return without invoking initialization if top stories are loaded
if (this.storiesLoaded) {
return;
}
if (!this.discoveryStreamEnabled && !this.propertiesInitialized) {
this.initializeProperties();
}
this.init();
}
handleDisabled(action) {
switch (action.type) {
case at.INIT:
this.lazyLoadTopStories();
break;
case at.PREF_CHANGED:
if (action.data.name === DISCOVERY_STREAM_PREF) {
this.lazyLoadTopStories(action.data.value);
}
if (action.data.name === DISCOVERY_STREAM_PREF_ENABLED) {
this.lazyLoadTopStories();
}
break;
case at.UNINIT:
this.uninit();
break;
}
}
async onAction(action) {
if (this.discoveryStreamEnabled) {
this.handleDisabled(action);
return;
}
switch (action.type) {
// Check discoverystream pref and load activity stream top stories only if needed
case at.INIT:
this.lazyLoadTopStories();
break;
case at.SYSTEM_TICK:
let stories;
let topics;
if (Date.now() - this.storiesLastUpdated >= STORIES_UPDATE_TIME) {
stories = await this.fetchStories();
}
if (Date.now() - this.topicsLastUpdated >= TOPICS_UPDATE_TIME) {
topics = await this.fetchTopics();
}
this.doContentUpdate({ stories, topics }, false);
break;
case at.UNINIT:
this.uninit();
break;
case at.NEW_TAB_REHYDRATED:
this.getPocketState(action.meta.fromTarget);
this.maybeAddSpoc(action.meta.fromTarget);
break;
case at.SECTION_OPTIONS_CHANGED:
if (action.data === SECTION_ID) {
await this.clearCache();
this.uninit();
this.init();
}
break;
case at.PLACES_LINK_BLOCKED:
if (this.spocs) {
this.spocs = this.spocs.filter(s => s.url !== action.data.url);
}
break;
case at.PLACES_HISTORY_CLEARED:
if (this.personalized) {
this.resetDomainAffinityScores();
}
break;
case at.TELEMETRY_IMPRESSION_STATS: {
// We want to make sure we only track impressions from Top Stories,
// otherwise unexpected things that are not properly handled can happen.
// Example: Impressions from spocs on Discovery Stream can cause the
// Top Stories impressions pref to continuously grow, see bug #1523408
if (action.data.source === IMPRESSION_SOURCE) {
const payload = action.data;
const viewImpression = !(
"click" in payload ||
"block" in payload ||
"pocket" in payload
);
if (payload.tiles && viewImpression) {
if (this.shouldShowSpocs()) {
payload.tiles.forEach(t => {
if (this.spocCampaignMap.has(t.id)) {
this.recordCampaignImpression(this.spocCampaignMap.get(t.id));
}
});
}
if (this.personalized) {
const topRecs = payload.tiles
.filter(t => !this.spocCampaignMap.has(t.id))
.map(t => t.id);
this.recordTopRecImpressions(topRecs);
}
}
}
break;
}
case at.PREF_CHANGED:
if (action.data.name === DISCOVERY_STREAM_PREF) {
this.lazyLoadTopStories(action.data.value);
}
// Check if spocs was disabled. Remove them if they were.
if (action.data.name === "showSponsored" && !action.data.value) {
await this.removeSpocs();
}
if (action.data.name === "pocketCta") {
this.dispatchPocketCta(action.data.value, true);
}
if (action.data.name === OPTIONS_PREF) {
try {
const options = JSON.parse(action.data.value);
if (this.processAffinityProividerVersion(options)) {
await this.clearCache();
this.uninit();
this.init();
}
} catch (e) {
Cu.reportError(
`Problem initializing affinity provider v2: ${e.message}`
);
}
}
break;
}
}
};
this.STORIES_UPDATE_TIME = STORIES_UPDATE_TIME;
this.TOPICS_UPDATE_TIME = TOPICS_UPDATE_TIME;
this.SECTION_ID = SECTION_ID;
this.SPOC_IMPRESSION_TRACKING_PREF = SPOC_IMPRESSION_TRACKING_PREF;
this.REC_IMPRESSION_TRACKING_PREF = REC_IMPRESSION_TRACKING_PREF;
this.MIN_DOMAIN_AFFINITIES_UPDATE_TIME = MIN_DOMAIN_AFFINITIES_UPDATE_TIME;
this.DEFAULT_RECS_EXPIRE_TIME = DEFAULT_RECS_EXPIRE_TIME;
const EXPORTED_SYMBOLS = [
"TopStoriesFeed",
"STORIES_UPDATE_TIME",
"TOPICS_UPDATE_TIME",
"SECTION_ID",
"SPOC_IMPRESSION_TRACKING_PREF",
"MIN_DOMAIN_AFFINITIES_UPDATE_TIME",
"REC_IMPRESSION_TRACKING_PREF",
"DEFAULT_RECS_EXPIRE_TIME",
];
================================================
FILE: lib/UTEventReporting.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");
/**
* Note: the schema can be found in
* https://searchfox.org/mozilla-central/source/toolkit/components/telemetry/Events.yaml
*/
const EXTRAS_FIELD_NAMES = [
"addon_version",
"session_id",
"page",
"user_prefs",
"action_position",
];
this.UTEventReporting = class UTEventReporting {
constructor() {
Services.telemetry.setEventRecordingEnabled("activity_stream", true);
this.sendUserEvent = this.sendUserEvent.bind(this);
this.sendSessionEndEvent = this.sendSessionEndEvent.bind(this);
this.sendTrailheadEnrollEvent = this.sendTrailheadEnrollEvent.bind(this);
}
_createExtras(data) {
// Make a copy of the given data and delete/modify it as needed.
let utExtras = Object.assign({}, data);
for (let field of Object.keys(utExtras)) {
if (EXTRAS_FIELD_NAMES.includes(field)) {
utExtras[field] = String(utExtras[field]);
continue;
}
delete utExtras[field];
}
return utExtras;
}
sendUserEvent(data) {
let mainFields = ["event", "source"];
let eventFields = mainFields.map(field => String(data[field]) || null);
Services.telemetry.recordEvent(
"activity_stream",
"event",
...eventFields,
this._createExtras(data)
);
}
sendSessionEndEvent(data) {
Services.telemetry.recordEvent(
"activity_stream",
"end",
"session",
String(data.session_duration),
this._createExtras(data)
);
}
sendTrailheadEnrollEvent(data) {
Services.telemetry.recordEvent(
"activity_stream",
"enroll",
"preference_study",
data.experiment,
{
experimentType: data.type,
branch: data.branch,
}
);
}
uninit() {
Services.telemetry.setEventRecordingEnabled("activity_stream", false);
}
};
const EXPORTED_SYMBOLS = ["UTEventReporting"];
================================================
FILE: lib/UserDomainAffinityProvider.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");
ChromeUtils.defineModuleGetter(
this,
"PlacesUtils",
"resource://gre/modules/PlacesUtils.jsm"
);
const DEFAULT_TIME_SEGMENTS = [
{ id: "hour", startTime: 3600, endTime: 0, weightPosition: 1 },
{ id: "day", startTime: 86400, endTime: 3600, weightPosition: 0.75 },
{ id: "week", startTime: 604800, endTime: 86400, weightPosition: 0.5 },
{ id: "weekPlus", startTime: 0, endTime: 604800, weightPosition: 0.25 },
{ id: "alltime", startTime: 0, endTime: 0, weightPosition: 0.25 },
];
const DEFAULT_PARAMETER_SETS = {
"linear-frequency": {
recencyFactor: 0.4,
frequencyFactor: 0.5,
combinedDomainFactor: 0.5,
perfectFrequencyVisits: 10,
perfectCombinedDomainScore: 2,
multiDomainBoost: 0.1,
itemScoreFactor: 0,
},
};
const DEFAULT_MAX_HISTORY_QUERY_RESULTS = 1000;
function merge(...args) {
return Object.assign.apply(this, args);
}
/**
* Provides functionality to personalize content recommendations by calculating
* user domain affinity scores. These scores are used to calculate relevance
* scores for items/recs/stories that have domain affinities.
*
* The algorithm works as follows:
*
* - The recommendation endpoint returns a settings object containing
* timeSegments and parametersets.
*
* - For every time segment we calculate the corresponding domain visit counts,
* yielding result objects of the following structure: {"mozilla.org": 12,
* "mozilla.com": 34} (see UserDomainAffinityProvider#queryVisits)
*
* - These visit counts are transformed to domain affinity scores for all
* provided parameter sets: {"mozilla.org": {"paramSet1": 0.8,
* "paramSet2": 0.9}, "mozilla.org": {"paramSet1": 1, "paramSet2": 0.9}}
* (see UserDomainAffinityProvider#calculateScoresForParameterSets)
*
* - The parameter sets provide factors for weighting which allows for
* flexible targeting. The functionality to calculate final scores can
* be seen in UserDomainAffinityProvider#calculateScores
*
* - The user domain affinity scores are summed up across all time segments
* see UserDomainAffinityProvider#calculateAllUserDomainAffinityScores
*
* - An item's domain affinities are matched to the user's domain affinity
* scores by calculating an item relevance score
* (see UserDomainAffinityProvider#calculateItemRelevanceScore)
*
* - The item relevance scores are used to sort items (see TopStoriesFeed for
* more details)
*
* - The data structure was chosen to allow for fast cache lookups during
* relevance score calculation. While user domain affinities are calculated
* infrequently (i.e. only once a day), the item relevance score (potentially)
* needs to be calculated every time the feed updates. Therefore allowing cache
* lookups of scores[domain][parameterSet] is beneficial
*/
this.UserDomainAffinityProvider = class UserDomainAffinityProvider {
constructor(
timeSegments = DEFAULT_TIME_SEGMENTS,
parameterSets = DEFAULT_PARAMETER_SETS,
maxHistoryQueryResults = DEFAULT_MAX_HISTORY_QUERY_RESULTS,
version,
scores
) {
this.timeSegments = timeSegments;
this.maxHistoryQueryResults = maxHistoryQueryResults;
this.version = version;
if (scores) {
this.parameterSets = parameterSets;
this.scores = scores;
} else {
this.parameterSets = this.prepareParameterSets(parameterSets);
this.scores = this.calculateAllUserDomainAffinityScores();
}
}
/**
* Adds dynamic parameters to the given parameter sets that need to be
* computed based on time segments.
*
* @param ps The parameter sets
* @return Updated parameter sets with additional fields (i.e. timeSegmentWeights)
*/
prepareParameterSets(ps) {
return (
Object.keys(ps)
// Add timeSegmentWeight fields to param sets e.g. timeSegmentWeights: {"hour": 1, "day": 0.8915, ...}
.map(k => ({
[k]: merge(ps[k], {
timeSegmentWeights: this.calculateTimeSegmentWeights(
ps[k].recencyFactor
),
}),
}))
.reduce((acc, cur) => merge(acc, cur))
);
}
/**
* Calculates a time segment weight based on the provided recencyFactor.
*
* @param recencyFactor The recency factor indicating how to weigh recency
* @return An object containing time segment weights: {"hour": 0.987, "day": 1}
*/
calculateTimeSegmentWeights(recencyFactor) {
return this.timeSegments.reduce(
(acc, cur) =>
merge(acc, {
[cur.id]: this.calculateScore(cur.weightPosition, 1, recencyFactor),
}),
{}
);
}
/**
* Calculates user domain affinity scores based on browsing history and the
* available times segments and parameter sets.
*/
calculateAllUserDomainAffinityScores() {
return (
this.timeSegments
// Calculate parameter set specific domain scores for each time segment
// => [{"a.com": {"ps1": 12, "ps2": 34}, "b.com": {"ps1": 56, "ps2": 78}}, ...]
.map(ts => this.calculateUserDomainAffinityScores(ts))
// Keep format, but reduce to single object, with combined scores across all time segments
// => "{a.com":{"ps1":2,"ps2":2}, "b.com":{"ps1":3,"ps2":3}}""
.reduce((acc, cur) => this._combineScores(acc, cur))
);
}
/**
* Calculates the user domain affinity scores for the given time segment.
*
* @param ts The time segment
* @return The parameter specific scores for all domains with visits in
* this time segment: {"a.com": {"ps1": 12, "ps2": 34}, "b.com" ...}
*/
calculateUserDomainAffinityScores(ts) {
// Returns domains and visit counts for this time segment: {"a.com": 1, "b.com": 2}
let visits = this.queryVisits(ts);
return Object.keys(visits).reduce(
(acc, d) =>
merge(acc, {
[d]: this.calculateScoresForParameterSets(ts, visits[d]),
}),
{}
);
}
/**
* Calculates the scores for all parameter sets for the given time segment
* and domain visit count.
*
* @param ts The time segment
* @param vc The domain visit count in the given time segment
* @return The parameter specific scores for the visit count in
* this time segment: {"ps1": 12, "ps2": 34}
*/
calculateScoresForParameterSets(ts, vc) {
return Object.keys(this.parameterSets).reduce(
(acc, ps) =>
merge(acc, {
[ps]: this.calculateScoreForParameterSet(
ts,
vc,
this.parameterSets[ps]
),
}),
{}
);
}
/**
* Calculates the final affinity score in the given time segment for the given parameter set
*
* @param timeSegment The time segment
* @param visitCount The domain visit count in the given time segment
* @param parameterSet The parameter set to use for scoring
* @return The final score
*/
calculateScoreForParameterSet(timeSegment, visitCount, parameterSet) {
return this.calculateScore(
visitCount * parameterSet.timeSegmentWeights[timeSegment.id],
parameterSet.perfectFrequencyVisits,
parameterSet.frequencyFactor
);
}
/**
* Keeps the same format, but reduces the two objects to a single object, with
* combined scores across all time segments => {a.com":{"ps1":2,"ps2":2},
* "b.com":{"ps1":3,"ps2":3}}
*/
_combineScores(a, b) {
// Merge both score objects so we get a combined object holding all domains.
// This is so we can combine them without missing domains that are in a and not in b and vice versa.
const c = merge({}, a, b);
return Object.keys(c).reduce(
(acc, d) => merge(acc, this._combine(a, b, c, d)),
{}
);
}
_combine(a, b, c, d) {
return (
Object.keys(c[d])
// Summing up the parameter set specific scores of each domain
.map(ps => ({
[d]: {
[ps]: Math.min(
1,
((a[d] && a[d][ps]) || 0) + ((b[d] && b[d][ps]) || 0)
),
},
}))
// Reducing from an array of objects with a single parameter set to a single object
// [{"a.com":{"ps1":11}}, {"a.com: {"ps2":12}}] => {"a.com":{"ps1":11,"ps2":12}}
.reduce((acc, cur) => ({ [d]: merge(acc[d], cur[d]) }))
);
}
/**
* Calculates a value on the curve described by the provided parameters. The curve we're using is
* (a^(b*x) - 1) / (a^b - 1): https://www.desmos.com/calculator/maqhpttupp
*
* @param {number} score A value between 0 and maxScore, representing x.
* @param {number} maxScore Highest possible score.
* @param {number} factor The slope describing the curve to get to maxScore. A low slope value
* [0, 0.5] results in a log-shaped curve, a high slope [0.5, 1] results in a exp-shaped curve,
* a slope of exactly 0.5 is linear.
* @param {number} ease Adjusts how much bend is in the curve i.e. how dramatic the maximum
* effect of the slope can be. This represents b in the formula above.
* @return {number} the final score
*/
calculateScore(score, maxScore, factor, ease = 2) {
let a = 0;
let x = Math.max(0, score / maxScore);
if (x >= 1) {
return 1;
}
if (factor === 0.5) {
return x;
}
if (factor < 0.5) {
// We want a log-shaped curve so we scale "a" between 0 and .99
a = (factor / 0.5) * 0.49;
} else if (factor > 0.5) {
// We want an exp-shaped curve so we scale "a" between 1.01 and 10
a = 1 + ((factor - 0.5) / 0.5) * 9;
}
return (Math.pow(a, ease * x) - 1) / (Math.pow(a, ease) - 1);
}
/**
* Queries the visit counts in the given time segment.
*
* @param ts the time segment
* @return the visit count object: {"a.com": 1, "b.com": 2}
*/
queryVisits(ts) {
const visitCounts = {};
const query = PlacesUtils.history.getNewQuery();
const wwwRegEx = /^www\./;
query.beginTimeReference = query.TIME_RELATIVE_NOW;
query.beginTime =
ts.startTime && ts.startTime !== 0
? -(ts.startTime * 1000 * 1000)
: -(Date.now() * 1000);
query.endTimeReference = query.TIME_RELATIVE_NOW;
query.endTime =
ts.endTime && ts.endTime !== 0 ? -(ts.endTime * 1000 * 1000) : 0;
const options = PlacesUtils.history.getNewQueryOptions();
options.sortingMode = options.SORT_BY_VISITCOUNT_DESCENDING;
options.maxResults = this.maxHistoryQueryResults;
const { root } = PlacesUtils.history.executeQuery(query, options);
root.containerOpen = true;
for (let i = 0; i < root.childCount; i++) {
let node = root.getChild(i);
let host = Services.io.newURI(node.uri).host.replace(wwwRegEx, "");
if (!visitCounts[host]) {
visitCounts[host] = 0;
}
visitCounts[host] += node.accessCount;
}
root.containerOpen = false;
return visitCounts;
}
/**
* Calculates an item's relevance score.
*
* @param item the item (story), must contain domain affinities, otherwise a
* score of 1 is returned.
* @return the calculated item's score or 1 if item has no domain_affinities
* or references an unknown parameter set.
*/
calculateItemRelevanceScore(item) {
const params = this.parameterSets[item.parameter_set];
if (!item.domain_affinities || !params) {
return item.item_score;
}
const scores = Object.keys(item.domain_affinities).reduce(
(acc, d) => {
let userDomainAffinityScore = this.scores[d]
? this.scores[d][item.parameter_set]
: false;
if (userDomainAffinityScore) {
acc.combinedDomainScore +=
userDomainAffinityScore * item.domain_affinities[d];
acc.matchingDomainsCount++;
}
return acc;
},
{ combinedDomainScore: 0, matchingDomainsCount: 0 }
);
// Boost the score as configured in the provided parameter set
const boostedCombinedDomainScore =
scores.combinedDomainScore *
Math.pow(params.multiDomainBoost + 1, scores.matchingDomainsCount);
// Calculate what the score would be if the item score is ignored
const normalizedCombinedDomainScore = this.calculateScore(
boostedCombinedDomainScore,
params.perfectCombinedDomainScore,
params.combinedDomainFactor
);
// Calculate the final relevance score using the itemScoreFactor. The itemScoreFactor
// allows weighting the item score in relation to the normalizedCombinedDomainScore:
// An itemScoreFactor of 1 results in the item score and ignores the combined domain score
// An itemScoreFactor of 0.5 results in the the average of item score and combined domain score
// An itemScoreFactor of 0 results in the combined domain score and ignores the item score
return (
params.itemScoreFactor *
(item.item_score - normalizedCombinedDomainScore) +
normalizedCombinedDomainScore
);
}
/**
* Returns an object holding the settings and affinity scores of this provider instance.
*/
getAffinities() {
return {
timeSegments: this.timeSegments,
parameterSets: this.parameterSets,
maxHistoryQueryResults: this.maxHistoryQueryResults,
version: this.version,
scores: this.scores,
};
}
};
const EXPORTED_SYMBOLS = ["UserDomainAffinityProvider"];
================================================
FILE: loaders/inject-loader.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/. */
// Note: this is based on https://github.com/plasticine/inject-loader,
// patched to make istanbul work properly
const loaderUtils = require("loader-utils");
const QUOTE_REGEX_STRING = "['|\"]{1}";
const hasOnlyExcludeFlags = query =>
Object.keys(query).filter(key => query[key] === true).length === 0;
const escapePath = path => path.replace("/", "\\/");
function createRequireStringRegex(query) {
const regexArray = [];
// if there is no query then replace everything
if (Object.keys(query).length === 0) {
regexArray.push("([^\\)]+)");
} else if (hasOnlyExcludeFlags(query)) {
// if there are only negation matches in the query then replace everything
// except them
Object.keys(query).forEach(key =>
regexArray.push(`(?!${QUOTE_REGEX_STRING}${escapePath(key)})`)
);
regexArray.push("([^\\)]+)");
} else {
regexArray.push(`(${QUOTE_REGEX_STRING}(`);
regexArray.push(
Object.keys(query)
.map(key => escapePath(key))
.join("|")
);
regexArray.push(`)${QUOTE_REGEX_STRING})`);
}
// Wrap the regex to match `require()`
regexArray.unshift("require\\(");
regexArray.push("\\)");
return new RegExp(regexArray.join(""), "g");
}
module.exports = function inject(src) {
if (this.cacheable) {
this.cacheable();
}
const regex = createRequireStringRegex(loaderUtils.getOptions(this) || {});
return `module.exports = function inject(injections) {
var module = {exports: {}};
var exports = module.exports;
${src.replace(regex, "(injections[$1] || /* istanbul ignore next */ $&)")}
return module.exports;
}\n`;
};
================================================
FILE: mochitest.sh
================================================
#!/bin/bash
export SHELL=/bin/bash
export TASKCLUSTER_ROOT_URL="https://taskcluster.net"
# Display required for `browser_parsable_css` tests
export DISPLAY=:99.0
# Required to support the unicode in the output
export LC_ALL=C.UTF-8
/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
# Pull latest m-c and update tip
cd /mozilla-central && hg pull && hg update -C
# Build Activity Stream and copy the output to m-c
cd /activity-stream && npm install . && npm run buildmc
# Build latest m-c with Activity Stream changes
cd /mozilla-central && rm -rf ./objdir-frontend && ./mach build \
&& ./mach lint browser/components/newtab \
&& ./mach lint -l codespell browser/locales/en-US/browser/newtab \
&& ./mach test browser/components/newtab/test/browser --headless \
&& ./mach test browser/components/newtab/test/xpcshell \
&& ./mach test --log-tbpl test_run_log \
browser/base/content/test/about/browser_aboutHome_search_telemetry.js \
browser/base/content/test/static/browser_parsable_css.js \
browser/base/content/test/tabs/browser_new_tab_in_privileged_process_pref.js \
browser/components/enterprisepolicies/tests/browser/browser_policy_set_homepage.js \
browser/components/extensions/test/browser/browser_ext_topSites.js \
browser/components/preferences/in-content/tests/browser_hometab_restore_defaults.js \
browser/components/preferences/in-content/tests/browser_newtab_menu.js \
browser/components/preferences/in-content/tests/browser_search_subdialogs_within_preferences_1.js \
browser/components/search/test/browser/browser_google_behavior.js \
browser/modules/test/browser/browser_UsageTelemetry_content.js \
&& ! grep -q TEST-UNEXPECTED test_run_log \
&& RUN_FIND_DUPES=1 ./mach package \
&& ./mach test --appname=dist all_files_referenced --headless
================================================
FILE: 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/.
with Files("**"):
BUG_COMPONENT = ("Firefox", "New Tab Page")
BROWSER_CHROME_MANIFESTS += ['test/browser/browser.ini']
SPHINX_TREES['docs'] = 'docs'
XPCSHELL_TESTS_MANIFESTS += [
'test/xpcshell/xpcshell.ini',
]
XPIDL_SOURCES += [
'nsIAboutNewTabService.idl',
]
XPIDL_MODULE = 'browser-newtab'
EXTRA_JS_MODULES += [
'AboutNewTabService.jsm',
]
XPCOM_MANIFESTS += [
'components.conf',
]
JAR_MANIFESTS += ['jar.mn']
================================================
FILE: nsIAboutNewTabService.idl
================================================
/* 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/. */
#include "nsISupports.idl"
/**
* Allows to override about:newtab to point to a different location
* than the one specified within AboutRedirector.cpp
*/
[scriptable, uuid(dfcd2adc-7867-4d3a-ba70-17501f208142)]
interface nsIAboutNewTabService : nsISupports
{
/**
* Returns the url of the resource for the newtab page if not overridden,
* otherwise a string represenation of the new URL.
*/
attribute ACString newTabURL;
/**
* Returns the default URL (local or activity stream depending on pref)
*/
attribute ACString defaultURL;
/**
* Returns the about:welcome URL.
*/
attribute ACString welcomeURL;
/**
* Returns true if opening the New Tab page will notify the user of a change.
*/
attribute bool willNotifyUser;
/**
* Returns true if the default resource got overridden.
*/
readonly attribute bool overridden;
/**
* Returns true if the default resource is activity stream and isn't
* overridden
*/
readonly attribute bool activityStreamEnabled;
/**
* Returns true if the the debug pref for activity stream is true
*/
readonly attribute bool activityStreamDebug;
/**
* Resets to the default resource and also resets the
* overridden attribute to false.
*/
void resetNewTabURL();
/**
* Records a scalar metric for how long it takes to pain Top Sites, this will
* only record the first timestamp, all the subsequent calls will be ignored.
*/
void maybeRecordTopsitesPainted(in unsigned long long timestamp);
};
================================================
FILE: package.json
================================================
{
"name": "activity-streams",
"description": "A rich visual history feed and a reimagined home page make it easier than ever to find exactly what you're looking for in Firefox.\n\nLearn more about this Test Pilot experiment at https://testpilot.firefox.com/.",
"version": "1.14.3",
"author": "Mozilla (https://mozilla.org/)",
"bugs": {
"url": "https://github.com/mozilla/activity-stream/issues"
},
"dependencies": {
"fluent": "0.12.0",
"fluent-react": "0.8.4",
"react": "16.8.6",
"react-dom": "16.8.6",
"react-redux": "7.0.3",
"react-transition-group": "4.2.1",
"redux": "4.0.1",
"reselect": "4.0.0"
},
"devDependencies": {
"@babel/core": "7.4.5",
"@babel/plugin-proposal-async-generator-functions": "7.2.0",
"@babel/preset-react": "7.0.0",
"acorn": "6.1.1",
"babel-eslint": "10.0.3",
"babel-loader": "8.0.6",
"babel-plugin-jsm-to-commonjs": "0.5.0",
"babel-plugin-jsm-to-esmodules": "0.6.0",
"chai": "4.2.0",
"chai-json-schema": "1.5.1",
"cpx": "1.5.0",
"enzyme": "3.9.0",
"enzyme-adapter-react-16": "1.13.2",
"eslint": "6.2.2",
"eslint-config-prettier": "4.2.0",
"eslint-plugin-fetch-options": "0.0.5",
"eslint-plugin-html": "6.0.0",
"eslint-plugin-import": "2.17.3",
"eslint-plugin-jsx-a11y": "6.2.1",
"eslint-plugin-mozilla": "2.1.0",
"eslint-plugin-no-unsanitized": "3.0.2",
"eslint-plugin-prettier": "3.0.1",
"eslint-plugin-react": "7.13.0",
"eslint-plugin-react-hooks": "1.6.0",
"istanbul-instrumenter-loader": "3.0.1",
"joi-browser": "13.4.0",
"karma": "4.1.0",
"karma-chai": "0.1.0",
"karma-coverage-istanbul-reporter": "2.0.5",
"karma-firefox-launcher": "1.1.0",
"karma-json-reporter": "1.2.1",
"karma-mocha": "1.3.0",
"karma-mocha-reporter": "2.2.5",
"karma-sinon": "1.0.5",
"karma-sourcemap-loader": "0.3.7",
"karma-webpack": "3.0.5",
"loader-utils": "1.2.3",
"lodash": "4.17.14",
"minimist": "1.2.0",
"mocha": "6.1.4",
"mock-raf": "1.0.1",
"node-fetch": "2.6.0",
"node-sass": "4.12.0",
"npm-run-all": "4.1.5",
"prettier": "1.17.0",
"prop-types": "15.7.2",
"raw-loader": "2.0.0",
"react-test-renderer": "16.8.6",
"rimraf": "2.6.3",
"sass": "1.20.1",
"sass-lint": "1.13.1",
"shelljs": "0.8.3",
"sinon": "7.3.2",
"webpack": "4.32.2",
"webpack-cli": "3.3.2",
"yamscripts": "0.1.0"
},
"engines": {
"firefox": ">=45.0 <=*",
"//": "when changing node versions, also edit .travis.yml and .nvmrc",
"node": "8.*",
"npm": "6.9"
},
"homepage": "https://github.com/mozilla/activity-stream",
"keywords": [
"mozilla",
"firefox",
"activity-stream"
],
"license": "MPL-2.0",
"main": "bootstrap.js",
"repository": "mozilla/activity-stream",
"config": {
"mc_dir": "../mozilla-central"
},
"scripts": {
"mochitest": "(cd $npm_package_config_mc_dir && ./mach mochitest browser/components/newtab/test/browser --headless)",
"mochitest-debug": "(cd $npm_package_config_mc_dir && ./mach mochitest --jsdebugger browser/components/newtab/test/browser)",
"bundle": "npm-run-all bundle:*",
"bundle:webpack": "webpack --config webpack.system-addon.config.js",
"bundle:css": "node-sass content-src/styles -o css",
"bundle:html": "rimraf prerendered && node ./bin/render-activity-stream-html.js",
"buildmc": "npm-run-all buildmc:*",
"prebuildmc": "rimraf $npm_package_config_mc_dir/browser/components/newtab/",
"buildmc:bundle": "npm run bundle",
"buildmc:copy": "rsync --exclude-from .mcignore -a . $npm_package_config_mc_dir/browser/components/newtab/",
"buildmc:copyPingCentre": "cpx \"ping-centre/PingCentre.jsm\" $npm_package_config_mc_dir/browser/modules",
"builduplift": "npm-run-all builduplift:*",
"prebuilduplift": "npm run prebuildmc",
"builduplift:bundle": "npm run bundle",
"builduplift:copy": "npm run buildmc:copy",
"buildlibrary": "npm-run-all buildlibrary:*",
"buildlibrary:webpack": "webpack --config webpack.aboutlibrary.config.js",
"buildlibrary:css": "node-sass --source-map true --source-map-contents content-src/aboutlibrary -o aboutlibrary/content",
"buildlibrary:copy": "cpx \"aboutlibrary/**/{,.}*\" $npm_package_config_mc_dir/browser/components/library",
"startmc": "npm-run-all --parallel startmc:*",
"prestartmc": "npm run buildmc",
"startmc:copy": "cpx \"{{,.}*,!(node_modules)/**/{,.}*}\" $npm_package_config_mc_dir/browser/components/newtab/ -w",
"startmc:copyPingCentre": "npm run buildmc:copyPingCentre -- -w",
"startmc:watch": "npm run watchmc",
"watchmc": "npm-run-all --parallel watchmc:*",
"watchmc:webpack": "npm run bundle:webpack -- --env.development -w",
"watchmc:css": "npm run bundle:css && npm run bundle:css -- --source-map-embed --source-map-contents -w",
"importmc": "npm-run-all importmc:*",
"importmc:src": "rsync --exclude-from .mcignore -a $npm_package_config_mc_dir/browser/components/newtab/ .",
"testmc": "npm-run-all testmc:*",
"testmc:lint": "npm run lint",
"testmc:build": "npm run bundle:webpack",
"testmc:unit": "karma start karma.mc.config.js",
"tddmc": "karma start karma.mc.config.js --tdd",
"debugcoverage": "open logs/coverage/index.html",
"lint": "npm-run-all lint:*",
"lint:eslint-check": "eslint --cache --print-config AboutNewTabService.jsm | eslint-config-prettier-check",
"lint:eslint": "eslint --cache --ext=.js,.jsm,.jsx .",
"lint:sasslint": "sass-lint -v -q",
"test": "npm run testmc",
"tdd": "npm run tddmc",
"vendor": "npm-run-all vendor:*",
"vendor:react": "node ./bin/vendor-react.js",
"fix": "npm-run-all fix:*",
"fix:eslint": "npm run lint:eslint -- --fix",
"help": "yamscripts help",
"yamscripts": "yamscripts compile",
"__": "# NOTE: THESE SCRIPTS ARE COMPILED!!! EDIT yamscripts.yml instead!!!"
},
"title": "Activity Stream",
"permissions": {
"multiprocess": true,
"private-browsing": true
}
}
================================================
FILE: ping-centre/PingCentre.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/. */
const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
ChromeUtils.defineModuleGetter(
this,
"AppConstants",
"resource://gre/modules/AppConstants.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"UpdateUtils",
"resource://gre/modules/UpdateUtils.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"TelemetryEnvironment",
"resource://gre/modules/TelemetryEnvironment.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"ServiceRequest",
"resource://gre/modules/ServiceRequest.jsm"
);
const PREF_BRANCH = "browser.ping-centre.";
const TELEMETRY_PREF = `${PREF_BRANCH}telemetry`;
const LOGGING_PREF = `${PREF_BRANCH}log`;
const STRUCTURED_INGESTION_SEND_TIMEOUT = 30 * 1000; // 30 seconds
const FHR_UPLOAD_ENABLED_PREF = "datareporting.healthreport.uploadEnabled";
const BROWSER_SEARCH_REGION_PREF = "browser.search.region";
// Only report region for following regions, to ensure that users in countries
// with small user population (less than 10000) cannot be uniquely identified.
// See bug 1421422 for more details.
const REGION_WHITELIST = new Set([
"AE",
"AF",
"AL",
"AM",
"AR",
"AT",
"AU",
"AZ",
"BA",
"BD",
"BE",
"BF",
"BG",
"BJ",
"BO",
"BR",
"BY",
"CA",
"CH",
"CI",
"CL",
"CM",
"CN",
"CO",
"CR",
"CU",
"CY",
"CZ",
"DE",
"DK",
"DO",
"DZ",
"EC",
"EE",
"EG",
"ES",
"ET",
"FI",
"FR",
"GB",
"GE",
"GH",
"GP",
"GR",
"GT",
"HK",
"HN",
"HR",
"HU",
"ID",
"IE",
"IL",
"IN",
"IQ",
"IR",
"IS",
"IT",
"JM",
"JO",
"JP",
"KE",
"KH",
"KR",
"KW",
"KZ",
"LB",
"LK",
"LT",
"LU",
"LV",
"LY",
"MA",
"MD",
"ME",
"MG",
"MK",
"ML",
"MM",
"MN",
"MQ",
"MT",
"MU",
"MX",
"MY",
"MZ",
"NC",
"NG",
"NI",
"NL",
"NO",
"NP",
"NZ",
"OM",
"PA",
"PE",
"PH",
"PK",
"PL",
"PR",
"PS",
"PT",
"PY",
"QA",
"RE",
"RO",
"RS",
"RU",
"RW",
"SA",
"SD",
"SE",
"SG",
"SI",
"SK",
"SN",
"SV",
"SY",
"TG",
"TH",
"TN",
"TR",
"TT",
"TW",
"TZ",
"UA",
"UG",
"US",
"UY",
"UZ",
"VE",
"VN",
"ZA",
"ZM",
"ZW",
]);
/**
* Observe various notifications and send them to a telemetry endpoint.
*
* @param {Object} options
* @param {string} options.topic - a unique ID for users of PingCentre to distinguish
* their data on the server side.
*/
class PingCentre {
constructor(options) {
if (!options.topic) {
throw new Error("Must specify topic.");
}
this._topic = options.topic;
this._prefs = Services.prefs.getBranch("");
this._enabled = this._prefs.getBoolPref(TELEMETRY_PREF);
this._onTelemetryPrefChange = this._onTelemetryPrefChange.bind(this);
this._prefs.addObserver(TELEMETRY_PREF, this._onTelemetryPrefChange);
this._fhrEnabled = this._prefs.getBoolPref(FHR_UPLOAD_ENABLED_PREF);
this._onFhrPrefChange = this._onFhrPrefChange.bind(this);
this._prefs.addObserver(FHR_UPLOAD_ENABLED_PREF, this._onFhrPrefChange);
this.logging = this._prefs.getBoolPref(LOGGING_PREF);
this._onLoggingPrefChange = this._onLoggingPrefChange.bind(this);
this._prefs.addObserver(LOGGING_PREF, this._onLoggingPrefChange);
}
get enabled() {
return this._enabled && this._fhrEnabled;
}
_onLoggingPrefChange(aSubject, aTopic, prefKey) {
this.logging = this._prefs.getBoolPref(prefKey);
}
_onTelemetryPrefChange(aSubject, aTopic, prefKey) {
this._enabled = this._prefs.getBoolPref(prefKey);
}
_onFhrPrefChange(aSubject, aTopic, prefKey) {
this._fhrEnabled = this._prefs.getBoolPref(prefKey);
}
_createExperimentsString(activeExperiments, filter) {
let experimentsString = "";
for (let experimentID in activeExperiments) {
if (
!activeExperiments[experimentID] ||
!activeExperiments[experimentID].branch ||
(filter && !experimentID.includes(filter))
) {
continue;
}
let expString = `${experimentID}:${
activeExperiments[experimentID].branch
}`;
experimentsString = experimentsString.concat(`${expString};`);
}
return experimentsString;
}
_getRegion() {
let region = "UNSET";
if (Services.prefs.prefHasUserValue(BROWSER_SEARCH_REGION_PREF)) {
region = Services.prefs.getStringPref(BROWSER_SEARCH_REGION_PREF);
if (region === "") {
region = "EMPTY";
} else if (!REGION_WHITELIST.has(region)) {
region = "OTHER";
}
}
return region;
}
_createStructuredIngestionPing(data, options = {}) {
let { filter } = options;
let experiments = TelemetryEnvironment.getActiveExperiments();
let experimentsString = this._createExperimentsString(experiments, filter);
let locale = data.locale || Services.locale.appLocaleAsLangTag;
const payload = Object.assign(
{
locale,
version: AppConstants.MOZ_APP_VERSION,
release_channel: UpdateUtils.getUpdateChannel(false),
},
data
);
if (experimentsString) {
payload.shield_id = experimentsString;
}
return payload;
}
static _gzipCompressString(string) {
let observer = {
buffer: "",
onStreamComplete(loader, context, status, length, result) {
this.buffer = String.fromCharCode(...result);
},
};
let scs = Cc["@mozilla.org/streamConverters;1"].getService(
Ci.nsIStreamConverterService
);
let listener = Cc["@mozilla.org/network/stream-loader;1"].createInstance(
Ci.nsIStreamLoader
);
listener.init(observer);
let converter = scs.asyncConvertData(
"uncompressed",
"gzip",
listener,
null
);
let stringStream = Cc[
"@mozilla.org/io/string-input-stream;1"
].createInstance(Ci.nsIStringInputStream);
stringStream.data = string;
converter.onStartRequest(null, null);
converter.onDataAvailable(null, stringStream, 0, string.length);
converter.onStopRequest(null, null, null);
return observer.buffer;
}
static _sendInGzip(endpoint, payload) {
return new Promise((resolve, reject) => {
let request = new ServiceRequest({ mozAnon: true });
request.mozBackgroundRequest = true;
request.timeout = STRUCTURED_INGESTION_SEND_TIMEOUT;
request.open("POST", endpoint, true);
request.overrideMimeType("text/plain");
request.setRequestHeader(
"Content-Type",
"application/json; charset=UTF-8"
);
request.setRequestHeader("Content-Encoding", "gzip");
request.setRequestHeader("Date", new Date().toUTCString());
request.onload = event => {
if (request.status !== 200) {
reject(event);
} else {
resolve(event);
}
};
request.onerror = reject;
request.onabort = reject;
request.ontimeout = reject;
let payloadStream = Cc[
"@mozilla.org/io/string-input-stream;1"
].createInstance(Ci.nsIStringInputStream);
payloadStream.data = PingCentre._gzipCompressString(payload);
request.sendInputStream(payloadStream);
});
}
/**
* Sends a ping to the Structured Ingestion telemetry pipeline.
*
* The payload would be compressed using gzip.
*
* @param {Object} data The payload to be sent.
* @param {String} endpoint The destination endpoint. Note that Structured Ingestion
* requires a different endpoint for each ping. It's up to the
* caller to provide that. See more details at
* https://github.com/mozilla/gcp-ingestion/blob/master/docs/edge.md#postput-request
* @param {Object} options Other options for this ping.
*/
sendStructuredIngestionPing(data, endpoint, options) {
if (!this.enabled) {
return Promise.resolve();
}
const ping = this._createStructuredIngestionPing(data, options);
const payload = JSON.stringify(ping);
if (this.logging) {
Services.console.logStringMessage(
`TELEMETRY PING (STRUCTURED INGESTION): ${payload}\n`
);
}
return PingCentre._sendInGzip(endpoint, payload).catch(event => {
Cu.reportError(
`Structured Ingestion ping failure with error: ${event.type}`
);
});
}
uninit() {
try {
this._prefs.removeObserver(TELEMETRY_PREF, this._onTelemetryPrefChange);
this._prefs.removeObserver(LOGGING_PREF, this._onLoggingPrefChange);
this._prefs.removeObserver(
FHR_UPLOAD_ENABLED_PREF,
this._onFhrPrefChange
);
} catch (e) {
Cu.reportError(e);
}
}
}
this.PingCentre = PingCentre;
this.PingCentreConstants = {
FHR_UPLOAD_ENABLED_PREF,
TELEMETRY_PREF,
LOGGING_PREF,
};
const EXPORTED_SYMBOLS = ["PingCentre", "PingCentreConstants"];
================================================
FILE: test/.eslintrc.js
================================================
module.exports = {
"env": {
"mocha": true
},
"globals": {
"assert": true,
"chai": true,
"sinon": true
},
"rules": {
"func-name-matching": 0,
"import/no-commonjs": 2,
"lines-between-class-members": 0,
"react/jsx-no-bind": 0,
"require-await": 0
}
};
================================================
FILE: test/browser/blue_page.html
================================================
================================================
FILE: test/browser/browser.ini
================================================
[DEFAULT]
support-files =
blue_page.html
red_page.html
head.js
prefs =
browser.newtabpage.activity-stream.debug=false
browser.newtabpage.activity-stream.discoverystream.enabled=true
browser.newtabpage.activity-stream.discoverystream.endpoints=data:
browser.newtabpage.activity-stream.feeds.section.topstories=true
browser.newtabpage.activity-stream.feeds.section.topstories.options={"provider_name":""}
[browser_aboutwelcome.js]
[browser_as_load_location.js]
[browser_as_render.js]
[browser_asrouter_snippets.js]
[browser_asrouter_targeting.js]
[browser_asrouter_trigger_listeners.js]
[browser_discovery_render.js]
[browser_discovery_styles.js]
[browser_enabled_newtabpage.js]
[browser_highlights_section.js]
[browser_getScreenshots.js]
[browser_newtab_overrides.js]
[browser_onboarding_rtamo.js]
skip-if = (os == "linux") # Test setup only implemented for OSX and Windows
[browser_topsites_contextMenu_options.js]
[browser_topsites_section.js]
[browser_asrouter_cfr.js]
[browser_asrouter_bookmarkpanel.js]
[browser_asrouter_toolbarbadge.js]
[browser_asrouter_whatsnewpanel.js]
================================================
FILE: test/browser/browser_aboutwelcome.js
================================================
"use strict";
const { ASRouter } = ChromeUtils.import(
"resource://activity-stream/lib/ASRouter.jsm"
);
const BRANCH_PREF = "trailhead.firstrun.branches";
/**
* Sets the trailhead branch pref to the passed value.
*/
async function setTrailheadBranch(value) {
Services.prefs.setCharPref(BRANCH_PREF, value);
// Reset trailhead so it loads the new branch.
Services.prefs.clearUserPref("trailhead.firstrun.didSeeAboutWelcome");
await ASRouter.setState({ trailheadInitialized: false });
registerCleanupFunction(() => {
Services.prefs.clearUserPref(BRANCH_PREF);
});
}
/**
* Test a specific trailhead branch.
*/
async function test_trailhead_branch(
branchName,
expectedSelectors = [],
unexpectedSelectors = []
) {
await setTrailheadBranch(branchName);
let tab = await BrowserTestUtils.openNewForegroundTab(
gBrowser,
"about:welcome",
false
);
let browser = tab.linkedBrowser;
await ContentTask.spawn(
browser,
{ expectedSelectors, branchName, unexpectedSelectors },
async ({
expectedSelectors: expected,
branchName: branch,
unexpectedSelectors: unexpected,
}) => {
for (let selector of expected) {
await ContentTaskUtils.waitForCondition(
() => content.document.querySelector(selector),
`Should render ${selector} in the ${branch} branch`
);
}
for (let selector of unexpected) {
ok(
!content.document.querySelector(selector),
`Should not render ${selector} in the ${branch} branch`
);
}
}
);
BrowserTestUtils.removeTab(tab);
}
/**
* Test the the various trailhead branches.
*/
add_task(async function test_trailhead_branches() {
await test_trailhead_branch(
"join-dynamic",
// Expected selectors:
[
".trailhead.joinCohort",
"button[data-l10n-id=onboarding-data-sync-button2]",
"button[data-l10n-id=onboarding-firefox-monitor-button]",
"button[data-l10n-id=onboarding-browse-privately-button]",
]
);
// Validate sync card is not shown if user usesFirefoxSync
await pushPrefs(["services.sync.username", "someone@foo.com"]);
await test_trailhead_branch(
"join-dynamic",
// Expected selectors:
[
".trailhead.joinCohort",
"button[data-l10n-id=onboarding-firefox-monitor-button]",
"button[data-l10n-id=onboarding-browse-privately-button]",
],
// Unexpected selectors:
["button[data-l10n-id=onboarding-data-sync-button2]"]
);
// Validate multidevice card is not shown if user has mobile devices connected
await pushPrefs(["services.sync.clients.devices.mobile", 1]);
await test_trailhead_branch(
"join-dynamic",
// Expected selectors:
[
".trailhead.joinCohort",
"button[data-l10n-id=onboarding-firefox-monitor-button]",
],
// Unexpected selectors:
["button[data-l10n-id=onboarding-mobile-phone-button"]
);
await test_trailhead_branch(
"sync-supercharge",
// Expected selectors:
[
".trailhead.syncCohort",
"button[data-l10n-id=onboarding-data-sync-button2]",
"button[data-l10n-id=onboarding-firefox-monitor-button]",
"button[data-l10n-id=onboarding-mobile-phone-button]",
]
);
await test_trailhead_branch(
"modal_variant_a-supercharge",
// Expected selectors:
[
".trailhead.joinCohort",
"p[data-l10n-id=onboarding-benefit-sync-text]",
"p[data-l10n-id=onboarding-benefit-monitor-text]",
"p[data-l10n-id=onboarding-benefit-lockwise-text]",
]
);
await test_trailhead_branch(
"modal_variant_f-supercharge",
// Expected selectors:
[
".trailhead.joinCohort",
"h3[data-l10n-id=onboarding-welcome-form-header]",
"p[data-l10n-id=onboarding-benefit-products-text]",
"p[data-l10n-id=onboarding-benefit-knowledge-text]",
"p[data-l10n-id=onboarding-benefit-privacy-text]",
]
);
await test_trailhead_branch(
"full_page_d-supercharge",
// Expected selectors:
[
".trailhead-fullpage",
".trailheadCard",
"p[data-l10n-id=onboarding-benefit-products-text]",
"button[data-l10n-id=onboarding-join-form-continue]",
"button[data-l10n-id=onboarding-join-form-signin]",
]
);
await test_trailhead_branch(
"full_page_e-supercharge",
// Expected selectors:
[
".fullPageCardsAtTop",
".trailhead-fullpage",
".trailheadCard",
"p[data-l10n-id=onboarding-benefit-products-text]",
"button[data-l10n-id=onboarding-join-form-continue]",
"button[data-l10n-id=onboarding-join-form-signin]",
]
);
await test_trailhead_branch(
"nofirstrun",
[],
// Unexpected selectors:
["#trailheadDialog", ".trailheadCards"]
);
});
================================================
FILE: test/browser/browser_as_load_location.js
================================================
"use strict";
/**
* Helper to test that a newtab page loads its html document.
*
* @param selector {String} CSS selector to find an element in newtab content
* @param message {String} Description of the test printed with the assertion
*/
async function checkNewtabLoads(selector, message) {
// simulate a newtab open as a user would
BrowserOpenTab();
// wait until the browser loads
let browser = gBrowser.selectedBrowser;
await waitForPreloaded(browser);
// check what the content task thinks has been loaded.
let found = await ContentTask.spawn(
browser,
selector,
arg => content.document.querySelector(arg) !== null
);
ok(found, message);
// avoid leakage
BrowserTestUtils.removeTab(gBrowser.selectedTab);
}
// Test with activity stream on
async function checkActivityStreamLoads() {
await checkNewtabLoads(
"body.activity-stream",
"Got Element"
);
}
// Run a first time not from a preloaded browser
add_task(async function checkActivityStreamNotPreloadedLoad() {
NewTabPagePreloading.removePreloadedBrowser(window);
await checkActivityStreamLoads();
});
// Run a second time from a preloaded browser
add_task(checkActivityStreamLoads);
================================================
FILE: test/browser/browser_as_render.js
================================================
"use strict";
test_newtab({
async before({ pushPrefs }) {
await pushPrefs([
"browser.newtabpage.activity-stream.improvesearch.handoffToAwesomebar",
false,
]);
},
test: function test_render_search() {
let search = content.document.getElementById("newtab-search-text");
ok(search, "Got the search box");
isnot(
search.placeholder,
"search_web_placeholder",
"Search box is localized"
);
},
});
test_newtab({
async before({ pushPrefs }) {
await pushPrefs([
"browser.newtabpage.activity-stream.improvesearch.handoffToAwesomebar",
true,
]);
},
test: function test_render_search_handoff() {
let search = content.document.querySelector(".search-handoff-button");
ok(search, "Got the search handoff button");
},
});
test_newtab(function test_render_topsites() {
let topSites = content.document.querySelector(".top-sites-list");
ok(topSites, "Got the top sites section");
});
test_newtab({
async before({ pushPrefs }) {
await pushPrefs([
"browser.newtabpage.activity-stream.feeds.topsites",
false,
]);
},
test: function test_render_no_topsites() {
let topSites = content.document.querySelector(".top-sites-list");
ok(!topSites, "No top sites section");
},
});
// This next test runs immediately after test_render_no_topsites to make sure
// the topsites pref is restored
test_newtab(function test_render_topsites_again() {
let topSites = content.document.querySelector(".top-sites-list");
ok(topSites, "Got the top sites section again");
});
================================================
FILE: test/browser/browser_asrouter_bookmarkpanel.js
================================================
const { PanelTestProvider } = ChromeUtils.import(
"resource://activity-stream/lib/PanelTestProvider.jsm"
);
const { BookmarkPanelHub } = ChromeUtils.import(
"resource://activity-stream/lib/BookmarkPanelHub.jsm"
);
add_task(async function test_fxa_message_shown() {
const tab = await BrowserTestUtils.openNewForegroundTab(gBrowser);
registerCleanupFunction(async () => {
await clearHistoryAndBookmarks();
BrowserTestUtils.removeTab(tab);
});
const testURL = "data:text/plain,test cfr fxa bookmark panel message";
const browser = gBrowser.selectedBrowser;
BrowserTestUtils.loadURI(browser, testURL);
await BrowserTestUtils.browserLoaded(browser, false, testURL);
const [msg] = PanelTestProvider.getMessages();
const response = BookmarkPanelHub.onResponse(
msg,
{
container: document.getElementById("editBookmarkPanelRecommendation"),
infoButton: document.getElementById("editBookmarkPanelInfoButton"),
recommendationContainer: document.getElementById(
"editBookmarkPanelRecommendation"
),
url: testURL,
document,
},
window
);
Assert.ok(response, "We sent a valid message");
const popupShownPromise = BrowserTestUtils.waitForEvent(
StarUI.panel,
"popupshown"
);
// Wait for the bookmark panel state to settle and be ready to open the panel
await BrowserTestUtils.waitForCondition(
() => BookmarkingUI.status !== BookmarkingUI.STATUS_UPDATING
);
BookmarkingUI.star.click();
await popupShownPromise;
await BrowserTestUtils.waitForCondition(
() => document.getElementById("cfrMessageContainer"),
`Should create a
container for the message`
);
for (const selector of [
"#cfrClose",
"#editBookmarkPanelRecommendationTitle",
"#editBookmarkPanelRecommendationContent",
"#editBookmarkPanelRecommendationCta",
]) {
Assert.ok(
document.getElementById("cfrMessageContainer").querySelector(selector),
`Should contain ${selector}`
);
}
const ftlFiles = Array.from(document.querySelectorAll("link")).filter(
l =>
l.getAttribute("href") === "browser/newtab/asrouter.ftl" ||
l.getAttribute("href") === "browser/branding/sync-brand.ftl"
);
Assert.equal(
ftlFiles.length,
2,
"Two fluent files required for translating the message"
);
const popupHiddenPromise = BrowserTestUtils.waitForEvent(
StarUI.panel,
"popuphidden"
);
let removeButton = document.getElementById("editBookmarkPanelRemoveButton");
removeButton.click();
await popupHiddenPromise;
});
================================================
FILE: test/browser/browser_asrouter_cfr.js
================================================
const { CFRPageActions } = ChromeUtils.import(
"resource://activity-stream/lib/CFRPageActions.jsm"
);
const { ASRouterTriggerListeners } = ChromeUtils.import(
"resource://activity-stream/lib/ASRouterTriggerListeners.jsm"
);
const { ASRouter } = ChromeUtils.import(
"resource://activity-stream/lib/ASRouter.jsm"
);
const createDummyRecommendation = ({
action,
category,
heading_text,
layout,
skip_address_bar_notifier,
template,
}) => {
let recommendation = {
template,
content: {
layout: layout || "addon_recommendation",
category,
anchor_id: "page-action-buttons",
skip_address_bar_notifier,
heading_text: heading_text || "Mochitest",
info_icon: {
label: { attributes: { tooltiptext: "Why am I seeing this" } },
sumo_path: "extensionrecommendations",
},
icon: "foo",
icon_dark_theme: "bar",
learn_more: "extensionrecommendations",
addon: {
id: "addon-id",
title: "Addon name",
icon: "foo",
author: "Author name",
amo_url: "https://example.com",
},
descriptionDetails: { steps: [] },
text: "Mochitest",
buttons: {
primary: {
label: {
value: "OK",
attributes: { accesskey: "O" },
},
action: {
type: action.type,
data: {},
},
},
secondary: [
{
label: {
value: "Cancel",
attributes: { accesskey: "C" },
},
},
{
label: {
value: "Cancel 1",
attributes: { accesskey: "A" },
},
},
{
label: {
value: "Cancel 2",
attributes: { accesskey: "B" },
},
},
],
},
},
};
recommendation.content.notification_text = new String("Mochitest"); // eslint-disable-line
recommendation.content.notification_text.attributes = {
tooltiptext: "Mochitest tooltip",
"a11y-announcement": "Mochitest announcement",
};
return recommendation;
};
function checkCFRFeaturesElements(notification) {
Assert.ok(notification.hidden === false, "Panel should be visible");
Assert.equal(
notification.getAttribute("data-notification-category"),
"message_and_animation",
"Panel have correct data attribute"
);
Assert.ok(
notification.querySelector(
"#cfr-notification-footer-pintab-animation-container"
),
"Pin tab animation exists"
);
Assert.ok(
notification.querySelector("#cfr-notification-feature-steps"),
"Pin tab steps"
);
}
function checkCFRAddonsElements(notification) {
Assert.ok(notification.hidden === false, "Panel should be visible");
Assert.equal(
notification.getAttribute("data-notification-category"),
"addon_recommendation",
"Panel have correct data attribute"
);
Assert.ok(
notification.querySelector("#cfr-notification-footer-text-and-addon-info"),
"Panel should have addon info container"
);
Assert.ok(
notification.querySelector("#cfr-notification-footer-filled-stars"),
"Panel should have addon rating info"
);
Assert.ok(
notification.querySelector("#cfr-notification-author"),
"Panel should have author info"
);
}
function checkCFRSocialTrackingProtection(notification) {
Assert.ok(notification.hidden === false, "Panel should be visible");
Assert.ok(
notification.getAttribute("data-notification-category") ===
"icon_and_message",
"Panel have corret data attribute"
);
Assert.ok(
notification.querySelector("#cfr-notification-footer-learn-more-link"),
"Panel should have learn more link"
);
}
function checkCFRTrackingProtectionMilestone(notification) {
Assert.ok(notification.hidden === false, "Panel should be visible");
Assert.ok(
notification.getAttribute("data-notification-category") === "short_message",
"Panel have correct data attribute"
);
}
function clearNotifications() {
for (let notification of PopupNotifications._currentNotifications) {
notification.remove();
}
// Clicking the primary action also removes the notification
Assert.equal(
PopupNotifications._currentNotifications.length,
0,
"Should have removed the notification"
);
}
function trigger_cfr_panel(
browser,
trigger,
{
action = { type: "FOO" },
heading_text,
category = "cfrAddons",
layout,
skip_address_bar_notifier = false,
use_single_secondary_button = false,
template = "cfr_doorhanger",
} = {}
) {
// a fake action type will result in the action being ignored
const recommendation = createDummyRecommendation({
action,
category,
heading_text,
layout,
skip_address_bar_notifier,
template,
});
if (category !== "cfrAddons") {
delete recommendation.content.addon;
}
if (use_single_secondary_button) {
recommendation.content.buttons.secondary = [
recommendation.content.buttons.secondary[0],
];
}
clearNotifications();
if (recommendation.template === "milestone_message") {
return CFRPageActions.showMilestone(
browser,
recommendation,
// Use the real AS dispatch method to trigger real notifications
ASRouter.dispatch
);
}
return CFRPageActions.addRecommendation(
browser,
trigger,
recommendation,
// Use the real AS dispatch method to trigger real notifications
ASRouter.dispatch
);
}
add_task(async function setup() {
// Store it in order to restore to the original value
const { _fetchLatestAddonVersion } = CFRPageActions;
// Prevent fetching the real addon url and making a network request
CFRPageActions._fetchLatestAddonVersion = x => "http://example.com";
registerCleanupFunction(() => {
CFRPageActions._fetchLatestAddonVersion = _fetchLatestAddonVersion;
});
});
add_task(async function test_cfr_notification_show() {
// addRecommendation checks that scheme starts with http and host matches
let browser = gBrowser.selectedBrowser;
await BrowserTestUtils.loadURI(browser, "http://example.com/");
await BrowserTestUtils.browserLoaded(browser, false, "http://example.com/");
const response = await trigger_cfr_panel(browser, "example.com");
Assert.ok(
response,
"Should return true if addRecommendation checks were successful"
);
const oldFocus = document.activeElement;
const showPanel = BrowserTestUtils.waitForEvent(
PopupNotifications.panel,
"popupshown"
);
// Open the panel
document.getElementById("contextual-feature-recommendation").click();
await showPanel;
Assert.ok(
document.getElementById("contextual-feature-recommendation-notification")
.hidden === false,
"Panel should be visible"
);
Assert.equal(
document.activeElement,
oldFocus,
"Focus didn't move when panel was shown"
);
// Check there is a primary button and click it. It will trigger the callback.
Assert.ok(
document.getElementById("contextual-feature-recommendation-notification")
.button
);
let hidePanel = BrowserTestUtils.waitForEvent(
PopupNotifications.panel,
"popuphidden"
);
document
.getElementById("contextual-feature-recommendation-notification")
.button.click();
await hidePanel;
// Clicking the primary action also removes the notification
Assert.equal(
PopupNotifications._currentNotifications.length,
0,
"Should have removed the notification"
);
});
add_task(async function test_cfr_notification_show() {
// addRecommendation checks that scheme starts with http and host matches
let browser = gBrowser.selectedBrowser;
await BrowserTestUtils.loadURI(browser, "http://example.com/");
await BrowserTestUtils.browserLoaded(browser, false, "http://example.com/");
let response = await trigger_cfr_panel(browser, "example.com", {
heading_text: "First Message",
});
Assert.ok(
response,
"Should return true if addRecommendation checks were successful"
);
const showPanel = BrowserTestUtils.waitForEvent(
PopupNotifications.panel,
"popupshown"
);
// Try adding another message
response = await trigger_cfr_panel(browser, "example.com", {
heading_text: "Second Message",
});
Assert.equal(
response,
false,
"Should return false if second call did not add the message"
);
// Open the panel
document.getElementById("contextual-feature-recommendation").click();
await showPanel;
Assert.ok(
document.getElementById("contextual-feature-recommendation-notification")
.hidden === false,
"Panel should be visible"
);
Assert.equal(
document.getElementById("cfr-notification-header-label").value,
"First Message",
"The first message should be visible"
);
// Check there is a primary button and click it. It will trigger the callback.
Assert.ok(
document.getElementById("contextual-feature-recommendation-notification")
.button
);
let hidePanel = BrowserTestUtils.waitForEvent(
PopupNotifications.panel,
"popuphidden"
);
document
.getElementById("contextual-feature-recommendation-notification")
.button.click();
await hidePanel;
// Clicking the primary action also removes the notification
Assert.equal(
PopupNotifications._currentNotifications.length,
0,
"Should have removed the notification"
);
});
add_task(async function test_cfr_addon_install() {
// addRecommendation checks that scheme starts with http and host matches
const browser = gBrowser.selectedBrowser;
await BrowserTestUtils.loadURI(browser, "http://example.com/");
await BrowserTestUtils.browserLoaded(browser, false, "http://example.com/");
const response = await trigger_cfr_panel(browser, "example.com", {
action: { type: "INSTALL_ADDON_FROM_URL" },
});
Assert.ok(
response,
"Should return true if addRecommendation checks were successful"
);
const showPanel = BrowserTestUtils.waitForEvent(
PopupNotifications.panel,
"popupshown"
);
// Open the panel
document.getElementById("contextual-feature-recommendation").click();
await showPanel;
Assert.ok(
document.getElementById("contextual-feature-recommendation-notification")
.hidden === false,
"Panel should be visible"
);
checkCFRAddonsElements(
document.getElementById("contextual-feature-recommendation-notification")
);
// Check there is a primary button and click it. It will trigger the callback.
Assert.ok(
document.getElementById("contextual-feature-recommendation-notification")
.button
);
const hidePanel = BrowserTestUtils.waitForEvent(
PopupNotifications.panel,
"popuphidden"
);
document
.getElementById("contextual-feature-recommendation-notification")
.button.click();
await hidePanel;
await BrowserTestUtils.waitForEvent(PopupNotifications.panel, "popupshown");
let [notification] = PopupNotifications.panel.childNodes;
// Trying to install the addon will trigger a progress popup or an error popup if
// running the test multiple times in a row
Assert.ok(
notification.id === "addon-progress-notification" ||
notification.id === "addon-install-failed-notification",
"Should try to install the addon"
);
// This removes the `Addon install failure` notifications
while (PopupNotifications._currentNotifications.length) {
PopupNotifications.remove(PopupNotifications._currentNotifications[0]);
}
// There should be no more notifications left
Assert.equal(
PopupNotifications._currentNotifications.length,
0,
"Should have removed the notification"
);
});
add_task(async function test_cfr_pin_tab_notification_show() {
// addRecommendation checks that scheme starts with http and host matches
let browser = gBrowser.selectedBrowser;
await BrowserTestUtils.loadURI(browser, "http://example.com/");
await BrowserTestUtils.browserLoaded(browser, false, "http://example.com/");
const response = await trigger_cfr_panel(browser, "example.com", {
action: { type: "PIN_CURRENT_TAB" },
category: "cfrFeatures",
layout: "message_and_animation",
});
Assert.ok(
response,
"Should return true if addRecommendation checks were successful"
);
const showPanel = BrowserTestUtils.waitForEvent(
PopupNotifications.panel,
"popupshown"
);
// Open the panel
document.getElementById("contextual-feature-recommendation").click();
await showPanel;
const notification = document.getElementById(
"contextual-feature-recommendation-notification"
);
checkCFRFeaturesElements(notification);
// Check there is a primary button and click it. It will trigger the callback.
Assert.ok(notification.button);
let hidePanel = BrowserTestUtils.waitForEvent(
PopupNotifications.panel,
"popuphidden"
);
document
.getElementById("contextual-feature-recommendation-notification")
.button.click();
await hidePanel;
await BrowserTestUtils.waitForCondition(
() => gBrowser.selectedTab.pinned,
"Primary action should pin tab"
);
Assert.ok(gBrowser.selectedTab.pinned, "Current tab should be pinned");
gBrowser.unpinTab(gBrowser.selectedTab);
// Clicking the primary action also removes the notification
Assert.equal(
PopupNotifications._currentNotifications.length,
0,
"Should have removed the notification"
);
});
add_task(
async function test_cfr_social_tracking_protection_notification_show() {
// addRecommendation checks that scheme starts with http and host matches
let browser = gBrowser.selectedBrowser;
await BrowserTestUtils.loadURI(browser, "http://example.com/");
await BrowserTestUtils.browserLoaded(browser, false, "http://example.com/");
const showPanel = BrowserTestUtils.waitForEvent(
PopupNotifications.panel,
"popupshown"
);
const response = await trigger_cfr_panel(browser, "example.com", {
action: { type: "OPEN_PROTECTION_PANEL" },
category: "cfrFeatures",
layout: "icon_and_message",
skip_address_bar_notifier: true,
use_single_secondary_button: true,
});
Assert.ok(
response,
"Should return true if addRecommendation checks were successful"
);
await showPanel;
const notification = document.getElementById(
"contextual-feature-recommendation-notification"
);
checkCFRSocialTrackingProtection(notification);
// Check there is a primary button and click it. It will trigger the callback.
Assert.ok(notification.button);
let hidePanel = BrowserTestUtils.waitForEvent(
PopupNotifications.panel,
"popuphidden"
);
document
.getElementById("contextual-feature-recommendation-notification")
.button.click();
await hidePanel;
}
);
add_task(
async function test_cfr_tracking_protection_milestone_notification_show() {
await SpecialPowers.pushPrefEnv({
set: [
["browser.contentblocking.cfr-milestone.milestone-achieved", 1000],
[
"browser.newtabpage.activity-stream.asrouter.providers.cfr",
`{"id":"cfr","enabled":true,"type":"local","localProvider":"CFRMessageProvider","frequency":{"custom":[{"period":"daily","cap":10}]},"categories":["cfrAddons","cfrFeatures"],"updateCycleInMs":3600000}`,
],
],
});
// addRecommendation checks that scheme starts with http and host matches
let browser = gBrowser.selectedBrowser;
await BrowserTestUtils.loadURI(browser, "http://example.com/");
await BrowserTestUtils.browserLoaded(browser, false, "http://example.com/");
const showPanel = BrowserTestUtils.waitForEvent(
PopupNotifications.panel,
"popupshown"
);
const response = await trigger_cfr_panel(browser, "example.com", {
action: { type: "OPEN_PROTECTION_REPORT" },
category: "cfrFeatures",
layout: "short_message",
skip_address_bar_notifier: true,
heading_text: "Test Milestone Message",
template: "milestone_message",
});
Assert.ok(
response,
"Should return true if addRecommendation checks were successful"
);
await showPanel;
const notification = document.getElementById(
"contextual-feature-recommendation-notification"
);
// checkCFRSocialTrackingProtection(notification);
checkCFRTrackingProtectionMilestone(notification);
// Check there is a primary button and click it. It will trigger the callback.
Assert.ok(notification.button);
let hidePanel = BrowserTestUtils.waitForEvent(
PopupNotifications.panel,
"popuphidden"
);
document
.getElementById("contextual-feature-recommendation-notification")
.button.click();
await BrowserTestUtils.removeTab(gBrowser.selectedTab);
await hidePanel;
}
);
add_task(async function test_cfr_features_and_addon_show() {
// addRecommendation checks that scheme starts with http and host matches
let browser = gBrowser.selectedBrowser;
await BrowserTestUtils.loadURI(browser, "http://example.com/");
await BrowserTestUtils.browserLoaded(browser, false, "http://example.com/");
// Trigger Feature CFR
let response = await trigger_cfr_panel(browser, "example.com", {
action: { type: "PIN_CURRENT_TAB" },
category: "cfrFeatures",
layout: "message_and_animation",
});
Assert.ok(
response,
"Should return true if addRecommendation checks were successful"
);
let showPanel = BrowserTestUtils.waitForEvent(
PopupNotifications.panel,
"popupshown"
);
// Open the panel
document.getElementById("contextual-feature-recommendation").click();
await showPanel;
const notification = document.getElementById(
"contextual-feature-recommendation-notification"
);
checkCFRFeaturesElements(notification);
// Check there is a primary button and click it. It will trigger the callback.
Assert.ok(notification.button);
let hidePanel = BrowserTestUtils.waitForEvent(
PopupNotifications.panel,
"popuphidden"
);
document
.getElementById("contextual-feature-recommendation-notification")
.button.click();
await hidePanel;
// Clicking the primary action also removes the notification
Assert.equal(
PopupNotifications._currentNotifications.length,
0,
"Should have removed the notification"
);
// Trigger Addon CFR
response = await trigger_cfr_panel(browser, "example.com");
Assert.ok(
response,
"Should return true if addRecommendation checks were successful"
);
showPanel = BrowserTestUtils.waitForEvent(
PopupNotifications.panel,
"popupshown"
);
// Open the panel
document.getElementById("contextual-feature-recommendation").click();
await showPanel;
Assert.ok(
document.getElementById("contextual-feature-recommendation-notification")
.hidden === false,
"Panel should be visible"
);
checkCFRAddonsElements(
document.getElementById("contextual-feature-recommendation-notification")
);
// Check there is a primary button and click it. It will trigger the callback.
Assert.ok(notification.button);
hidePanel = BrowserTestUtils.waitForEvent(
PopupNotifications.panel,
"popuphidden"
);
document
.getElementById("contextual-feature-recommendation-notification")
.button.click();
await hidePanel;
// Clicking the primary action also removes the notification
Assert.equal(
PopupNotifications._currentNotifications.length,
0,
"Should have removed the notification"
);
});
add_task(async function test_cfr_addon_and_features_show() {
// addRecommendation checks that scheme starts with http and host matches
let browser = gBrowser.selectedBrowser;
await BrowserTestUtils.loadURI(browser, "http://example.com/");
await BrowserTestUtils.browserLoaded(browser, false, "http://example.com/");
// Trigger Feature CFR
let response = await trigger_cfr_panel(browser, "example.com");
Assert.ok(
response,
"Should return true if addRecommendation checks were successful"
);
let showPanel = BrowserTestUtils.waitForEvent(
PopupNotifications.panel,
"popupshown"
);
// Open the panel
document.getElementById("contextual-feature-recommendation").click();
await showPanel;
const notification = document.getElementById(
"contextual-feature-recommendation-notification"
);
checkCFRAddonsElements(notification);
// Check there is a primary button and click it. It will trigger the callback.
Assert.ok(notification.button);
let hidePanel = BrowserTestUtils.waitForEvent(
PopupNotifications.panel,
"popuphidden"
);
document
.getElementById("contextual-feature-recommendation-notification")
.button.click();
await hidePanel;
// Clicking the primary action also removes the notification
Assert.equal(
PopupNotifications._currentNotifications.length,
0,
"Should have removed the notification"
);
// Trigger Addon CFR
response = await trigger_cfr_panel(browser, "example.com", {
action: { type: "PIN_CURRENT_TAB" },
category: "cfrAddons",
});
Assert.ok(
response,
"Should return true if addRecommendation checks were successful"
);
showPanel = BrowserTestUtils.waitForEvent(
PopupNotifications.panel,
"popupshown"
);
// Open the panel
document.getElementById("contextual-feature-recommendation").click();
await showPanel;
Assert.ok(
document.getElementById("contextual-feature-recommendation-notification")
.hidden === false,
"Panel should be visible"
);
checkCFRAddonsElements(
document.getElementById("contextual-feature-recommendation-notification")
);
// Check there is a primary button and click it. It will trigger the callback.
Assert.ok(notification.button);
hidePanel = BrowserTestUtils.waitForEvent(
PopupNotifications.panel,
"popuphidden"
);
document
.getElementById("contextual-feature-recommendation-notification")
.button.click();
await hidePanel;
// Clicking the primary action also removes the notification
Assert.equal(
PopupNotifications._currentNotifications.length,
0,
"Should have removed the notification"
);
});
add_task(async function test_onLocationChange_cb() {
let count = 0;
const triggerHandler = () => ++count;
const TEST_URL =
"https://example.com/browser/browser/components/newtab/test/browser/blue_page.html";
const browser = gBrowser.selectedBrowser;
await ASRouterTriggerListeners.get("openURL").init(triggerHandler, [
"example.com",
]);
await BrowserTestUtils.loadURI(browser, "about:blank");
await BrowserTestUtils.browserLoaded(browser, false, "about:blank");
await BrowserTestUtils.loadURI(browser, "http://example.com/");
await BrowserTestUtils.browserLoaded(browser, false, "http://example.com/");
Assert.equal(count, 1, "Count navigation to example.com");
// Anchor scroll triggers a location change event with the same document
// https://searchfox.org/mozilla-central/rev/8848b9741fc4ee4e9bc3ae83ea0fc048da39979f/uriloader/base/nsIWebProgressListener.idl#400-403
await BrowserTestUtils.loadURI(browser, "http://example.com/#foo");
await BrowserTestUtils.waitForLocationChange(
gBrowser,
"http://example.com/#foo"
);
Assert.equal(count, 1, "It should ignore same page navigation");
await BrowserTestUtils.loadURI(browser, TEST_URL);
await BrowserTestUtils.browserLoaded(browser, false, TEST_URL);
Assert.equal(count, 2, "We moved to a new document");
registerCleanupFunction(() => {
ASRouterTriggerListeners.get("openURL").uninit();
});
});
add_task(async function test_matchPattern() {
let count = 0;
const triggerHandler = () => ++count;
const frequentVisitsTrigger = ASRouterTriggerListeners.get("frequentVisits");
await frequentVisitsTrigger.init(triggerHandler, [], ["*://*.example.com/"]);
const browser = gBrowser.selectedBrowser;
await BrowserTestUtils.loadURI(browser, "http://example.com/");
await BrowserTestUtils.browserLoaded(browser, false, "http://example.com/");
await BrowserTestUtils.waitForCondition(
() => frequentVisitsTrigger._visits.get("example.com").length === 1,
"Registered pattern matched the current location"
);
await BrowserTestUtils.loadURI(browser, "about:config");
await BrowserTestUtils.browserLoaded(browser, false, "about:config");
await BrowserTestUtils.waitForCondition(
() => frequentVisitsTrigger._visits.get("example.com").length === 1,
"Navigated to a new page but not a match"
);
await BrowserTestUtils.loadURI(browser, "http://example.com/");
await BrowserTestUtils.browserLoaded(browser, false, "http://example.com/");
await BrowserTestUtils.waitForCondition(
() => frequentVisitsTrigger._visits.get("example.com").length === 1,
"Navigated to a location that matches the pattern but within 15 mins"
);
await BrowserTestUtils.loadURI(browser, "http://www.example.com/");
await BrowserTestUtils.browserLoaded(
browser,
false,
"http://www.example.com/"
);
await BrowserTestUtils.waitForCondition(
() => frequentVisitsTrigger._visits.get("www.example.com").length === 1,
"www.example.com is a different host that also matches the pattern."
);
await BrowserTestUtils.waitForCondition(
() => frequentVisitsTrigger._visits.get("example.com").length === 1,
"www.example.com is a different host that also matches the pattern."
);
registerCleanupFunction(() => {
ASRouterTriggerListeners.get("frequentVisits").uninit();
});
});
add_task(async function test_providerNames() {
const providersBranch =
"browser.newtabpage.activity-stream.asrouter.providers.";
const cfrProviderPrefs = Services.prefs.getChildList(providersBranch);
for (const prefName of cfrProviderPrefs) {
const prefValue = JSON.parse(Services.prefs.getStringPref(prefName));
if (prefValue.id) {
// Snippets are disabled in tests and value is set to []
Assert.equal(
prefValue.id,
prefName.slice(providersBranch.length),
"Provider id and pref name do not match"
);
}
}
});
add_task(async function test_cfr_notification_keyboard() {
// addRecommendation checks that scheme starts with http and host matches
const browser = gBrowser.selectedBrowser;
await BrowserTestUtils.loadURI(browser, "http://example.com/");
await BrowserTestUtils.browserLoaded(browser, false, "http://example.com/");
const response = await trigger_cfr_panel(browser, "example.com");
Assert.ok(
response,
"Should return true if addRecommendation checks were successful"
);
// Open the panel with the keyboard.
// Toolbar buttons aren't always focusable; toolbar keyboard navigation
// makes them focusable on demand. Therefore, we must force focus.
const button = document.getElementById("contextual-feature-recommendation");
button.setAttribute("tabindex", "-1");
button.focus();
button.removeAttribute("tabindex");
let focused = BrowserTestUtils.waitForEvent(
PopupNotifications.panel,
"focus",
true
);
EventUtils.synthesizeKey(" ");
await focused;
Assert.ok(true, "Focus inside panel after button pressed");
let hidden = BrowserTestUtils.waitForEvent(
PopupNotifications.panel,
"popuphidden"
);
EventUtils.synthesizeKey("KEY_Escape");
await hidden;
Assert.ok(true, "Panel hidden after Escape pressed");
});
================================================
FILE: test/browser/browser_asrouter_snippets.js
================================================
"use strict";
const { ASRouter } = ChromeUtils.import(
"resource://activity-stream/lib/ASRouter.jsm"
);
const { SnippetsTestMessageProvider } = ChromeUtils.import(
"resource://activity-stream/lib/SnippetsTestMessageProvider.jsm"
);
test_newtab({
async before() {
let data = SnippetsTestMessageProvider.getMessages().find(
m => m.id === "SIMPLE_BELOW_SEARCH_TEST_1"
);
ASRouter.messageChannel.sendAsyncMessage("ASRouter:parent-to-child", {
type: "SET_MESSAGE",
data,
});
},
test: async function test_simple_below_search_snippet() {
// Verify the simple_below_search_snippet renders in container below searchbox
// and nothing is rendered in the footer.
await ContentTaskUtils.waitForCondition(
() =>
content.document.querySelector(
".below-search-snippet .SimpleBelowSearchSnippet"
),
"Should find the snippet inside the below search container"
);
is(
0,
content.document.querySelector("#footer-asrouter-container").childNodes
.length,
"Should not find any snippets in the footer container"
);
},
});
test_newtab({
async before() {
let data = SnippetsTestMessageProvider.getMessages().find(
m => m.id === "SIMPLE_TEST_1"
);
ASRouter.messageChannel.sendAsyncMessage("ASRouter:parent-to-child", {
type: "SET_MESSAGE",
data,
});
},
test: async function test_simple_snippet() {
// Verify the simple_snippet renders in the footer and the container below
// searchbox is not rendered.
await ContentTaskUtils.waitForCondition(
() =>
content.document.querySelector(
"#footer-asrouter-container .SimpleSnippet"
),
"Should find the snippet inside the footer container"
);
ok(
!content.document.querySelector(".below-search-snippet"),
"Should not find any snippets below search"
);
},
});
================================================
FILE: test/browser/browser_asrouter_targeting.js
================================================
const { ASRouterTargeting, QueryCache } = ChromeUtils.import(
"resource://activity-stream/lib/ASRouterTargeting.jsm"
);
const { AddonTestUtils } = ChromeUtils.import(
"resource://testing-common/AddonTestUtils.jsm"
);
const { CFRMessageProvider } = ChromeUtils.import(
"resource://activity-stream/lib/CFRMessageProvider.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"ProfileAge",
"resource://gre/modules/ProfileAge.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"AddonManager",
"resource://gre/modules/AddonManager.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"ShellService",
"resource:///modules/ShellService.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"NewTabUtils",
"resource://gre/modules/NewTabUtils.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"PlacesTestUtils",
"resource://testing-common/PlacesTestUtils.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"TelemetryEnvironment",
"resource://gre/modules/TelemetryEnvironment.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"AppConstants",
"resource://gre/modules/AppConstants.jsm"
);
// ASRouterTargeting.isMatch
add_task(async function should_do_correct_targeting() {
is(
await ASRouterTargeting.isMatch("FOO", { FOO: true }),
true,
"should return true for a matching value"
);
is(
await ASRouterTargeting.isMatch("!FOO", { FOO: true }),
false,
"should return false for a non-matching value"
);
});
add_task(async function should_handle_async_getters() {
const context = {
get FOO() {
return Promise.resolve(true);
},
};
is(
await ASRouterTargeting.isMatch("FOO", context),
true,
"should return true for a matching async value"
);
});
// ASRouterTargeting.findMatchingMessage
add_task(async function find_matching_message() {
const messages = [
{ id: "foo", targeting: "FOO" },
{ id: "bar", targeting: "!FOO" },
];
const context = { FOO: true };
const match = await ASRouterTargeting.findMatchingMessage({
messages,
context,
});
is(match, messages[0], "should match and return the correct message");
});
add_task(async function return_nothing_for_no_matching_message() {
const messages = [{ id: "bar", targeting: "!FOO" }];
const context = { FOO: true };
const match = await ASRouterTargeting.findMatchingMessage({
messages,
context,
});
is(
match,
undefined,
"should return nothing since no matching message exists"
);
});
add_task(async function check_syntax_error_handling() {
let result;
function onError(...args) {
result = args;
}
const messages = [{ id: "foo", targeting: "foo === 0" }];
const match = await ASRouterTargeting.findMatchingMessage({
messages,
onError,
});
is(
match,
undefined,
"should return nothing since no valid matching message exists"
);
// Note that in order for the following test to pass, we are expecting a particular filepath for mozjexl.
// If the location of this file has changed, the MOZ_JEXL_FILEPATH constant should be updated om ASRouterTargeting.jsm
is(
result[0],
ASRouterTargeting.ERROR_TYPES.MALFORMED_EXPRESSION,
"should recognize the error as coming from mozjexl and call onError with the MALFORMED_EXPRESSION error type"
);
ok(result[1].message, "should call onError with the error from mozjexl");
is(result[2], messages[0], "should call onError with the invalid message");
});
add_task(async function check_other_error_handling() {
let result;
function onError(...args) {
result = args;
}
const messages = [{ id: "foo", targeting: "foo" }];
const context = {
get foo() {
throw new Error("test error");
},
};
const match = await ASRouterTargeting.findMatchingMessage({
messages,
context,
onError,
});
is(
match,
undefined,
"should return nothing since no valid matching message exists"
);
// Note that in order for the following test to pass, we are expecting a particular filepath for mozjexl.
// If the location of this file has changed, the MOZ_JEXL_FILEPATH constant should be updated om ASRouterTargeting.jsm
is(
result[0],
ASRouterTargeting.ERROR_TYPES.OTHER_ERROR,
"should not recognize the error as being an other error, not a mozjexl one"
);
is(
result[1].message,
"test error",
"should call onError with the error thrown in the context"
);
is(result[2], messages[0], "should call onError with the invalid message");
});
// ASRouterTargeting.Environment
add_task(async function check_locale() {
ok(
Services.locale.appLocaleAsLangTag,
"Services.locale.appLocaleAsLangTag exists"
);
const message = {
id: "foo",
targeting: `locale == "${Services.locale.appLocaleAsLangTag}"`,
};
is(
await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
message,
"should select correct item when filtering by locale"
);
});
add_task(async function check_localeLanguageCode() {
const currentLanguageCode = Services.locale.appLocaleAsLangTag.substr(0, 2);
is(
Services.locale.negotiateLanguages(
[currentLanguageCode],
[Services.locale.appLocaleAsLangTag]
)[0],
Services.locale.appLocaleAsLangTag,
"currentLanguageCode should resolve to the current locale (e.g en => en-US)"
);
const message = {
id: "foo",
targeting: `localeLanguageCode == "${currentLanguageCode}"`,
};
is(
await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
message,
"should select correct item when filtering by localeLanguageCode"
);
});
add_task(async function checkProfileAgeCreated() {
let profileAccessor = await ProfileAge();
is(
await ASRouterTargeting.Environment.profileAgeCreated,
await profileAccessor.created,
"should return correct profile age creation date"
);
const message = {
id: "foo",
targeting: `profileAgeCreated > ${(await profileAccessor.created) - 100}`,
};
is(
await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
message,
"should select correct item by profile age created"
);
});
add_task(async function checkProfileAgeReset() {
let profileAccessor = await ProfileAge();
is(
await ASRouterTargeting.Environment.profileAgeReset,
await profileAccessor.reset,
"should return correct profile age reset"
);
const message = {
id: "foo",
targeting: `profileAgeReset == ${await profileAccessor.reset}`,
};
is(
await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
message,
"should select correct item by profile age reset"
);
});
add_task(async function checkCurrentDate() {
let message = {
id: "foo",
targeting: `currentDate < '${new Date(Date.now() + 5000)}'|date`,
};
is(
await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
message,
"should select message based on currentDate < timestamp"
);
message = {
id: "foo",
targeting: `currentDate > '${new Date(Date.now() - 5000)}'|date`,
};
is(
await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
message,
"should select message based on currentDate > timestamp"
);
});
add_task(async function check_usesFirefoxSync() {
await pushPrefs(["services.sync.username", "someone@foo.com"]);
is(
await ASRouterTargeting.Environment.usesFirefoxSync,
true,
"should return true if a fx account is set"
);
const message = { id: "foo", targeting: "usesFirefoxSync" };
is(
await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
message,
"should select correct item by usesFirefoxSync"
);
});
add_task(async function check_isFxAEnabled() {
await pushPrefs(["identity.fxaccounts.enabled", false]);
is(
await ASRouterTargeting.Environment.isFxAEnabled,
false,
"should return false if fxa is disabled"
);
const message = { id: "foo", targeting: "isFxAEnabled" };
is(
await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
undefined,
"should not select a message if fxa is disabled"
);
});
add_task(async function check_isFxAEnabled() {
await pushPrefs(["identity.fxaccounts.enabled", true]);
is(
await ASRouterTargeting.Environment.isFxAEnabled,
true,
"should return true if fxa is enabled"
);
const message = { id: "foo", targeting: "isFxAEnabled" };
is(
await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
message,
"should select the correct message"
);
});
add_task(async function check_totalBookmarksCount() {
// Make sure we remove default bookmarks so they don't interfere
await clearHistoryAndBookmarks();
const message = { id: "foo", targeting: "totalBookmarksCount > 0" };
const results = await ASRouterTargeting.findMatchingMessage({
messages: [message],
});
is(
results ? JSON.stringify(results) : results,
undefined,
"Should not select any message because bookmarks count is not 0"
);
const bookmark = await PlacesUtils.bookmarks.insert({
parentGuid: PlacesUtils.bookmarks.unfiledGuid,
title: "foo",
url: "https://mozilla1.com/nowNew",
});
QueryCache.queries.TotalBookmarksCount.expire();
is(
await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
message,
"Should select correct item after bookmarks are added."
);
// Cleanup
await PlacesUtils.bookmarks.remove(bookmark.guid);
});
add_task(async function check_needsUpdate() {
QueryCache.queries.CheckBrowserNeedsUpdate.setUp(true);
const message = { id: "foo", targeting: "needsUpdate" };
is(
await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
message,
"Should select message because update count > 0"
);
QueryCache.queries.CheckBrowserNeedsUpdate.setUp(false);
is(
await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
null,
"Should not select message because update count == 0"
);
});
add_task(async function checksearchEngines() {
const result = await ASRouterTargeting.Environment.searchEngines;
const expectedInstalled = (await Services.search.getVisibleEngines())
.map(engine => engine.identifier)
.sort()
.join(",");
ok(
result.installed.length,
"searchEngines.installed should be a non-empty array"
);
is(
result.installed.sort().join(","),
expectedInstalled,
"searchEngines.installed should be an array of visible search engines"
);
ok(
result.current && typeof result.current === "string",
"searchEngines.current should be a truthy string"
);
is(
result.current,
(await Services.search.getDefault()).identifier,
"searchEngines.current should be the current engine name"
);
const message = {
id: "foo",
targeting: `searchEngines[.current == ${
(await Services.search.getDefault()).identifier
}]`,
};
is(
await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
message,
"should select correct item by searchEngines.current"
);
const message2 = {
id: "foo",
targeting: `searchEngines[${
(await Services.search.getVisibleEngines())[0].identifier
} in .installed]`,
};
is(
await ASRouterTargeting.findMatchingMessage({ messages: [message2] }),
message2,
"should select correct item by searchEngines.installed"
);
});
add_task(async function checkisDefaultBrowser() {
const expected = ShellService.isDefaultBrowser();
const result = ASRouterTargeting.Environment.isDefaultBrowser;
is(typeof result, "boolean", "isDefaultBrowser should be a boolean value");
is(
result,
expected,
"isDefaultBrowser should be equal to ShellService.isDefaultBrowser()"
);
const message = {
id: "foo",
targeting: `isDefaultBrowser == ${expected.toString()}`,
};
is(
await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
message,
"should select correct item by isDefaultBrowser"
);
});
add_task(async function checkdevToolsOpenedCount() {
await pushPrefs(["devtools.selfxss.count", 5]);
is(
ASRouterTargeting.Environment.devToolsOpenedCount,
5,
"devToolsOpenedCount should be equal to devtools.selfxss.count pref value"
);
const message = { id: "foo", targeting: "devToolsOpenedCount >= 5" };
is(
await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
message,
"should select correct item by devToolsOpenedCount"
);
});
add_task(async function check_platformName() {
const message = {
id: "foo",
targeting: `platformName == "${AppConstants.platform}"`,
};
is(
await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
message,
"should select correct item by platformName"
);
});
AddonTestUtils.initMochitest(this);
add_task(async function checkAddonsInfo() {
const FAKE_ID = "testaddon@tests.mozilla.org";
const FAKE_NAME = "Test Addon";
const FAKE_VERSION = "0.5.7";
const xpi = AddonTestUtils.createTempWebExtensionFile({
manifest: {
applications: { gecko: { id: FAKE_ID } },
name: FAKE_NAME,
version: FAKE_VERSION,
},
});
await Promise.all([
AddonTestUtils.promiseWebExtensionStartup(FAKE_ID),
AddonManager.installTemporaryAddon(xpi),
]);
const { addons } = await AddonManager.getActiveAddons([
"extension",
"service",
]);
const { addons: asRouterAddons, isFullData } = await ASRouterTargeting
.Environment.addonsInfo;
ok(
addons.every(({ id }) => asRouterAddons[id]),
"should contain every addon"
);
ok(
Object.getOwnPropertyNames(asRouterAddons).every(id =>
addons.some(addon => addon.id === id)
),
"should contain no incorrect addons"
);
const testAddon = asRouterAddons[FAKE_ID];
ok(
Object.prototype.hasOwnProperty.call(testAddon, "version") &&
testAddon.version === FAKE_VERSION,
"should correctly provide `version` property"
);
ok(
Object.prototype.hasOwnProperty.call(testAddon, "type") &&
testAddon.type === "extension",
"should correctly provide `type` property"
);
ok(
Object.prototype.hasOwnProperty.call(testAddon, "isSystem") &&
testAddon.isSystem === false,
"should correctly provide `isSystem` property"
);
ok(
Object.prototype.hasOwnProperty.call(testAddon, "isWebExtension") &&
testAddon.isWebExtension === true,
"should correctly provide `isWebExtension` property"
);
// As we installed our test addon the addons database must be initialised, so
// (in this test environment) we expect to receive "full" data
ok(isFullData, "should receive full data");
ok(
Object.prototype.hasOwnProperty.call(testAddon, "name") &&
testAddon.name === FAKE_NAME,
"should correctly provide `name` property from full data"
);
ok(
Object.prototype.hasOwnProperty.call(testAddon, "userDisabled") &&
testAddon.userDisabled === false,
"should correctly provide `userDisabled` property from full data"
);
ok(
Object.prototype.hasOwnProperty.call(testAddon, "installDate") &&
Math.abs(Date.now() - new Date(testAddon.installDate)) < 60 * 1000,
"should correctly provide `installDate` property from full data"
);
});
add_task(async function checkFrecentSites() {
const now = Date.now();
const timeDaysAgo = numDays => now - numDays * 24 * 60 * 60 * 1000;
const visits = [];
for (const [uri, count, visitDate] of [
["https://mozilla1.com/", 10, timeDaysAgo(0)], // frecency 1000
["https://mozilla2.com/", 5, timeDaysAgo(1)], // frecency 500
["https://mozilla3.com/", 1, timeDaysAgo(2)], // frecency 100
]) {
[...Array(count).keys()].forEach(() =>
visits.push({
uri,
visitDate: visitDate * 1000, // Places expects microseconds
})
);
}
await PlacesTestUtils.addVisits(visits);
let message = {
id: "foo",
targeting: "'mozilla3.com' in topFrecentSites|mapToProperty('host')",
};
is(
await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
message,
"should select correct item by host in topFrecentSites"
);
message = {
id: "foo",
targeting: "'non-existent.com' in topFrecentSites|mapToProperty('host')",
};
is(
await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
undefined,
"should not select incorrect item by host in topFrecentSites"
);
message = {
id: "foo",
targeting:
"'mozilla2.com' in topFrecentSites[.frecency >= 400]|mapToProperty('host')",
};
is(
await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
message,
"should select correct item when filtering by frecency"
);
message = {
id: "foo",
targeting:
"'mozilla2.com' in topFrecentSites[.frecency >= 600]|mapToProperty('host')",
};
is(
await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
undefined,
"should not select incorrect item when filtering by frecency"
);
message = {
id: "foo",
targeting: `'mozilla2.com' in topFrecentSites[.lastVisitDate >= ${timeDaysAgo(
1
) - 1}]|mapToProperty('host')`,
};
is(
await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
message,
"should select correct item when filtering by lastVisitDate"
);
message = {
id: "foo",
targeting: `'mozilla2.com' in topFrecentSites[.lastVisitDate >= ${timeDaysAgo(
0
) - 1}]|mapToProperty('host')`,
};
is(
await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
undefined,
"should not select incorrect item when filtering by lastVisitDate"
);
message = {
id: "foo",
targeting: `(topFrecentSites[.frecency >= 900 && .lastVisitDate >= ${timeDaysAgo(
1
) -
1}]|mapToProperty('host') intersect ['mozilla3.com', 'mozilla2.com', 'mozilla1.com'])|length > 0`,
};
is(
await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
message,
"should select correct item when filtering by frecency and lastVisitDate with multiple candidate domains"
);
// Cleanup
await clearHistoryAndBookmarks();
});
add_task(async function check_pinned_sites() {
const originalPin = JSON.stringify(NewTabUtils.pinnedLinks.links);
const sitesToPin = [
{ url: "https://foo.com" },
{ url: "https://bloo.com" },
{ url: "https://floogle.com", searchTopSite: true },
];
sitesToPin.forEach(site =>
NewTabUtils.pinnedLinks.pin(site, NewTabUtils.pinnedLinks.links.length)
);
// Unpinning adds null to the list of pinned sites, which we should test that we handle gracefully for our targeting
NewTabUtils.pinnedLinks.unpin(sitesToPin[1]);
ok(
NewTabUtils.pinnedLinks.links.includes(null),
"should have set an item in pinned links to null via unpinning for testing"
);
let message;
message = {
id: "foo",
targeting: "'https://foo.com' in pinnedSites|mapToProperty('url')",
};
is(
await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
message,
"should select correct item by url in pinnedSites"
);
message = {
id: "foo",
targeting: "'foo.com' in pinnedSites|mapToProperty('host')",
};
is(
await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
message,
"should select correct item by host in pinnedSites"
);
message = {
id: "foo",
targeting:
"'floogle.com' in pinnedSites[.searchTopSite == true]|mapToProperty('host')",
};
is(
await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
message,
"should select correct item by host and searchTopSite in pinnedSites"
);
// Cleanup
sitesToPin.forEach(site => NewTabUtils.pinnedLinks.unpin(site));
await clearHistoryAndBookmarks();
is(
JSON.stringify(NewTabUtils.pinnedLinks.links),
originalPin,
"should restore pinned sites to its original state"
);
});
add_task(async function check_firefox_version() {
const message = { id: "foo", targeting: "firefoxVersion > 0" };
is(
await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
message,
"should select correct item when filtering by firefox version"
);
});
add_task(async function check_region() {
await SpecialPowers.pushPrefEnv({ set: [["browser.search.region", "DE"]] });
const message = { id: "foo", targeting: "region in ['DE']" };
is(
await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
message,
"should select correct item when filtering by firefox geo"
);
});
add_task(async function check_browserSettings() {
is(
await JSON.stringify(ASRouterTargeting.Environment.browserSettings.update),
JSON.stringify(TelemetryEnvironment.currentEnvironment.settings.update),
"should return correct update info"
);
});
add_task(async function check_sync() {
is(
await ASRouterTargeting.Environment.sync.desktopDevices,
Services.prefs.getIntPref("services.sync.clients.devices.desktop", 0),
"should return correct desktopDevices info"
);
is(
await ASRouterTargeting.Environment.sync.mobileDevices,
Services.prefs.getIntPref("services.sync.clients.devices.mobile", 0),
"should return correct mobileDevices info"
);
is(
await ASRouterTargeting.Environment.sync.totalDevices,
Services.prefs.getIntPref("services.sync.numClients", 0),
"should return correct mobileDevices info"
);
});
add_task(async function check_provider_cohorts() {
await pushPrefs([
"browser.newtabpage.activity-stream.asrouter.providers.onboarding",
JSON.stringify({
id: "onboarding",
messages: [],
enabled: true,
cohort: "foo",
}),
]);
await pushPrefs([
"browser.newtabpage.activity-stream.asrouter.providers.cfr",
JSON.stringify({ id: "cfr", enabled: true, cohort: "bar" }),
]);
is(
await ASRouterTargeting.Environment.providerCohorts.onboarding,
"foo",
"should have cohort foo for onboarding"
);
is(
await ASRouterTargeting.Environment.providerCohorts.cfr,
"bar",
"should have cohort bar for cfr"
);
});
add_task(async function check_xpinstall_enabled() {
// should default to true if pref doesn't exist
is(await ASRouterTargeting.Environment.xpinstallEnabled, true);
// flip to false, check targeting reflects that
await pushPrefs(["xpinstall.enabled", false]);
is(await ASRouterTargeting.Environment.xpinstallEnabled, false);
// flip to true, check targeting reflects that
await pushPrefs(["xpinstall.enabled", true]);
is(await ASRouterTargeting.Environment.xpinstallEnabled, true);
});
add_task(async function check_pinned_tabs() {
await BrowserTestUtils.withNewTab(
{ gBrowser, url: "about:blank" },
async browser => {
is(
await ASRouterTargeting.Environment.hasPinnedTabs,
false,
"No pin tabs yet"
);
let tab = gBrowser.getTabForBrowser(browser);
gBrowser.pinTab(tab);
is(
await ASRouterTargeting.Environment.hasPinnedTabs,
true,
"Should detect pinned tab"
);
gBrowser.unpinTab(tab);
}
);
});
add_task(async function check_hasAccessedFxAPanel() {
is(
await ASRouterTargeting.Environment.hasAccessedFxAPanel,
false,
"Not accessed yet"
);
await pushPrefs(["identity.fxaccounts.toolbar.accessed", true]);
is(
await ASRouterTargeting.Environment.hasAccessedFxAPanel,
true,
"Should detect panel access"
);
});
add_task(async function check_isFxABadgeEnabled() {
is(
await ASRouterTargeting.Environment.isFxABadgeEnabled,
true,
"Default pref value is true"
);
await pushPrefs(["browser.messaging-system.fxatoolbarbadge.enabled", false]);
is(
await ASRouterTargeting.Environment.isFxABadgeEnabled,
false,
"Value should be false according to pref"
);
});
add_task(async function check_isWhatsNewPanelEnabled() {
is(
await ASRouterTargeting.Environment.isWhatsNewPanelEnabled,
true,
"Enabled by default"
);
await pushPrefs(["browser.messaging-system.whatsNewPanel.enabled", false]);
is(
await ASRouterTargeting.Environment.isWhatsNewPanelEnabled,
false,
"Should update based on pref, e.g., for holdback"
);
});
add_task(async function checkCFRFeaturesUserPref() {
await pushPrefs([
"browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features",
false,
]);
is(
ASRouterTargeting.Environment.userPrefs.cfrFeatures,
false,
"cfrFeature should be false according to pref"
);
const message = { id: "foo", targeting: "userPrefs.cfrFeatures == false" };
is(
await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
message,
"should select correct item by cfrFeature"
);
});
add_task(async function checkCFRAddonsUserPref() {
await pushPrefs([
"browser.newtabpage.activity-stream.asrouter.userprefs.cfr.addons",
false,
]);
is(
ASRouterTargeting.Environment.userPrefs.cfrAddons,
false,
"cfrFeature should be false according to pref"
);
const message = { id: "foo", targeting: "userPrefs.cfrAddons == false" };
is(
await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
message,
"should select correct item by cfrAddons"
);
});
add_task(async function check_blockedCountByType() {
const message = {
id: "foo",
targeting:
"blockedCountByType.cryptominerCount == 0 && blockedCountByType.socialCount == 0",
};
is(
await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
message,
"should select correct item"
);
});
add_task(async function checkCFRPinnedTabsTargetting() {
const now = Date.now();
const timeMinutesAgo = numMinutes => now - numMinutes * 60 * 1000;
const messages = CFRMessageProvider.getMessages();
const trigger = {
id: "frequentVisits",
context: {
recentVisits: [
{ timestamp: timeMinutesAgo(61) },
{ timestamp: timeMinutesAgo(30) },
{ timestamp: timeMinutesAgo(1) },
],
},
param: { host: "github.com", url: "https://google.com" },
};
is(
await ASRouterTargeting.findMatchingMessage({ messages, trigger }),
undefined,
"should not select PIN_TAB mesage with only 2 visits in past hour"
);
trigger.context.recentVisits.push({ timestamp: timeMinutesAgo(59) });
is(
(await ASRouterTargeting.findMatchingMessage({ messages, trigger })).id,
"PIN_TAB",
"should select PIN_TAB mesage"
);
await BrowserTestUtils.withNewTab(
{ gBrowser, url: "about:blank" },
async browser => {
let tab = gBrowser.getTabForBrowser(browser);
gBrowser.pinTab(tab);
is(
await ASRouterTargeting.findMatchingMessage({ messages, trigger }),
undefined,
"should not select PIN_TAB mesage if there is a pinned tab already"
);
gBrowser.unpinTab(tab);
}
);
trigger.param = { host: "foo.bar", url: "https://foo.bar" };
is(
await ASRouterTargeting.findMatchingMessage({ messages, trigger }),
undefined,
"should not select PIN_TAB mesage with a trigger param/host not in our hostlist"
);
});
add_task(async function checkPatternMatches() {
const now = Date.now();
const timeMinutesAgo = numMinutes => now - numMinutes * 60 * 1000;
const messages = [
{
id: "message_with_pattern",
targeting: "true",
trigger: { id: "frequentVisits", patterns: ["*://*.github.com/"] },
},
];
const trigger = {
id: "frequentVisits",
context: {
recentVisits: [
{ timestamp: timeMinutesAgo(33) },
{ timestamp: timeMinutesAgo(17) },
{ timestamp: timeMinutesAgo(1) },
],
},
param: { host: "github.com", url: "https://gist.github.com" },
};
is(
(await ASRouterTargeting.findMatchingMessage({ messages, trigger })).id,
"message_with_pattern",
"should select PIN_TAB mesage"
);
});
add_task(async function checkPatternsValid() {
const messages = CFRMessageProvider.getMessages().filter(
m => m.trigger.patterns
);
for (const message of messages) {
Assert.ok(new MatchPatternSet(message.trigger.patterns));
}
});
================================================
FILE: test/browser/browser_asrouter_toolbarbadge.js
================================================
const { OnboardingMessageProvider } = ChromeUtils.import(
"resource://activity-stream/lib/OnboardingMessageProvider.jsm"
);
const { ToolbarBadgeHub } = ChromeUtils.import(
"resource://activity-stream/lib/ToolbarBadgeHub.jsm"
);
add_task(async function test_setup() {
// Cleanup pref value because we click the fxa accounts button.
// This is not required during tests because we "force show" the message
// by sending it directly to the Hub bypassing targeting.
registerCleanupFunction(() => {
Services.prefs.clearUserPref("identity.fxaccounts.toolbar.accessed");
});
});
add_task(async function test_fxa_badge_shown_nodelay() {
const [msg] = (await OnboardingMessageProvider.getMessages()).filter(
({ id }) => id === "FXA_ACCOUNTS_BADGE"
);
Assert.ok(msg, "FxA test message exists");
// Ensure we badge immediately
msg.content.delay = undefined;
let browserWindow = Services.wm.getMostRecentWindow("navigator:browser");
// Click the button and clear the badge that occurs normally at startup
let fxaButton = browserWindow.document.getElementById(msg.content.target);
fxaButton.click();
await BrowserTestUtils.waitForCondition(
() =>
!browserWindow.document
.getElementById(msg.content.target)
.querySelector(".toolbarbutton-badge")
.classList.contains("feature-callout"),
"Initially element is not badged"
);
ToolbarBadgeHub.registerBadgeNotificationListener(msg);
await BrowserTestUtils.waitForCondition(
() =>
browserWindow.document
.getElementById(msg.content.target)
.querySelector(".toolbarbutton-badge")
.classList.contains("feature-callout"),
"Wait for element to be badged"
);
let newWin = await BrowserTestUtils.openNewBrowserWindow();
browserWindow = Services.wm.getMostRecentWindow("navigator:browser");
await BrowserTestUtils.waitForCondition(
() =>
browserWindow.document
.getElementById(msg.content.target)
.querySelector(".toolbarbutton-badge")
.classList.contains("feature-callout"),
"Wait for element to be badged"
);
await BrowserTestUtils.closeWindow(newWin);
browserWindow = Services.wm.getMostRecentWindow("navigator:browser");
// Click the button and clear the badge
fxaButton = document.getElementById(msg.content.target);
fxaButton.click();
await BrowserTestUtils.waitForCondition(
() =>
!browserWindow.document
.getElementById(msg.content.target)
.querySelector(".toolbarbutton-badge")
.classList.contains("feature-callout"),
"Button should no longer be badged"
);
});
add_task(async function test_fxa_badge_shown_withdelay() {
const [msg] = (await OnboardingMessageProvider.getMessages()).filter(
({ id }) => id === "FXA_ACCOUNTS_BADGE"
);
Assert.ok(msg, "FxA test message exists");
// Enough to trigger the setTimeout badging
msg.content.delay = 1;
let browserWindow = Services.wm.getMostRecentWindow("navigator:browser");
// Click the button and clear the badge that occurs normally at startup
let fxaButton = browserWindow.document.getElementById(msg.content.target);
fxaButton.click();
await BrowserTestUtils.waitForCondition(
() =>
!browserWindow.document
.getElementById(msg.content.target)
.querySelector(".toolbarbutton-badge")
.classList.contains("feature-callout"),
"Initially element is not badged"
);
ToolbarBadgeHub.registerBadgeNotificationListener(msg);
await BrowserTestUtils.waitForCondition(
() =>
browserWindow.document
.getElementById(msg.content.target)
.querySelector(".toolbarbutton-badge")
.classList.contains("feature-callout"),
"Wait for element to be badged"
);
let newWin = await BrowserTestUtils.openNewBrowserWindow();
browserWindow = Services.wm.getMostRecentWindow("navigator:browser");
await BrowserTestUtils.waitForCondition(
() =>
browserWindow.document
.getElementById(msg.content.target)
.querySelector(".toolbarbutton-badge")
.classList.contains("feature-callout"),
"Wait for element to be badged"
);
await BrowserTestUtils.closeWindow(newWin);
browserWindow = Services.wm.getMostRecentWindow("navigator:browser");
// Click the button and clear the badge
fxaButton = document.getElementById(msg.content.target);
fxaButton.click();
await BrowserTestUtils.waitForCondition(
() =>
!browserWindow.document
.getElementById(msg.content.target)
.querySelector(".toolbarbutton-badge")
.classList.contains("feature-callout"),
"Button should no longer be badged"
);
});
================================================
FILE: test/browser/browser_asrouter_trigger_listeners.js
================================================
ChromeUtils.defineModuleGetter(
this,
"ASRouterTriggerListeners",
"resource://activity-stream/lib/ASRouterTriggerListeners.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"TestUtils",
"resource://testing-common/TestUtils.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"PrivateBrowsingUtils",
"resource://gre/modules/PrivateBrowsingUtils.jsm"
);
async function openURLInWindow(window, url) {
const { selectedBrowser } = window.gBrowser;
await BrowserTestUtils.loadURI(selectedBrowser, url);
await BrowserTestUtils.browserLoaded(selectedBrowser, false, url);
}
add_task(async function check_matchPatternFailureCase() {
const articleTrigger = ASRouterTriggerListeners.get("openArticleURL");
articleTrigger.uninit();
articleTrigger.init(() => {}, [], ["example.com"]);
is(
articleTrigger._matchPatternSet.matches("http://example.com"),
false,
"Should fail, bad pattern"
);
articleTrigger.init(() => {}, [], ["*://*.example.com"]);
is(
articleTrigger._matchPatternSet.matches("http://www.example.com"),
true,
"Should work, updated pattern"
);
articleTrigger.uninit();
});
add_task(async function check_openArticleURL() {
const TEST_URL =
"https://example.com/browser/browser/components/newtab/test/browser/red_page.html";
const articleTrigger = ASRouterTriggerListeners.get("openArticleURL");
// Previously initialized by the Router
articleTrigger.uninit();
// Initialize the trigger with a new triggerHandler that resolves a promise
// with the URL match
const listenerTriggered = new Promise(resolve =>
articleTrigger.init((browser, match) => resolve(match), ["example.com"])
);
const win = await BrowserTestUtils.openNewBrowserWindow();
await openURLInWindow(win, TEST_URL);
// Send a message from the content page (the TEST_URL) to the parent
// This should trigger the `receiveMessage` cb in the articleTrigger
await ContentTask.spawn(win.gBrowser.selectedBrowser, null, async () => {
sendAsyncMessage("Reader:UpdateReaderButton", { isArticle: true });
});
await listenerTriggered.then(data =>
is(
data.param.url,
TEST_URL,
"We should match on the TEST_URL as a website article"
)
);
// Cleanup
articleTrigger.uninit();
await BrowserTestUtils.closeWindow(win);
});
add_task(async function check_openURL_listener() {
const TEST_URL =
"https://example.com/browser/browser/components/newtab/test/browser/red_page.html";
let urlVisitCount = 0;
const triggerHandler = () => urlVisitCount++;
const openURLListener = ASRouterTriggerListeners.get("openURL");
// Previously initialized by the Router
openURLListener.uninit();
const normalWindow = await BrowserTestUtils.openNewBrowserWindow();
const privateWindow = await BrowserTestUtils.openNewBrowserWindow({
private: true,
});
// Initialise listener
await openURLListener.init(triggerHandler, ["example.com"]);
await openURLInWindow(normalWindow, TEST_URL);
await BrowserTestUtils.waitForCondition(
() => urlVisitCount !== 0,
"Wait for the location change listener to run"
);
is(urlVisitCount, 1, "should receive page visits from existing windows");
await openURLInWindow(normalWindow, "http://www.example.com/abc");
is(urlVisitCount, 1, "should not receive page visits for different domains");
await openURLInWindow(privateWindow, TEST_URL);
is(
urlVisitCount,
1,
"should not receive page visits from existing private windows"
);
const secondNormalWindow = await BrowserTestUtils.openNewBrowserWindow();
await openURLInWindow(secondNormalWindow, TEST_URL);
await BrowserTestUtils.waitForCondition(
() => urlVisitCount === 2,
"Wait for the location change listener to run"
);
is(urlVisitCount, 2, "should receive page visits from newly opened windows");
const secondPrivateWindow = await BrowserTestUtils.openNewBrowserWindow({
private: true,
});
await openURLInWindow(secondPrivateWindow, TEST_URL);
is(
urlVisitCount,
2,
"should not receive page visits from newly opened private windows"
);
// Uninitialise listener
openURLListener.uninit();
await openURLInWindow(normalWindow, TEST_URL);
is(
urlVisitCount,
2,
"should now not receive page visits from existing windows"
);
const thirdNormalWindow = await BrowserTestUtils.openNewBrowserWindow();
await openURLInWindow(thirdNormalWindow, TEST_URL);
is(
urlVisitCount,
2,
"should now not receive page visits from newly opened windows"
);
// Cleanup
const windows = [
normalWindow,
privateWindow,
secondNormalWindow,
secondPrivateWindow,
thirdNormalWindow,
];
await Promise.all(windows.map(win => BrowserTestUtils.closeWindow(win)));
});
add_task(async function check_newSavedLogin_listener() {
const TEST_URL =
"https://example.com/browser/browser/components/newtab/test/browser/red_page.html";
let loginsSaved = 0;
const triggerHandler = () => loginsSaved++;
const newSavedLoginListener = ASRouterTriggerListeners.get("newSavedLogin");
// Previously initialized by the Router
newSavedLoginListener.uninit();
// Initialise listener
await newSavedLoginListener.init(triggerHandler);
await BrowserTestUtils.withNewTab(
TEST_URL,
async function triggerNewSavedPassword(browser) {
Services.obs.notifyObservers(browser, "LoginStats:NewSavedPassword");
await BrowserTestUtils.waitForCondition(
() => loginsSaved !== 0,
"Wait for the observer notification to run"
);
is(loginsSaved, 1, "should receive observer notification");
}
);
// Uninitialise listener
newSavedLoginListener.uninit();
await BrowserTestUtils.withNewTab(
TEST_URL,
async function triggerNewSavedPasswordAfterUninit(browser) {
Services.obs.notifyObservers(browser, "LoginStats:NewSavedPassword");
await new Promise(resolve => executeSoon(resolve));
is(loginsSaved, 1, "shouldn't receive obs. notification after uninit");
}
);
});
add_task(async function check_trackingProtection_listener() {
const TEST_URL =
"https://example.com/browser/browser/components/newtab/test/browser/red_page.html";
const event1 = 0x0001;
const event2 = 0x0010;
const event3 = 0x0100;
const event4 = 0x1000;
// Initialise listener to listen 2 events, for any incoming event e,
// it will be triggered if and only if:
// 1. (e & event1) && (e & event2)
// 2. (e & event3)
const bindEvents = [event1 | event2, event3];
let observerEvent = 0;
let pageLoadSum = 0;
const triggerHandler = (target, trigger) => {
const {
id,
param: { host, type },
context: { pageLoad },
} = trigger;
is(id, "trackingProtection", "should match event name");
is(host, TEST_URL, "should match test URL");
is(
bindEvents.filter(e => (type & e) === e).length,
1,
`event ${type} is valid`
);
ok(pageLoadSum <= pageLoad, "pageLoad is non-decreasing");
observerEvent += 1;
pageLoadSum = pageLoad;
};
const trackingProtectionListener = ASRouterTriggerListeners.get(
"trackingProtection"
);
// Previously initialized by the Router
trackingProtectionListener.uninit();
await trackingProtectionListener.init(triggerHandler, bindEvents);
await BrowserTestUtils.withNewTab(
TEST_URL,
async function triggerTrackingProtection(browser) {
Services.obs.notifyObservers(
{
wrappedJSObject: {
browser,
host: TEST_URL,
event: event1, // won't trigger
},
},
"SiteProtection:ContentBlockingEvent"
);
}
);
is(observerEvent, 0, "shouldn't receive unrelated observer notification");
is(pageLoadSum, 0, "shouldn't receive unrelated observer notification");
await BrowserTestUtils.withNewTab(
TEST_URL,
async function triggerTrackingProtection(browser) {
Services.obs.notifyObservers(
{
wrappedJSObject: {
browser,
host: TEST_URL,
event: event3, // will trigger
},
},
"SiteProtection:ContentBlockingEvent"
);
await BrowserTestUtils.waitForCondition(
() => observerEvent !== 0,
"Wait for the observer notification to run"
);
is(observerEvent, 1, "should receive observer notification");
is(pageLoadSum, 2, "should receive observer notification");
Services.obs.notifyObservers(
{
wrappedJSObject: {
browser,
host: TEST_URL,
event: event1 | event2 | event4, // still trigger
},
},
"SiteProtection:ContentBlockingEvent"
);
await BrowserTestUtils.waitForCondition(
() => observerEvent !== 1,
"Wait for the observer notification to run"
);
is(observerEvent, 2, "should receive another observer notification");
is(pageLoadSum, 2, "should receive another observer notification");
Services.obs.notifyObservers(
{
wrappedJSObject: {
browser,
host: TEST_URL,
event: event1, // no trigger
},
},
"SiteProtection:ContentBlockingEvent"
);
await new Promise(resolve => executeSoon(resolve));
is(observerEvent, 2, "shouldn't receive unrelated notification");
is(pageLoadSum, 2, "shouldn't receive unrelated notification");
}
);
// Uninitialise listener
trackingProtectionListener.uninit();
await BrowserTestUtils.withNewTab(
TEST_URL,
async function triggerTrackingProtectionAfterUninit(browser) {
Services.obs.notifyObservers(
{
wrappedJSObject: {
browser,
host: TEST_URL,
event: event3, // wont trigger after uninit
},
},
"SiteProtection:ContentBlockingEvent"
);
await new Promise(resolve => executeSoon(resolve));
is(observerEvent, 2, "shouldn't receive obs. notification after uninit");
is(pageLoadSum, 2, "shouldn't receive obs. notification after uninit");
}
);
});
add_task(async function check_trackingProtectionMilestone_listener() {
const TEST_URL =
"https://example.com/browser/browser/components/newtab/test/browser/red_page.html";
let observerEvent = 0;
const triggerHandler = (target, trigger) => {
const {
id,
param: { host },
} = trigger;
is(id, "trackingProtection", "should match event name");
is(host, "ContentBlockingMilestone", "Should be the correct event type");
observerEvent += 1;
};
const trackingProtectionListener = ASRouterTriggerListeners.get(
"trackingProtection"
);
// Previously initialized by the Router
trackingProtectionListener.uninit();
// Initialise listener
trackingProtectionListener.init(triggerHandler, ["ContentBlockingMilestone"]);
await BrowserTestUtils.withNewTab(
TEST_URL,
async function triggerTrackingProtection(browser) {
Services.obs.notifyObservers(
{
wrappedJSObject: {
browser,
event: "Other Event",
},
},
"SiteProtection:ContentBlockingMilestone"
);
}
);
is(observerEvent, 0, "shouldn't receive unrelated observer notification");
await BrowserTestUtils.withNewTab(
TEST_URL,
async function triggerTrackingProtection(browser) {
Services.obs.notifyObservers(
{
wrappedJSObject: {
browser,
event: "ContentBlockingMilestone",
},
},
"SiteProtection:ContentBlockingMilestone"
);
await BrowserTestUtils.waitForCondition(
() => observerEvent !== 0,
"Wait for the observer notification to run"
);
is(observerEvent, 1, "should receive observer notification");
}
);
// Uninitialise listener
trackingProtectionListener.uninit();
await BrowserTestUtils.withNewTab(
TEST_URL,
async function triggerTrackingProtectionAfterUninit(browser) {
Services.obs.notifyObservers(
{
wrappedJSObject: {
browser,
event: "ContentBlockingMilestone",
},
},
"SiteProtection:ContentBlockingMilestone"
);
await new Promise(resolve => executeSoon(resolve));
is(observerEvent, 1, "shouldn't receive obs. notification after uninit");
}
);
});
================================================
FILE: test/browser/browser_asrouter_whatsnewpanel.js
================================================
const { PanelTestProvider } = ChromeUtils.import(
"resource://activity-stream/lib/PanelTestProvider.jsm"
);
const { ToolbarPanelHub } = ChromeUtils.import(
"resource://activity-stream/lib/ToolbarPanelHub.jsm"
);
add_task(async function test_messages_rendering() {
const msgs = (await PanelTestProvider.getMessages()).filter(
({ template }) => template === "whatsnew_panel_message"
);
Assert.ok(msgs.length, "FxA test message exists");
Object.defineProperty(ToolbarPanelHub, "messages", {
get: () => Promise.resolve(msgs),
configurable: true,
});
await ToolbarPanelHub.enableAppmenuButton();
const mainView = document.getElementById("appMenu-mainView");
UITour.showMenu(window, "appMenu");
await BrowserTestUtils.waitForEvent(mainView, "ViewShown");
Assert.equal(mainView.hidden, false, "Panel is visible");
const whatsNewBtn = document.getElementById("appMenu-whatsnew-button");
Assert.equal(whatsNewBtn.hidden, false, "What's New is present");
// Show the What's New Messages
whatsNewBtn.click();
const shownMessages = await BrowserTestUtils.waitForCondition(
() =>
document.getElementById("PanelUI-whatsNew-message-container") &&
document.querySelectorAll(
"#PanelUI-whatsNew-message-container .whatsNew-message"
).length
);
Assert.equal(
shownMessages,
msgs.length,
"Expected number of What's New messages rendered."
);
UITour.hideMenu(window, "appMenu");
});
================================================
FILE: test/browser/browser_discovery_render.js
================================================
"use strict";
async function before({ pushPrefs }) {
await pushPrefs([
"browser.newtabpage.activity-stream.discoverystream.config",
JSON.stringify({
collapsible: true,
enabled: true,
hardcoded_layout: true,
}),
]);
}
test_newtab({
before,
test: async function test_render_hardcoded() {
const topSites = await ContentTaskUtils.waitForCondition(() =>
content.document.querySelector(".ds-top-sites")
);
ok(topSites, "Got the discovery stream top sites section");
const learnMore = content.document.querySelector(
".ds-layout a[href$=new_tab_learn_more]"
);
is(
learnMore.textContent,
"What’s Pocket?",
"Got the rendered Message with link text and url within discovery stream"
);
},
});
================================================
FILE: test/browser/browser_discovery_styles.js
================================================
"use strict";
function fakePref(layout) {
return [
"browser.newtabpage.activity-stream.discoverystream.config",
JSON.stringify({
enabled: true,
layout_endpoint: `data:,${encodeURIComponent(JSON.stringify(layout))}`,
}),
];
}
test_newtab({
async before({ pushPrefs }) {
await pushPrefs(
fakePref({
layout: [
{
width: 12,
components: [
{
type: "TopSites",
},
{
type: "HorizontalRule",
styles: {
hr: "border-width: 3.14159mm",
},
},
],
},
],
})
);
},
test: async function test_hr_override() {
const hr = await ContentTaskUtils.waitForCondition(() =>
content.document.querySelector("hr")
);
ok(
content.getComputedStyle(hr).borderTopWidth.match(/11.?\d*px/),
"applied and normalized hr component width override"
);
},
});
test_newtab({
async before({ pushPrefs }) {
await pushPrefs(
fakePref({
layout: [
{
width: 12,
components: [
{
type: "TopSites",
},
{
type: "HorizontalRule",
styles: {
"*": "color: #f00",
"": "font-size: 1.2345cm",
hr: "font-weight: 12345",
},
},
],
},
],
})
);
},
test: async function test_multiple_overrides() {
const hr = await ContentTaskUtils.waitForCondition(() =>
content.document.querySelector("hr")
);
const styles = content.getComputedStyle(hr);
is(styles.color, "rgb(255, 0, 0)", "applied and normalized color");
is(styles.fontSize, "46.65px", "applied and normalized font size");
is(styles.fontWeight, "400", "applied and normalized font weight");
},
});
test_newtab({
async before({ pushPrefs }) {
await pushPrefs(
fakePref({
layout: [
{
width: 12,
components: [
{
type: "HorizontalRule",
styles: {
// NB: Use display: none to avoid network requests to unfiltered urls
hr: `display: none;
background-image: url(https://example.com/background);
content: url(chrome://browser/content);
cursor: url( resource://activity-stream/cursor ), auto;
list-style-image: url('https://img-getpocket.cdn.mozilla.net/list');`,
},
},
],
},
],
})
);
},
test: async function test_url_filtering() {
const hr = await ContentTaskUtils.waitForCondition(() =>
content.document.querySelector("hr")
);
const styles = content.getComputedStyle(hr);
is(
styles.backgroundImage,
"none",
"filtered out invalid background image url"
);
is(
styles.content,
`url("chrome://browser/content/browser.xul")`,
"applied, normalized and allowed content url"
);
is(
styles.cursor,
`url("resource://activity-stream/cursor"), auto`,
"applied, normalized and allowed cursor url"
);
is(
styles.listStyleImage,
`url("https://img-getpocket.cdn.mozilla.net/list")`,
"applied, normalized and allowed list style image url"
);
},
});
test_newtab({
async before({ pushPrefs }) {
await pushPrefs(
fakePref({
layout: [
{
width: 12,
components: [
{
type: "HorizontalRule",
styles: {
"@media (min-width: 0)":
"content: url(chrome://browser/content)",
"@media (min-width: 0) *":
"content: url(chrome://browser/content)",
"@media (min-width: 0) { * }":
"content: url(chrome://browser/content)",
},
},
],
},
],
})
);
},
test: async function test_atrule_filtering() {
const hr = await ContentTaskUtils.waitForCondition(() =>
content.document.querySelector("hr")
);
is(
content.getComputedStyle(hr).content,
"none",
"filtered out attempted @media query"
);
},
});
================================================
FILE: test/browser/browser_enabled_newtabpage.js
================================================
function checkSpec(uri, check, message) {
const { spec } = NetUtil.newChannel({
loadUsingSystemPrincipal: true,
uri,
}).URI;
info(`got ${spec} for ${uri}`);
check(spec, "about:blank", message);
}
add_task(async function test_newtab_enabled() {
checkSpec(
"about:newtab",
isnot,
"did not get blank for default about:newtab"
);
checkSpec("about:home", isnot, "did not get blank for default about:home");
await SpecialPowers.pushPrefEnv({
set: [["browser.newtabpage.enabled", false]],
});
checkSpec("about:newtab", is, "got blank when newtab is not enabled");
checkSpec("about:home", isnot, "still did not get blank for about:home");
});
================================================
FILE: test/browser/browser_getScreenshots.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/. */
"use strict";
// a blue page
const TEST_URL =
"https://example.com/browser/browser/components/newtab/test/browser/blue_page.html";
const XHTMLNS = "http://www.w3.org/1999/xhtml";
ChromeUtils.defineModuleGetter(
this,
"Screenshots",
"resource://activity-stream/lib/Screenshots.jsm"
);
function get_pixels_for_blob(blob, width, height) {
return new Promise(resolve => {
// get the pixels out of the screenshot that we just took
let img = document.createElementNS(XHTMLNS, "img");
let imgPath = URL.createObjectURL(blob);
img.setAttribute("src", imgPath);
img.addEventListener(
"load",
() => {
let canvas = document.createElementNS(XHTMLNS, "canvas");
canvas.setAttribute("width", width);
canvas.setAttribute("height", height);
let ctx = canvas.getContext("2d");
ctx.drawImage(img, 0, 0, width, height);
const result = ctx.getImageData(0, 0, width, height).data;
URL.revokeObjectURL(imgPath);
resolve(result);
},
{ once: true }
);
});
}
add_task(async function test_screenshot() {
await SpecialPowers.pushPrefEnv({
set: [["browser.pagethumbnails.capturing_disabled", false]],
});
// take a screenshot of a blue page and save it as a blob
const screenshotAsObject = await Screenshots.getScreenshotForURL(TEST_URL);
let pixels = await get_pixels_for_blob(screenshotAsObject.data, 10, 10);
let rgbaCount = { r: 0, g: 0, b: 0, a: 0 };
while (pixels.length) {
// break the pixels into arrays of 4 components [red, green, blue, alpha]
let [r, g, b, a, ...rest] = pixels;
pixels = rest;
// count the number of each coloured pixels
if (r === 255) {
rgbaCount.r += 1;
}
if (g === 255) {
rgbaCount.g += 1;
}
if (b === 255) {
rgbaCount.b += 1;
}
if (a === 255) {
rgbaCount.a += 1;
}
}
// in the end, we should only have 100 blue pixels (10 x 10) with full opacity
Assert.equal(rgbaCount.b, 100, "Has 100 blue pixels");
Assert.equal(rgbaCount.a, 100, "Has full opacity");
Assert.equal(rgbaCount.r, 0, "Does not have any red pixels");
Assert.equal(rgbaCount.g, 0, "Does not have any green pixels");
});
================================================
FILE: test/browser/browser_highlights_section.js
================================================
"use strict";
/**
* Helper for setup and cleanup of Highlights section tests.
* @param bookmarkCount Number of bookmark higlights to add
* @param test The test case
*/
function test_highlights(bookmarkCount, test) {
test_newtab({
async before({ tab }) {
if (bookmarkCount) {
await addHighlightsBookmarks(bookmarkCount);
// Wait for HighlightsFeed to update and display the items.
await ContentTask.spawn(tab.linkedBrowser, null, async () => {
await ContentTaskUtils.waitForCondition(
() =>
content.document.querySelector(".card-outer:not(.placeholder)"),
"No highlights cards found."
);
});
}
},
test,
async after() {
await clearHistoryAndBookmarks();
},
});
}
test_highlights(
2, // Number of highlights cards
function check_highlights_cards() {
let found = content.document.querySelectorAll(
".card-outer:not(.placeholder)"
).length;
is(found, 2, "there should be 2 highlights cards");
found = content.document.querySelectorAll(".section-list .placeholder")
.length;
is(found, 2, "there should be 1 row * 4 - 2 = 2 highlights placeholder");
found = content.document.querySelectorAll(
".card-context-icon.icon-bookmark-added"
).length;
is(found, 2, "there should be 2 bookmark icons");
}
);
test_highlights(
1, // Number of highlights cards
function check_highlights_context_menu() {
const menuButton = content.document.querySelector(
".card-outer .context-menu-button"
);
// Open the menu.
menuButton.click();
const found = content.document.querySelector(".card-outer .context-menu");
ok(found && !found.hidden, "Should find a visible context menu");
}
);
test_highlights(
1, // Number of highlights cards
async function check_highlights_context_menu() {
let found = content.document.querySelectorAll(
".card-context-icon.icon-bookmark-added"
).length;
is(found, 1, "there should be 1 bookmark icon");
const menuButton = content.document.querySelector(
".card-outer .context-menu-button"
);
// Open the menu.
menuButton.click();
const contextMenu = content.document.querySelector(
".card-outer .context-menu"
);
ok(
contextMenu && !contextMenu.hidden,
"Should find a visible context menu"
);
const removeBookmarkBtn = contextMenu.querySelector(
"button .icon-bookmark-added"
);
removeBookmarkBtn.click();
await ContentTaskUtils.waitForCondition(
() =>
content.document.querySelectorAll(
".card-context-icon.icon-bookmark-added"
).length === 0,
"no more bookmark cards should be visible"
);
}
);
================================================
FILE: test/browser/browser_newtab_overrides.js
================================================
"use strict";
XPCOMUtils.defineLazyServiceGetter(
this,
"aboutNewTabService",
"@mozilla.org/browser/aboutnewtab-service;1",
"nsIAboutNewTabService"
);
registerCleanupFunction(() => {
aboutNewTabService.resetNewTabURL();
});
function nextChangeNotificationPromise(aNewURL, testMessage) {
return TestUtils.topicObserved("newtab-url-changed", function observer(
aSubject,
aData
) {
// jshint unused:false
Assert.equal(aData, aNewURL, testMessage);
return true;
});
}
/*
* Tests that the default newtab page is always returned when one types "about:newtab" in the URL bar,
* even when overridden.
*/
add_task(async function redirector_ignores_override() {
let overrides = ["chrome://browser/content/aboutRobots.xhtml", "about:home"];
for (let overrideURL of overrides) {
let notificationPromise = nextChangeNotificationPromise(
overrideURL,
`newtab page now points to ${overrideURL}`
);
aboutNewTabService.newTabURL = overrideURL;
await notificationPromise;
Assert.ok(aboutNewTabService.overridden, "url has been overridden");
let tabOptions = {
gBrowser,
url: "about:newtab",
};
/*
* Simulate typing "about:newtab" in the url bar.
*
* Bug 1240169 - We expect the redirector to lead the user to "about:newtab", the default URL,
* due to invoking AboutRedirector. A user interacting with the chrome otherwise would lead
* to the overriding URLs.
*/
await BrowserTestUtils.withNewTab(tabOptions, async browser => {
await ContentTask.spawn(browser, {}, async () => {
Assert.equal(content.location.href, "about:newtab", "Got right URL");
Assert.equal(
content.document.location.href,
"about:newtab",
"Got right URL"
);
Assert.notEqual(
content.document.nodePrincipal,
Services.scriptSecurityManager.getSystemPrincipal(),
"activity stream principal should not match systemPrincipal"
);
});
}); // jshint ignore:line
}
});
/*
* Tests loading an overridden newtab page by simulating opening a newtab page from chrome
*/
add_task(async function override_loads_in_browser() {
let overrides = [
"chrome://browser/content/aboutRobots.xhtml",
"about:home",
" about:home",
];
for (let overrideURL of overrides) {
let notificationPromise = nextChangeNotificationPromise(
overrideURL.trim(),
`newtab page now points to ${overrideURL}`
);
aboutNewTabService.newTabURL = overrideURL;
await notificationPromise;
Assert.ok(aboutNewTabService.overridden, "url has been overridden");
// simulate a newtab open as a user would
BrowserOpenTab(); // jshint ignore:line
let browser = gBrowser.selectedBrowser;
await BrowserTestUtils.browserLoaded(browser);
await ContentTask.spawn(browser, { url: overrideURL }, async args => {
Assert.equal(content.location.href, args.url.trim(), "Got right URL");
Assert.equal(
content.document.location.href,
args.url.trim(),
"Got right URL"
);
}); // jshint ignore:line
BrowserTestUtils.removeTab(gBrowser.selectedTab);
}
});
/*
* Tests edge cases when someone overrides the newtabpage with whitespace
*/
add_task(async function override_blank_loads_in_browser() {
let overrides = ["", " ", "\n\t", " about:blank"];
for (let overrideURL of overrides) {
let notificationPromise = nextChangeNotificationPromise(
"about:blank",
"newtab page now points to about:blank"
);
aboutNewTabService.newTabURL = overrideURL;
await notificationPromise;
Assert.ok(aboutNewTabService.overridden, "url has been overridden");
// simulate a newtab open as a user would
BrowserOpenTab(); // jshint ignore:line
let browser = gBrowser.selectedBrowser;
await BrowserTestUtils.browserLoaded(browser);
await ContentTask.spawn(browser, {}, async () => {
Assert.equal(content.location.href, "about:blank", "Got right URL");
Assert.equal(
content.document.location.href,
"about:blank",
"Got right URL"
);
}); // jshint ignore:line
BrowserTestUtils.removeTab(gBrowser.selectedTab);
}
});
================================================
FILE: test/browser/browser_onboarding_rtamo.js
================================================
const { ASRouter } = ChromeUtils.import(
"resource://activity-stream/lib/ASRouter.jsm"
);
const { OnboardingMessageProvider } = ChromeUtils.import(
"resource://activity-stream/lib/OnboardingMessageProvider.jsm"
);
const { AttributionCode } = ChromeUtils.import(
"resource:///modules/AttributionCode.jsm"
);
const BRANCH_PREF = "trailhead.firstrun.branches";
async function setRTAMOOnboarding() {
await ASRouter.forceAttribution({
campaign: "non-fx-button",
source: "addons.mozilla.org",
content: "iridium@particlecore.github.io",
});
AttributionCode._clearCache();
const data = await AttributionCode.getAttrDataAsync();
Assert.equal(
data.source,
"addons.mozilla.org",
"Attribution data should be set"
);
Services.prefs.setCharPref(BRANCH_PREF, "join-supercharge");
// Reset trailhead so it loads the new branch.
Services.prefs.clearUserPref("trailhead.firstrun.didSeeAboutWelcome");
await ASRouter.setState({ trailheadInitialized: false });
ASRouter._updateMessageProviders();
await ASRouter.loadMessagesFromAllProviders();
registerCleanupFunction(async () => {
// Separate cleanup methods between mac and windows
if (AppConstants.platform === "macosx") {
const { path } = Services.dirsvc.get("GreD", Ci.nsIFile).parent.parent;
const attributionSvc = Cc["@mozilla.org/mac-attribution;1"].getService(
Ci.nsIMacAttributionService
);
attributionSvc.setReferrerUrl(path, "", true);
}
// Clear cache call is only possible in a testing environment
let env = Cc["@mozilla.org/process/environment;1"].getService(
Ci.nsIEnvironment
);
env.set("XPCSHELL_TEST_PROFILE_DIR", "testing");
Services.prefs.clearUserPref(BRANCH_PREF);
await AttributionCode.deleteFileAsync();
AttributionCode._clearCache();
});
}
add_task(async function setup() {
// Store it in order to restore to the original value
const { getAddonInfo } = OnboardingMessageProvider;
// Prevent fetching the real addon url and making a network request
OnboardingMessageProvider.getAddonInfo = () => ({
name: "mochitest_name",
iconURL: "mochitest_iconURL",
url: "https://example.com",
});
registerCleanupFunction(() => {
OnboardingMessageProvider.getAddonInfo = getAddonInfo;
});
});
add_task(async () => {
await setRTAMOOnboarding();
let tab = await BrowserTestUtils.openNewForegroundTab(
gBrowser,
"about:welcome",
false
);
let browser = tab.linkedBrowser;
await ContentTask.spawn(browser, {}, async () => {
// Wait for Activity Stream to load
await ContentTaskUtils.waitForCondition(
() => content.document.querySelector(".activity-stream"),
`Should render Activity Stream`
);
await ContentTaskUtils.waitForCondition(
() => content.document.body.classList.contains("welcome"),
"The modal setup should be completed"
);
await ContentTaskUtils.waitForCondition(
() => content.document.body.classList.contains("hide-main"),
"You shouldn't be able to see newtabpage content"
);
for (let selector of [
// ReturnToAMO elements
".ReturnToAMOOverlay",
".ReturnToAMOContainer",
".ReturnToAMOAddonContents",
".ReturnToAMOIcon",
]) {
ok(content.document.querySelector(selector), `Should render ${selector}`);
}
// Make sure strings are properly shown
Assert.equal(
content.document.querySelector(".ReturnToAMOText").innerText,
"Now let’s get you mochitest_name."
);
});
BrowserTestUtils.removeTab(tab);
});
================================================
FILE: test/browser/browser_topsites_contextMenu_options.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/. */
"use strict";
test_newtab({
before: setDefaultTopSites,
// Test verifies the menu options for a default top site.
test: async function defaultTopSites_menuOptions() {
const siteSelector =
".top-site-outer:not(.search-shortcut):not(.placeholder)";
await ContentTaskUtils.waitForCondition(
() => content.document.querySelector(siteSelector),
"Topsite tippytop icon not found"
);
const contextMenuItems = await content.openContextMenuAndGetOptions(
siteSelector
);
const contextMenuItemsText = contextMenuItems.map(v => v.textContent);
Assert.equal(
contextMenuItemsText.length,
5,
"Number of options is correct"
);
const expectedItemsText = [
"Pin",
"Edit",
"Open in a New Window",
"Open in a New Private Window",
"Dismiss",
];
for (let i = 0; i < contextMenuItemsText.length; i++) {
Assert.equal(
contextMenuItemsText[i],
expectedItemsText[i],
"Name option is correct"
);
}
},
});
test_newtab({
before: setDefaultTopSites,
// Test verifies that the next top site in queue replaces a dismissed top site.
test: async function defaultTopSites_dismiss() {
const siteSelector =
".top-site-outer:not(.search-shortcut):not(.placeholder)";
await ContentTaskUtils.waitForCondition(
() => content.document.querySelector(siteSelector),
"Topsite tippytop icon not found"
);
// Don't count search topsites
const defaultTopSitesNumber = content.document.querySelectorAll(
siteSelector
).length;
Assert.equal(defaultTopSitesNumber, 5, "5 top sites are loaded by default");
// Skip the search topsites select the second default topsite
const secondTopSite = content.document
.querySelectorAll(siteSelector)[1]
.getAttribute("href");
const contextMenuItems = await content.openContextMenuAndGetOptions(
siteSelector
);
Assert.equal(
contextMenuItems[4].textContent,
"Dismiss",
"'Dismiss' is the 5th item in the context menu list"
);
contextMenuItems[4].querySelector("button").click();
// Wait for the topsite to be dismissed and the second one to replace it
await ContentTaskUtils.waitForCondition(
() =>
content.document.querySelector(siteSelector).getAttribute("href") ===
secondTopSite,
"First default topsite was dismissed"
);
await ContentTaskUtils.waitForCondition(
() => content.document.querySelectorAll(siteSelector).length === 4,
"4 top sites are displayed after one of them is dismissed"
);
},
async after() {
await new Promise(resolve => NewTabUtils.undoAll(resolve));
},
});
test_newtab({
before: setDefaultTopSites,
test: async function searchTopSites_dismiss() {
const siteSelector = ".search-shortcut";
await ContentTaskUtils.waitForCondition(
() => content.document.querySelectorAll(siteSelector).length === 1,
"1 search topsites is loaded by default"
);
const contextMenuItems = await content.openContextMenuAndGetOptions(
siteSelector
);
is(
contextMenuItems.length,
2,
"Search TopSites should only have Unpin and Dismiss"
);
// Unpin
contextMenuItems[0].querySelector("button").click();
await ContentTaskUtils.waitForCondition(
() => content.document.querySelectorAll(siteSelector).length === 1,
"1 search topsite displayed after we unpin the other one"
);
},
after: () => {
// Required for multiple test runs in the same browser, pref is used to
// prevent pinning the same search topsite twice
Services.prefs.clearUserPref(
"browser.newtabpage.activity-stream.improvesearch.topSiteSearchShortcuts.havePinned"
);
},
});
================================================
FILE: test/browser/browser_topsites_section.js
================================================
"use strict";
// Check TopSites edit modal and overlay show up.
test_newtab(
// it should be able to click the topsites add button to reveal the add top site modal and overlay.
function topsites_edit() {
// Open the section context menu.
content.document
.querySelector(".top-sites .section-top-bar .context-menu-button")
.click();
let contextMenu = content.document.querySelector(
".top-sites .section-top-bar .context-menu"
);
ok(contextMenu, "Should find a visible topsite context menu");
const topsitesAddBtn = content.document.querySelector(
".top-sites .context-menu-item button"
);
topsitesAddBtn.click();
let found = content.document.querySelector(".topsite-form");
ok(found && !found.hidden, "Should find a visible topsite form");
found = content.document.querySelector(".modalOverlayOuter");
ok(found && !found.hidden, "Should find a visible overlay");
}
);
// Test pin/unpin context menu options.
test_newtab({
before: setDefaultTopSites,
// it should pin the website when we click the first option of the topsite context menu.
test: async function topsites_pin_unpin() {
const siteSelector =
".top-site-outer:not(.search-shortcut):not(.placeholder)";
await ContentTaskUtils.waitForCondition(
() => content.document.querySelector(siteSelector),
"Topsite tippytop icon not found"
);
// There are only topsites on the page, the selector with find the first topsite menu button.
let topsiteEl = content.document.querySelector(siteSelector);
let topsiteContextBtn = topsiteEl.querySelector(".context-menu-button");
topsiteContextBtn.click();
await ContentTaskUtils.waitForCondition(
() => topsiteEl.querySelector(".top-sites-list .context-menu"),
"No context menu found"
);
let contextMenu = topsiteEl.querySelector(".top-sites-list .context-menu");
ok(contextMenu, "Should find a topsite context menu");
const pinUnpinTopsiteBtn = contextMenu.querySelector(
".top-sites .context-menu-item button"
);
// Pin the topsite.
pinUnpinTopsiteBtn.click();
// Need to wait for pin action.
await ContentTaskUtils.waitForCondition(
() => topsiteEl.querySelector(".icon-pin-small"),
"No pinned icon found"
);
let pinnedIcon = topsiteEl.querySelectorAll(".icon-pin-small").length;
is(pinnedIcon, 1, "should find 1 pinned topsite");
// Unpin the topsite.
topsiteContextBtn = topsiteEl.querySelector(".context-menu-button");
ok(topsiteContextBtn, "Should find a context menu button");
topsiteContextBtn.click();
topsiteEl.querySelector(".context-menu-item button").click();
// Need to wait for unpin action.
await ContentTaskUtils.waitForCondition(
() => !topsiteEl.querySelector(".icon-pin-small"),
"Topsite should be unpinned"
);
},
});
// Check Topsites add
test_newtab({
before: setDefaultTopSites,
// it should be able to click the topsites edit button to reveal the edit topsites modal and overlay.
test: async function topsites_add() {
let nativeInputValueSetter = Object.getOwnPropertyDescriptor(
content.window.HTMLInputElement.prototype,
"value"
).set;
let event = new content.Event("input", { bubbles: true });
// Find the add topsites button
content.document
.querySelector(".top-sites .section-top-bar .context-menu-button")
.click();
let contextMenu = content.document.querySelector(
".top-sites .section-top-bar .context-menu"
);
ok(contextMenu, "Should find a visible topsite context menu");
const topsitesAddBtn = content.document.querySelector(
".top-sites .context-menu-item button"
);
topsitesAddBtn.click();
let found = content.document.querySelector(".modalOverlayOuter");
ok(found && !found.hidden, "Should find a visible overlay");
// Write field title
let fieldTitle = content.document.querySelector(".field input");
ok(fieldTitle && !fieldTitle.hidden, "Should find field title input");
nativeInputValueSetter.call(fieldTitle, "Bugzilla");
fieldTitle.dispatchEvent(event);
is(fieldTitle.value, "Bugzilla", "The field title should match");
// Write field url
let fieldURL = content.document.querySelector(".field.url input");
ok(fieldURL && !fieldURL.hidden, "Should find field url input");
nativeInputValueSetter.call(fieldURL, "https://bugzilla.mozilla.org");
fieldURL.dispatchEvent(event);
is(
fieldURL.value,
"https://bugzilla.mozilla.org",
"The field url should match"
);
// Click the "Add" button
let addBtn = content.document.querySelector(".done");
addBtn.click();
// Wait for Topsite to be populated
await ContentTaskUtils.waitForCondition(
() =>
content.document
.querySelector(".top-site-outer:first-child a")
.getAttribute("href") === "https://bugzilla.mozilla.org",
"No Topsite found"
);
// Remove topsite after test is complete
let topsiteContextBtn = content.document.querySelector(
".top-sites-list .context-menu-button"
);
topsiteContextBtn.click();
await ContentTaskUtils.waitForCondition(
() => content.document.querySelector(".top-sites-list .context-menu"),
"No context menu found"
);
let contextMen = content.document.querySelector(
".top-sites-list .context-menu"
);
const dismissBtn = contextMen.querySelector(
".top-sites .context-menu-item button .icon-dismiss"
);
dismissBtn.click();
// Wait for Topsite to be removed
await ContentTaskUtils.waitForCondition(
() =>
content.document
.querySelector(".top-site-outer:first-child a")
.getAttribute("href") !== "https://bugzilla.mozilla.org",
"Topsite not removed"
);
},
});
test_newtab({
before: setDefaultTopSites,
test: async function test_search_topsite_keyword() {
await ContentTaskUtils.waitForCondition(
() => content.document.querySelector(".search-shortcut .title.pinned"),
"Wait for pinned search topsites"
);
const searchTopSites = content.document.querySelectorAll(".title.pinned");
ok(
searchTopSites.length >= 1,
"There should be at least 2 search topsites"
);
searchTopSites[0].click();
return searchTopSites[0].innerText;
},
after(searchTopSiteTag) {
ok(
gURLBar.focused,
"We clicked a search topsite the focus should be in location bar"
);
ok(
gURLBar.value.includes(searchTopSiteTag),
"Should contain the tag of the search topsite clicked"
);
},
});
================================================
FILE: test/browser/head.js
================================================
"use strict";
ChromeUtils.defineModuleGetter(
this,
"PlacesTestUtils",
"resource://testing-common/PlacesTestUtils.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"QueryCache",
"resource://activity-stream/lib/ASRouterTargeting.jsm"
);
function popPrefs() {
return SpecialPowers.popPrefEnv();
}
function pushPrefs(...prefs) {
return SpecialPowers.pushPrefEnv({ set: prefs });
}
// eslint-disable-next-line no-unused-vars
async function setDefaultTopSites() {
// The pref for TopSites is empty by default.
await pushPrefs([
"browser.newtabpage.activity-stream.default.sites",
"https://www.youtube.com/,https://www.facebook.com/,https://www.amazon.com/,https://www.reddit.com/,https://www.wikipedia.org/,https://twitter.com/",
]);
// Toggle the feed off and on as a workaround to read the new prefs.
await pushPrefs(["browser.newtabpage.activity-stream.feeds.topsites", false]);
await pushPrefs(["browser.newtabpage.activity-stream.feeds.topsites", true]);
await pushPrefs([
"browser.newtabpage.activity-stream.improvesearch.topSiteSearchShortcuts",
true,
]);
}
// eslint-disable-next-line no-unused-vars
async function clearHistoryAndBookmarks() {
await PlacesUtils.bookmarks.eraseEverything();
await PlacesUtils.history.clear();
QueryCache.expireAll();
}
/**
* Helper to wait for potentially preloaded browsers to "load" where a preloaded
* page has already loaded and won't trigger "load", and a "load"ed page might
* not necessarily have had all its javascript/render logic executed.
*/
async function waitForPreloaded(browser) {
let readyState = await ContentTask.spawn(
browser,
{},
() => content.document.readyState
);
if (readyState !== "complete") {
await BrowserTestUtils.browserLoaded(browser);
}
}
/**
* Helper to force the HighlightsFeed to update.
*/
function refreshHighlightsFeed() {
// Toggling the pref will clear the feed cache and force a places query.
Services.prefs.setBoolPref(
"browser.newtabpage.activity-stream.feeds.section.highlights",
false
);
Services.prefs.setBoolPref(
"browser.newtabpage.activity-stream.feeds.section.highlights",
true
);
}
/**
* Helper to populate the Highlights section with bookmark cards.
* @param count Number of items to add.
*/
// eslint-disable-next-line no-unused-vars
async function addHighlightsBookmarks(count) {
const bookmarks = new Array(count).fill(null).map((entry, i) => ({
parentGuid: PlacesUtils.bookmarks.unfiledGuid,
title: "foo",
url: `https://mozilla${i}.com/nowNew`,
}));
for (let placeInfo of bookmarks) {
await PlacesUtils.bookmarks.insert(placeInfo);
// Bookmarks need at least one visit to show up as highlights.
await PlacesTestUtils.addVisits(placeInfo.url);
}
// Force HighlightsFeed to make a request for the new items.
refreshHighlightsFeed();
}
/**
* Helper to add various helpers to the content process by injecting variables
* and functions to the `content` global.
*/
function addContentHelpers() {
const { document } = content;
Object.assign(content, {
/**
* Click the context menu button for an item and get its options list.
*
* @param selector {String} Selector to get an item (e.g., top site, card)
* @return {Array} The nodes for the options.
*/
async openContextMenuAndGetOptions(selector) {
const item = document.querySelector(selector);
const contextButton = item.querySelector(".context-menu-button");
contextButton.click();
// Gives fluent-dom the time to render strings
await new Promise(r => content.requestAnimationFrame(r));
const contextMenu = item.querySelector(".context-menu");
const contextMenuList = contextMenu.querySelector(".context-menu-list");
return [...contextMenuList.getElementsByClassName("context-menu-item")];
},
});
}
/**
* Helper to run Activity Stream about:newtab test tasks in content.
*
* @param testInfo {Function|Object}
* {Function} This parameter will be used as if the function were called with
* an Object with this parameter as "test" key's value.
* {Object} The following keys are expected:
* before {Function} Optional. Runs before and returns an arg for "test"
* test {Function} The test to run in the about:newtab content task taking
* an arg from "before" and returns a result to "after"
* after {Function} Optional. Runs after and with the result of "test"
*/
// eslint-disable-next-line no-unused-vars
function test_newtab(testInfo) {
// Extract any test parts or default to just the single content task
let { before, test: contentTask, after } = testInfo;
if (!before) {
before = () => ({});
}
if (!contentTask) {
contentTask = testInfo;
}
if (!after) {
after = () => {};
}
// Helper to push prefs for just this test and pop them when done
let needPopPrefs = false;
let scopedPushPrefs = async (...args) => {
needPopPrefs = true;
await pushPrefs(...args);
};
let scopedPopPrefs = async () => {
if (needPopPrefs) {
await popPrefs();
}
};
// Make the test task with optional before/after and content task to run in a
// new tab that opens and closes.
let testTask = async () => {
// Open about:newtab without using the default load listener
let tab = await BrowserTestUtils.openNewForegroundTab(
gBrowser,
"about:newtab",
false
);
// Specially wait for potentially preloaded browsers
let browser = tab.linkedBrowser;
await waitForPreloaded(browser);
// Add shared helpers to the content process
ContentTask.spawn(browser, {}, addContentHelpers);
// Wait for React to render something
await BrowserTestUtils.waitForCondition(
() =>
ContentTask.spawn(
browser,
{},
() => content.document.getElementById("root").children.length
),
"Should render activity stream content"
);
// Chain together before -> contentTask -> after data passing
try {
let contentArg = await before({ pushPrefs: scopedPushPrefs, tab });
let contentResult = await ContentTask.spawn(
browser,
contentArg,
contentTask
);
await after(contentResult);
} finally {
// Clean up for next tests
await scopedPopPrefs();
BrowserTestUtils.removeTab(tab);
}
};
// Copy the name of the content task to identify the test
Object.defineProperty(testTask, "name", { value: contentTask.name });
add_task(testTask);
}
================================================
FILE: test/browser/red_page.html
================================================
================================================
FILE: test/schemas/pings.js
================================================
import { CONTENT_MESSAGE_TYPE, MAIN_MESSAGE_TYPE } from "common/Actions.jsm";
import Joi from "joi-browser";
export const baseKeys = {
// client_id will be set by PingCentre if it doesn't exist.
client_id: Joi.string().optional(),
addon_version: Joi.string().required(),
locale: Joi.string().required(),
session_id: Joi.string(),
page: Joi.valid([
"about:home",
"about:newtab",
"about:welcome",
"both",
"unknown",
]),
user_prefs: Joi.number()
.integer()
.required(),
};
export const BasePing = Joi.object()
.keys(baseKeys)
.options({ allowUnknown: true });
export const eventsTelemetryExtraKeys = Joi.object()
.keys({
session_id: baseKeys.session_id.required(),
page: baseKeys.page.required(),
addon_version: baseKeys.addon_version.required(),
user_prefs: baseKeys.user_prefs.required(),
action_position: Joi.string().optional(),
})
.options({ allowUnknown: false });
export const UserEventPing = Joi.object().keys(
Object.assign({}, baseKeys, {
session_id: baseKeys.session_id.required(),
page: baseKeys.page.required(),
source: Joi.string(),
event: Joi.string().required(),
action: Joi.valid("activity_stream_user_event").required(),
metadata_source: Joi.string(),
highlight_type: Joi.valid(["bookmarks", "recommendation", "history"]),
recommender_type: Joi.string(),
value: Joi.object().keys({
newtab_url_category: Joi.string(),
newtab_extension_id: Joi.string(),
home_url_category: Joi.string(),
home_extension_id: Joi.string(),
}),
})
);
export const UTUserEventPing = Joi.array().items(
Joi.string()
.required()
.valid("activity_stream"),
Joi.string()
.required()
.valid("event"),
Joi.string()
.required()
.valid([
"CLICK",
"SEARCH",
"BLOCK",
"DELETE",
"DELETE_CONFIRM",
"DIALOG_CANCEL",
"DIALOG_OPEN",
"OPEN_NEW_WINDOW",
"OPEN_PRIVATE_WINDOW",
"OPEN_NEWTAB_PREFS",
"CLOSE_NEWTAB_PREFS",
"BOOKMARK_DELETE",
"BOOKMARK_ADD",
"PIN",
"UNPIN",
"SAVE_TO_POCKET",
]),
Joi.string().required(),
eventsTelemetryExtraKeys
);
// Use this to validate actions generated from Redux
export const UserEventAction = Joi.object().keys({
type: Joi.string().required(),
data: Joi.object()
.keys({
event: Joi.valid([
"CLICK",
"SEARCH",
"SEARCH_HANDOFF",
"BLOCK",
"DELETE",
"DELETE_CONFIRM",
"DIALOG_CANCEL",
"DIALOG_OPEN",
"OPEN_NEW_WINDOW",
"OPEN_PRIVATE_WINDOW",
"OPEN_NEWTAB_PREFS",
"CLOSE_NEWTAB_PREFS",
"BOOKMARK_DELETE",
"BOOKMARK_ADD",
"PIN",
"PREVIEW_REQUEST",
"UNPIN",
"SAVE_TO_POCKET",
"MENU_MOVE_UP",
"MENU_MOVE_DOWN",
"SCREENSHOT_REQUEST",
"MENU_REMOVE",
"MENU_COLLAPSE",
"MENU_EXPAND",
"MENU_MANAGE",
"MENU_ADD_TOPSITE",
"MENU_PRIVACY_NOTICE",
"DELETE_FROM_POCKET",
"ARCHIVE_FROM_POCKET",
"SKIPPED_SIGNIN",
"SUBMIT_EMAIL",
"SUBMIT_SIGNIN",
"SHOW_PRIVACY_INFO",
"CLICK_PRIVACY_INFO",
]).required(),
source: Joi.valid(["TOP_SITES", "TOP_STORIES", "HIGHLIGHTS"]),
action_position: Joi.number().integer(),
value: Joi.object().keys({
icon_type: Joi.valid([
"tippytop",
"rich_icon",
"screenshot_with_icon",
"screenshot",
"no_image",
"custom_screenshot",
]),
card_type: Joi.valid([
"bookmark",
"trending",
"pinned",
"pocket",
"search",
"spoc",
"organic",
]),
search_vendor: Joi.valid(["google", "amazon"]),
has_flow_params: Joi.bool(),
}),
})
.required(),
meta: Joi.object()
.keys({
to: Joi.valid(MAIN_MESSAGE_TYPE).required(),
from: Joi.valid(CONTENT_MESSAGE_TYPE).required(),
})
.required(),
});
export const UndesiredPing = Joi.object().keys(
Object.assign({}, baseKeys, {
source: Joi.string().required(),
event: Joi.string().required(),
action: Joi.valid("activity_stream_undesired_event").required(),
value: Joi.number().required(),
})
);
export const TileSchema = Joi.object().keys({
id: Joi.number()
.integer()
.required(),
pos: Joi.number().integer(),
});
export const ImpressionStatsPing = Joi.object().keys(
Object.assign({}, baseKeys, {
source: Joi.string().required(),
impression_id: Joi.string().required(),
client_id: Joi.valid("n/a").required(),
session_id: Joi.valid("n/a").required(),
action: Joi.valid("activity_stream_impression_stats").required(),
tiles: Joi.array()
.items(TileSchema)
.required(),
click: Joi.number().integer(),
block: Joi.number().integer(),
pocket: Joi.number().integer(),
})
);
export const SpocsFillEntrySchema = Joi.object().keys({
id: Joi.number()
.integer()
.required(),
displayed: Joi.number()
.integer()
.required(),
reason: Joi.string().required(),
full_recalc: Joi.number()
.integer()
.required(),
});
export const SpocsFillPing = Joi.object().keys(
Object.assign({}, baseKeys, {
impression_id: Joi.string().required(),
session_id: Joi.valid("n/a").required(),
spoc_fills: Joi.array()
.items(SpocsFillEntrySchema)
.required(),
})
);
export const PerfPing = Joi.object().keys(
Object.assign({}, baseKeys, {
source: Joi.string(),
event: Joi.string().required(),
action: Joi.valid("activity_stream_performance_event").required(),
value: Joi.number().required(),
})
);
export const SessionPing = Joi.object().keys(
Object.assign({}, baseKeys, {
session_id: baseKeys.session_id.required(),
page: baseKeys.page.required(),
session_duration: Joi.number().integer(),
action: Joi.valid("activity_stream_session").required(),
perf: Joi.object()
.keys({
// How long it took in ms for data to be ready for display.
highlights_data_late_by_ms: Joi.number().positive(),
// Timestamp of the action perceived by the user to trigger the load
// of this page.
//
// Not required at least for the error cases where the
// observer event doesn't fire
load_trigger_ts: Joi.number()
.positive()
.notes(["server counter", "server counter alert"]),
// What was the perceived trigger of the load action?
//
// Not required at least for the error cases where the observer event
// doesn't fire
load_trigger_type: Joi.valid([
"first_window_opened",
"menu_plus_or_keyboard",
"unexpected",
])
.notes(["server counter", "server counter alert"])
.required(),
// How long it took in ms for data to be ready for display.
topsites_data_late_by_ms: Joi.number().positive(),
// When did the topsites element finish painting? Note that, at least for
// the first tab to be loaded, and maybe some others, this will be before
// topsites has yet to receive screenshots updates from the add-on code,
// and is therefore just showing placeholder screenshots.
topsites_first_painted_ts: Joi.number()
.positive()
.notes(["server counter", "server counter alert"]),
// Information about the quality of TopSites images and icons.
topsites_icon_stats: Joi.object().keys({
custom_screenshot: Joi.number(),
rich_icon: Joi.number(),
screenshot: Joi.number(),
screenshot_with_icon: Joi.number(),
tippytop: Joi.number(),
no_image: Joi.number(),
}),
// The count of pinned Top Sites.
topsites_pinned: Joi.number(),
// The count of search shortcut Top Sites.
topsites_search_shortcuts: Joi.number(),
// When the page itself receives an event that document.visibilityState
// == visible.
//
// Not required at least for the (error?) case where the
// visibility_event doesn't fire. (It's not clear whether this
// can happen in practice, but if it does, we'd like to know about it).
visibility_event_rcvd_ts: Joi.number()
.positive()
.notes(["server counter", "server counter alert"]),
// The boolean to signify whether the page is preloaded or not.
is_preloaded: Joi.bool().required(),
})
.required(),
})
);
export const ASRouterEventPing = Joi.object()
.keys({
addon_version: Joi.string().required(),
locale: Joi.string().required(),
message_id: Joi.string().required(),
event: Joi.string().required(),
client_id: Joi.string(),
impression_id: Joi.string(),
})
.or("client_id", "impression_id");
export const UTSessionPing = Joi.array().items(
Joi.string()
.required()
.valid("activity_stream"),
Joi.string()
.required()
.valid("end"),
Joi.string()
.required()
.valid("session"),
Joi.string().required(),
eventsTelemetryExtraKeys
);
export const trailheadEnrollExtraKeys = Joi.object()
.keys({
experimentType: Joi.string().required(),
branch: Joi.string().required(),
})
.options({ allowUnknown: false });
export const UTTrailheadEnrollPing = Joi.array().items(
Joi.string()
.required()
.valid("activity_stream"),
Joi.string()
.required()
.valid("enroll"),
Joi.string()
.required()
.valid("preference_study"),
Joi.string().required(),
trailheadEnrollExtraKeys
);
export function chaiAssertions(_chai, utils) {
const { Assertion } = _chai;
Assertion.addMethod("validate", function(schema, schemaName) {
const { error } = Joi.validate(this._obj, schema, { allowUnknown: false });
this.assert(
!error,
`Expected to be ${
schemaName ? `a valid ${schemaName}` : "valid"
} but there were errors: ${error}`
);
});
const assertions = {
/**
* assert.validate - Validates an item given a Joi schema
*
* @param {any} actual The item to validate
* @param {obj} schema A Joi schema
*/
validate(actual, schema, schemaName) {
new Assertion(actual).validate(schema, schemaName);
},
/**
* isUserEventAction - Passes if the item is a valid UserEvent action
*
* @param {any} actual The item to validate
*/
isUserEventAction(actual) {
new Assertion(actual).validate(UserEventAction, "UserEventAction");
},
};
Object.assign(_chai.assert, assertions);
}
================================================
FILE: test/unit/asrouter/ASRouter.test.js
================================================
import { _ASRouter, MessageLoaderUtils } from "lib/ASRouter.jsm";
import { ASRouterTargeting, QueryCache } from "lib/ASRouterTargeting.jsm";
import {
CHILD_TO_PARENT_MESSAGE_NAME,
FAKE_LOCAL_MESSAGES,
FAKE_LOCAL_PROVIDER,
FAKE_LOCAL_PROVIDERS,
FAKE_RECOMMENDATION,
FAKE_REMOTE_MESSAGES,
FAKE_REMOTE_PROVIDER,
FAKE_REMOTE_SETTINGS_PROVIDER,
FakeRemotePageManager,
PARENT_TO_CHILD_MESSAGE_NAME,
} from "./constants";
import { actionCreators as ac } from "common/Actions.jsm";
import {
ASRouterPreferences,
TARGETING_PREFERENCES,
} from "lib/ASRouterPreferences.jsm";
import { ASRouterTriggerListeners } from "lib/ASRouterTriggerListeners.jsm";
import { CFRPageActions } from "lib/CFRPageActions.jsm";
import { GlobalOverrider } from "test/unit/utils";
import { PanelTestProvider } from "lib/PanelTestProvider.jsm";
import ProviderResponseSchema from "content-src/asrouter/schemas/provider-response.schema.json";
import { SnippetsTestMessageProvider } from "lib/SnippetsTestMessageProvider.jsm";
const OUTGOING_MESSAGE_NAME = "ASRouter:parent-to-child";
const MESSAGE_PROVIDER_PREF_NAME =
"browser.newtabpage.activity-stream.asrouter.providers.snippets";
const FAKE_PROVIDERS = [
FAKE_LOCAL_PROVIDER,
FAKE_REMOTE_PROVIDER,
FAKE_REMOTE_SETTINGS_PROVIDER,
];
const FAKE_BUNDLE = [FAKE_LOCAL_MESSAGES[1], FAKE_LOCAL_MESSAGES[2]];
const ONE_DAY_IN_MS = 24 * 60 * 60 * 1000;
const FAKE_RESPONSE_HEADERS = { get() {} };
const USE_REMOTE_L10N_PREF =
"browser.newtabpage.activity-stream.asrouter.useRemoteL10n";
// Creates a message object that looks like messages returned by
// RemotePageManager listeners
function fakeAsyncMessage(action) {
return { data: action, target: new FakeRemotePageManager() };
}
// Create a message that looks like a user action
function fakeExecuteUserAction(action) {
return fakeAsyncMessage({ data: action, type: "USER_ACTION" });
}
describe("ASRouter", () => {
let Router;
let globals;
let channel;
let sandbox;
let messageBlockList;
let providerBlockList;
let messageImpressions;
let providerImpressions;
let previousSessionEnd;
let fetchStub;
let clock;
let getStringPrefStub;
let dispatchStub;
let fakeAttributionCode;
let FakeBookmarkPanelHub;
let FakeToolbarBadgeHub;
let FakeToolbarPanelHub;
let personalizedCfrScores;
function createFakeStorage() {
const getStub = sandbox.stub();
getStub.returns(Promise.resolve());
getStub
.withArgs("messageBlockList")
.returns(Promise.resolve(messageBlockList));
getStub
.withArgs("providerBlockList")
.returns(Promise.resolve(providerBlockList));
getStub
.withArgs("messageImpressions")
.returns(Promise.resolve(messageImpressions));
getStub
.withArgs("providerImpressions")
.returns(Promise.resolve(providerImpressions));
getStub
.withArgs("previousSessionEnd")
.returns(Promise.resolve(previousSessionEnd));
return {
get: getStub,
set: sandbox.stub().returns(Promise.resolve()),
};
}
function setMessageProviderPref(value) {
sandbox.stub(ASRouterPreferences, "providers").get(() => value);
}
async function createRouterAndInit(providers = FAKE_PROVIDERS) {
setMessageProviderPref(providers);
channel = new FakeRemotePageManager();
dispatchStub = sandbox.stub();
// `.freeze` to catch any attempts at modifying the object
Router = new _ASRouter(Object.freeze(FAKE_LOCAL_PROVIDERS));
await Router.init(channel, createFakeStorage(), dispatchStub);
}
beforeEach(async () => {
globals = new GlobalOverrider();
messageBlockList = [];
providerBlockList = [];
messageImpressions = {};
providerImpressions = {};
previousSessionEnd = 100;
sandbox = sinon.createSandbox();
personalizedCfrScores = {};
sandbox.spy(ASRouterPreferences, "init");
sandbox.spy(ASRouterPreferences, "uninit");
sandbox.spy(ASRouterPreferences, "addListener");
sandbox.spy(ASRouterPreferences, "removeListener");
sandbox.stub(ASRouterPreferences, "trailhead").get(() => {
return { trailheadTriplet: "test" };
});
sandbox.replaceGetter(
ASRouterPreferences,
"personalizedCfrScores",
() => personalizedCfrScores
);
clock = sandbox.useFakeTimers();
fetchStub = sandbox
.stub(global, "fetch")
.withArgs("http://fake.com/endpoint")
.resolves({
ok: true,
status: 200,
json: () => Promise.resolve({ messages: FAKE_REMOTE_MESSAGES }),
headers: FAKE_RESPONSE_HEADERS,
});
getStringPrefStub = sandbox.stub(global.Services.prefs, "getStringPref");
fakeAttributionCode = {
_clearCache: () => sinon.stub(),
getAttrDataAsync: () => Promise.resolve({ content: "addonID" }),
};
FakeBookmarkPanelHub = {
init: sandbox.stub(),
uninit: sandbox.stub(),
_forceShowMessage: sandbox.stub(),
};
FakeToolbarPanelHub = {
init: sandbox.stub(),
uninit: sandbox.stub(),
forceShowMessage: sandbox.stub(),
};
FakeToolbarBadgeHub = {
init: sandbox.stub(),
uninit: sandbox.stub(),
registerBadgeNotificationListener: sandbox.stub(),
};
globals.set({
ASRouterPreferences,
TARGETING_PREFERENCES,
ASRouterTargeting,
ASRouterTriggerListeners,
QueryCache,
AttributionCode: fakeAttributionCode,
// Testing framework doesn't know how to `defineLazyModuleGetter` so we're
// importing these modules into the global scope ourselves.
SnippetsTestMessageProvider,
PanelTestProvider,
BookmarkPanelHub: FakeBookmarkPanelHub,
ToolbarBadgeHub: FakeToolbarBadgeHub,
ToolbarPanelHub: FakeToolbarPanelHub,
KintoHttpClient: class {
bucket() {
return this;
}
collection() {
return this;
}
getRecord() {
return Promise.resolve({ data: {} });
}
},
Downloader: class {
download() {
return Promise.resolve("/path/to/downlowned");
}
},
});
await createRouterAndInit();
});
afterEach(() => {
ASRouterPreferences.uninit();
sandbox.restore();
globals.restore();
});
describe(".state", () => {
it("should throw if an attempt to set .state was made", () => {
assert.throws(() => {
Router.state = {};
});
});
});
describe("#init", () => {
it("should add a message listener to the RemotePageManager for incoming messages", () => {
assert.calledWith(
channel.addMessageListener,
CHILD_TO_PARENT_MESSAGE_NAME
);
const [, listenerAdded] = channel.addMessageListener.firstCall.args;
assert.isFunction(listenerAdded);
});
it("should set state.messageBlockList to the block list in persistent storage", async () => {
messageBlockList = ["foo"];
Router = new _ASRouter();
await Router.init(channel, createFakeStorage(), dispatchStub);
assert.deepEqual(Router.state.messageBlockList, ["foo"]);
});
it("should initialize all the hub providers", async () => {
// ASRouter init called in `beforeEach` block above
assert.calledOnce(FakeToolbarBadgeHub.init);
assert.calledOnce(FakeToolbarPanelHub.init);
assert.calledOnce(FakeBookmarkPanelHub.init);
assert.calledWithExactly(
FakeToolbarBadgeHub.init,
Router.waitForInitialized,
{
handleMessageRequest: Router.handleMessageRequest,
addImpression: Router.addImpression,
blockMessageById: Router.blockMessageById,
dispatch: Router.dispatch,
unblockMessageById: Router.unblockMessageById,
}
);
assert.calledWithExactly(
FakeToolbarPanelHub.init,
Router.waitForInitialized,
{
getMessages: Router.handleMessageRequest,
dispatch: Router.dispatch,
handleUserAction: Router.handleUserAction,
}
);
assert.calledWithExactly(
FakeBookmarkPanelHub.init,
Router.handleMessageRequest,
Router.addImpression,
Router.dispatch
);
});
it("should set state.messageImpressions to the messageImpressions object in persistent storage", async () => {
// Note that messageImpressions are only kept if a message exists in router and has a .frequency property,
// otherwise they will be cleaned up by .cleanupImpressions()
const testMessage = { id: "foo", frequency: { lifetimeCap: 10 } };
messageImpressions = { foo: [0, 1, 2] };
setMessageProviderPref([
{ id: "onboarding", type: "local", messages: [testMessage] },
]);
Router = new _ASRouter();
await Router.init(channel, createFakeStorage(), dispatchStub);
assert.deepEqual(Router.state.messageImpressions, messageImpressions);
});
it("should await .loadMessagesFromAllProviders() and add messages from providers to state.messages", async () => {
Router = new _ASRouter(Object.freeze(FAKE_LOCAL_PROVIDERS));
const loadMessagesSpy = sandbox.spy(
Router,
"loadMessagesFromAllProviders"
);
await Router.init(channel, createFakeStorage(), dispatchStub);
assert.calledOnce(loadMessagesSpy);
assert.isArray(Router.state.messages);
assert.lengthOf(
Router.state.messages,
FAKE_LOCAL_MESSAGES.length + FAKE_REMOTE_MESSAGES.length
);
});
it("should load additional whitelisted hosts", async () => {
getStringPrefStub.returns('["whitelist.com"]');
await createRouterAndInit();
assert.propertyVal(Router.WHITELIST_HOSTS, "whitelist.com", "preview");
// Should still include the defaults
assert.lengthOf(Object.keys(Router.WHITELIST_HOSTS), 3);
});
it("should fallback to defaults if pref parsing fails", async () => {
getStringPrefStub.returns("err");
await createRouterAndInit();
assert.lengthOf(Object.keys(Router.WHITELIST_HOSTS), 2);
assert.propertyVal(
Router.WHITELIST_HOSTS,
"snippets-admin.mozilla.org",
"preview"
);
assert.propertyVal(
Router.WHITELIST_HOSTS,
"activity-stream-icons.services.mozilla.com",
"production"
);
});
it("should set this.dispatchToAS to the third parameter passed to .init()", async () => {
assert.equal(Router.dispatchToAS, dispatchStub);
});
it("should set state.previousSessionEnd from IndexedDB", async () => {
previousSessionEnd = 200;
await createRouterAndInit();
assert.equal(Router.state.previousSessionEnd, previousSessionEnd);
});
it("should dispatch a AS_ROUTER_INITIALIZED event to AS with ASRouterPreferences.specialConditions", async () => {
assert.calledWith(
Router.dispatchToAS,
ac.BroadcastToContent({
type: "AS_ROUTER_INITIALIZED",
data: ASRouterPreferences.specialConditions,
})
);
});
it("should add observer for `intl:app-locales-changed`", async () => {
sandbox.spy(global.Services.obs, "addObserver");
await createRouterAndInit();
assert.calledOnce(global.Services.obs.addObserver);
assert.equal(
global.Services.obs.addObserver.args[0][1],
"intl:app-locales-changed"
);
});
it("should add a pref observer", async () => {
sandbox.spy(global.Services.prefs, "addObserver");
await createRouterAndInit();
assert.calledOnce(global.Services.prefs.addObserver);
assert.calledWithExactly(
global.Services.prefs.addObserver,
USE_REMOTE_L10N_PREF,
Router
);
});
describe("lazily loading local test providers", () => {
afterEach(() => {
Router.uninit();
});
it("should add the local test providers on init if devtools are enabled", async () => {
sandbox.stub(ASRouterPreferences, "devtoolsEnabled").get(() => true);
await createRouterAndInit();
assert.property(Router._localProviders, "SnippetsTestMessageProvider");
assert.property(Router._localProviders, "PanelTestProvider");
});
it("should not add the local test providers on init if devtools are disabled", async () => {
sandbox.stub(ASRouterPreferences, "devtoolsEnabled").get(() => false);
await createRouterAndInit();
assert.notProperty(
Router._localProviders,
"SnippetsTestMessageProvider"
);
assert.notProperty(Router._localProviders, "PanelTestProvider");
});
});
});
describe("preference changes", () => {
it("should call ASRouterPreferences.init and add a listener on init", () => {
assert.calledOnce(ASRouterPreferences.init);
assert.calledWith(ASRouterPreferences.addListener, Router.onPrefChange);
});
it("should call ASRouterPreferences.uninit and remove the listener on uninit", () => {
Router.uninit();
assert.calledOnce(ASRouterPreferences.uninit);
assert.calledWith(
ASRouterPreferences.removeListener,
Router.onPrefChange
);
});
it("should send a AS_ROUTER_TARGETING_UPDATE message", async () => {
const messageTargeted = {
id: "1",
campaign: "foocampaign",
targeting: "true",
};
const messageNotTargeted = { id: "2", campaign: "foocampaign" };
await Router.setState({
messages: [messageTargeted, messageNotTargeted],
});
sandbox.stub(ASRouterTargeting, "isMatch").resolves(false);
await Router.onPrefChange("services.sync.username");
assert.calledOnce(channel.sendAsyncMessage);
const [, { type, data }] = channel.sendAsyncMessage.firstCall.args;
assert.equal(type, "AS_ROUTER_TARGETING_UPDATE");
assert.equal(data[0], messageTargeted.id);
assert.lengthOf(data, 1);
});
it("should call loadMessagesFromAllProviders on pref change", () => {
sandbox.spy(Router, "loadMessagesFromAllProviders");
ASRouterPreferences.observe(null, null, MESSAGE_PROVIDER_PREF_NAME);
assert.calledOnce(Router.loadMessagesFromAllProviders);
});
it("should update the list of providers on pref change", () => {
const modifiedRemoteProvider = Object.assign({}, FAKE_REMOTE_PROVIDER, {
url: "baz.com",
});
setMessageProviderPref([
FAKE_LOCAL_PROVIDER,
modifiedRemoteProvider,
FAKE_REMOTE_SETTINGS_PROVIDER,
]);
const { length } = Router.state.providers;
ASRouterPreferences.observe(null, null, MESSAGE_PROVIDER_PREF_NAME);
const provider = Router.state.providers.find(p => p.url === "baz.com");
assert.lengthOf(Router.state.providers, length);
assert.isDefined(provider);
});
});
describe("setState", () => {
it("should broadcast a message to update the admin tool on a state change if the asrouter.devtoolsEnabled pref is", async () => {
sandbox.stub(ASRouterPreferences, "devtoolsEnabled").get(() => true);
sandbox.stub(Router, "getTargetingParameters").resolves({});
await Router.setState({ foo: 123 });
assert.calledOnce(channel.sendAsyncMessage);
assert.deepEqual(channel.sendAsyncMessage.firstCall.args[1], {
type: "ADMIN_SET_STATE",
data: Object.assign({}, Router.state, {
providerPrefs: ASRouterPreferences.providers,
userPrefs: ASRouterPreferences.getAllUserPreferences(),
targetingParameters: {},
trailhead: ASRouterPreferences.trailhead,
errors: Router.errors,
}),
});
});
it("should not send a message on a state change asrouter.devtoolsEnabled pref is on", async () => {
sandbox.stub(ASRouterPreferences, "devtoolsEnabled").get(() => false);
await Router.setState({ foo: 123 });
assert.notCalled(channel.sendAsyncMessage);
});
});
describe("getTargetingParameters", () => {
it("should return the targeting parameters", async () => {
const stub = sandbox.stub().resolves("foo");
const obj = { foo: 1 };
sandbox.stub(obj, "foo").get(stub);
const result = await Router.getTargetingParameters(obj, obj);
assert.calledTwice(stub);
assert.propertyVal(result, "foo", "foo");
});
});
describe("evaluateExpression", () => {
let stub;
beforeEach(async () => {
stub = sandbox.stub();
stub.resolves("foo");
sandbox.stub(ASRouterTargeting, "isMatch").callsFake(stub);
});
afterEach(() => {
sandbox.restore();
});
it("should call ASRouterTargeting to evaluate", async () => {
const targetStub = { sendAsyncMessage: sandbox.stub() };
await Router.evaluateExpression(targetStub, {});
assert.calledOnce(targetStub.sendAsyncMessage);
assert.equal(
targetStub.sendAsyncMessage.firstCall.args[1].data.evaluationStatus
.result,
"foo"
);
assert.isTrue(
targetStub.sendAsyncMessage.firstCall.args[1].data.evaluationStatus
.success
);
});
it("should catch evaluation errors", async () => {
stub.returns(Promise.reject(new Error("fake error")));
const targetStub = { sendAsyncMessage: sandbox.stub() };
await Router.evaluateExpression(targetStub, {});
assert.isFalse(
targetStub.sendAsyncMessage.firstCall.args[1].data.evaluationStatus
.success
);
});
});
describe("#routeMessageToTarget", () => {
let target;
beforeEach(() => {
sandbox.stub(CFRPageActions, "forceRecommendation");
sandbox.stub(CFRPageActions, "addRecommendation");
sandbox.stub(CFRPageActions, "showMilestone");
target = { sendAsyncMessage: sandbox.stub() };
});
it("should route whatsnew_panel_message message to the right hub", () => {
Router.routeMessageToTarget(
{ template: "whatsnew_panel_message" },
target,
"",
true
);
assert.calledOnce(FakeToolbarPanelHub.forceShowMessage);
assert.notCalled(FakeToolbarBadgeHub.registerBadgeNotificationListener);
assert.notCalled(FakeBookmarkPanelHub._forceShowMessage);
assert.notCalled(CFRPageActions.addRecommendation);
assert.notCalled(CFRPageActions.forceRecommendation);
assert.notCalled(CFRPageActions.showMilestone);
assert.notCalled(target.sendAsyncMessage);
});
it("should route toolbar_badge message to the right hub", () => {
Router.routeMessageToTarget({ template: "toolbar_badge" }, target);
assert.calledOnce(FakeToolbarBadgeHub.registerBadgeNotificationListener);
assert.notCalled(FakeToolbarPanelHub.forceShowMessage);
assert.notCalled(FakeBookmarkPanelHub._forceShowMessage);
assert.notCalled(CFRPageActions.addRecommendation);
assert.notCalled(CFRPageActions.forceRecommendation);
assert.notCalled(CFRPageActions.showMilestone);
assert.notCalled(target.sendAsyncMessage);
});
it("should route milestone_message to the right hub", () => {
Router.routeMessageToTarget({ template: "milestone_message" }, target);
assert.calledOnce(CFRPageActions.showMilestone);
assert.notCalled(CFRPageActions.addRecommendation);
assert.notCalled(CFRPageActions.forceRecommendation);
assert.notCalled(FakeBookmarkPanelHub._forceShowMessage);
assert.notCalled(FakeToolbarBadgeHub.registerBadgeNotificationListener);
assert.notCalled(FakeToolbarPanelHub.forceShowMessage);
assert.notCalled(target.sendAsyncMessage);
});
it("should route fxa_bookmark_panel message to the right hub force = true", () => {
Router.routeMessageToTarget(
{ template: "fxa_bookmark_panel" },
target,
{},
true
);
assert.calledOnce(FakeBookmarkPanelHub._forceShowMessage);
assert.notCalled(FakeToolbarPanelHub.forceShowMessage);
assert.notCalled(FakeToolbarBadgeHub.registerBadgeNotificationListener);
assert.notCalled(CFRPageActions.addRecommendation);
assert.notCalled(CFRPageActions.forceRecommendation);
assert.notCalled(CFRPageActions.showMilestone);
assert.notCalled(target.sendAsyncMessage);
});
it("should route cfr_doorhanger message to the right hub force = false", () => {
Router.routeMessageToTarget(
{ template: "cfr_doorhanger" },
target,
{ param: {} },
false
);
assert.calledOnce(CFRPageActions.addRecommendation);
assert.notCalled(FakeToolbarPanelHub.forceShowMessage);
assert.notCalled(FakeBookmarkPanelHub._forceShowMessage);
assert.notCalled(FakeToolbarBadgeHub.registerBadgeNotificationListener);
assert.notCalled(CFRPageActions.forceRecommendation);
assert.notCalled(CFRPageActions.showMilestone);
assert.notCalled(target.sendAsyncMessage);
});
it("should route cfr_doorhanger message to the right hub force = true", () => {
Router.routeMessageToTarget(
{ template: "cfr_doorhanger" },
target,
{},
true
);
assert.calledOnce(CFRPageActions.forceRecommendation);
assert.notCalled(FakeToolbarPanelHub.forceShowMessage);
assert.notCalled(CFRPageActions.addRecommendation);
assert.notCalled(CFRPageActions.showMilestone);
assert.notCalled(FakeBookmarkPanelHub._forceShowMessage);
assert.notCalled(FakeToolbarBadgeHub.registerBadgeNotificationListener);
assert.notCalled(target.sendAsyncMessage);
});
it("should route default to sending to content", () => {
Router.routeMessageToTarget({ template: "snippets" }, target, {}, true);
assert.calledOnce(target.sendAsyncMessage);
assert.notCalled(FakeToolbarPanelHub.forceShowMessage);
assert.notCalled(CFRPageActions.forceRecommendation);
assert.notCalled(CFRPageActions.addRecommendation);
assert.notCalled(CFRPageActions.showMilestone);
assert.notCalled(FakeBookmarkPanelHub._forceShowMessage);
assert.notCalled(FakeToolbarBadgeHub.registerBadgeNotificationListener);
});
});
describe("#loadMessagesFromAllProviders", () => {
function assertRouterContainsMessages(messages) {
const messageIdsInRouter = Router.state.messages.map(m => m.id);
for (const message of messages) {
assert.include(messageIdsInRouter, message.id);
}
}
it("should not trigger an update if not enough time has passed for a provider", async () => {
await createRouterAndInit([
{
id: "remotey",
type: "remote",
enabled: true,
url: "http://fake.com/endpoint",
updateCycleInMs: 300,
},
]);
const previousState = Router.state;
// Since we've previously gotten messages during init and we haven't advanced our fake timer,
// no updates should be triggered.
await Router.loadMessagesFromAllProviders();
assert.equal(Router.state, previousState);
});
it("should not trigger an update if we only have local providers", async () => {
await createRouterAndInit([
{
id: "foo",
type: "local",
enabled: true,
messages: FAKE_LOCAL_MESSAGES,
},
]);
const previousState = Router.state;
clock.tick(300);
await Router.loadMessagesFromAllProviders();
assert.equal(Router.state, previousState);
});
it("should apply personalization if defined", async () => {
personalizedCfrScores = { FOO: 1, BAR: 2 };
const NEW_MESSAGES = [{ id: "FOO" }, { id: "BAR" }];
fetchStub.withArgs("http://foo.com").resolves({
ok: true,
status: 200,
json: () => Promise.resolve({ messages: NEW_MESSAGES }),
headers: FAKE_RESPONSE_HEADERS,
});
await createRouterAndInit([
{
id: "cfr",
personalized: true,
personalizedModelVersion: "42",
type: "remote",
url: "http://foo.com",
enabled: true,
updateCycleInMs: 300,
},
]);
await Router.loadMessagesFromAllProviders();
// Make sure messages are there
assertRouterContainsMessages(NEW_MESSAGES);
// Make sure they have a score and personalizedModelVersion
for (const expectedMessage of NEW_MESSAGES) {
const { id } = expectedMessage;
const message = Router.state.messages.find(msg => msg.id === id);
assert.propertyVal(message, "score", personalizedCfrScores[message.id]);
assert.propertyVal(message, "personalizedModelVersion", "42");
}
});
it("should update messages for a provider if enough time has passed, without removing messages for other providers", async () => {
const NEW_MESSAGES = [{ id: "new_123" }];
await createRouterAndInit([
{
id: "remotey",
type: "remote",
url: "http://fake.com/endpoint",
enabled: true,
updateCycleInMs: 300,
},
{
id: "alocalprovider",
type: "local",
enabled: true,
messages: FAKE_LOCAL_MESSAGES,
},
]);
fetchStub.withArgs("http://fake.com/endpoint").resolves({
ok: true,
status: 200,
json: () => Promise.resolve({ messages: NEW_MESSAGES }),
headers: FAKE_RESPONSE_HEADERS,
});
clock.tick(301);
await Router.loadMessagesFromAllProviders();
// These are the new messages
assertRouterContainsMessages(NEW_MESSAGES);
// These are the local messages that should not have been deleted
assertRouterContainsMessages(FAKE_LOCAL_MESSAGES);
});
it("should parse the triggers in the messages and register the trigger listeners", async () => {
sandbox.spy(
ASRouterTriggerListeners.get("openURL"),
"init"
); /* eslint-disable object-property-newline */
/* eslint-disable object-curly-newline */ await createRouterAndInit([
{
id: "foo",
type: "local",
enabled: true,
messages: [
{
id: "foo",
template: "simple_template",
trigger: { id: "firstRun" },
content: { title: "Foo", body: "Foo123" },
},
{
id: "bar1",
template: "simple_template",
trigger: {
id: "openURL",
params: ["www.mozilla.org", "www.mozilla.com"],
},
content: { title: "Bar1", body: "Bar123" },
},
{
id: "bar2",
template: "simple_template",
trigger: { id: "openURL", params: ["www.example.com"] },
content: { title: "Bar2", body: "Bar123" },
},
],
},
]); /* eslint-enable object-property-newline */
/* eslint-enable object-curly-newline */ assert.calledTwice(
ASRouterTriggerListeners.get("openURL").init
);
assert.calledWithExactly(
ASRouterTriggerListeners.get("openURL").init,
Router._triggerHandler,
["www.mozilla.org", "www.mozilla.com"],
undefined
);
assert.calledWithExactly(
ASRouterTriggerListeners.get("openURL").init,
Router._triggerHandler,
["www.example.com"],
undefined
);
});
it("should gracefully handle RemoteSettings blowing up and dispatch undesired event", async () => {
sandbox
.stub(MessageLoaderUtils, "_getRemoteSettingsMessages")
.rejects("fake error");
await createRouterAndInit();
assert.calledWith(Router.dispatchToAS, {
data: {
action: "asrouter_undesired_event",
event: "ASR_RS_ERROR",
event_context: "remotey-settingsy",
message_id: "n/a",
},
meta: { from: "ActivityStream:Content", to: "ActivityStream:Main" },
type: "AS_ROUTER_TELEMETRY_USER_EVENT",
});
});
it("should dispatch undesired event if RemoteSettings returns no messages", async () => {
sandbox
.stub(MessageLoaderUtils, "_getRemoteSettingsMessages")
.resolves([]);
await createRouterAndInit();
assert.calledWith(Router.dispatchToAS, {
data: {
action: "asrouter_undesired_event",
event: "ASR_RS_NO_MESSAGES",
event_context: "remotey-settingsy",
message_id: "n/a",
},
meta: { from: "ActivityStream:Content", to: "ActivityStream:Main" },
type: "AS_ROUTER_TELEMETRY_USER_EVENT",
});
});
it("should download the attachment if RemoteSettings returns some messages", async () => {
sandbox
.stub(global.Services.locale, "appLocaleAsLangTag")
.get(() => "en-US");
sandbox
.stub(MessageLoaderUtils, "_getRemoteSettingsMessages")
.resolves([{ id: "message_1" }]);
const spy = sandbox.spy();
global.Downloader.prototype.download = spy;
const provider = {
id: "cfr",
enabled: true,
type: "remote-settings",
bucket: "cfr",
};
await createRouterAndInit([provider]);
assert.calledOnce(spy);
});
it("should dispatch undesired event if the ms-language-packs returns no messages", async () => {
sandbox
.stub(global.Services.locale, "appLocaleAsLangTag")
.get(() => "en-US");
sandbox
.stub(MessageLoaderUtils, "_getRemoteSettingsMessages")
.resolves([{ id: "message_1" }]);
sandbox
.stub(global.KintoHttpClient.prototype, "getRecord")
.resolves(null);
const provider = {
id: "cfr",
enabled: true,
type: "remote-settings",
bucket: "cfr",
};
await createRouterAndInit([provider]);
assert.calledWith(Router.dispatchToAS, {
data: {
action: "asrouter_undesired_event",
event: "ASR_RS_NO_MESSAGES",
event_context: "ms-language-packs",
message_id: "n/a",
},
meta: { from: "ActivityStream:Content", to: "ActivityStream:Main" },
type: "AS_ROUTER_TELEMETRY_USER_EVENT",
});
});
});
describe("#_updateMessageProviders", () => {
it("should correctly replace %STARTPAGE_VERSION% in remote provider urls", () => {
// If this test fails, you need to update the constant STARTPAGE_VERSION in
// ASRouter.jsm to match the `version` property of provider-response-schema.json
const expectedStartpageVersion = ProviderResponseSchema.version;
const provider = {
id: "foo",
enabled: true,
type: "remote",
url: "https://www.mozilla.org/%STARTPAGE_VERSION%/",
};
setMessageProviderPref([provider]);
Router._updateMessageProviders();
assert.equal(
Router.state.providers[0].url,
`https://www.mozilla.org/${parseInt(expectedStartpageVersion, 10)}/`
);
});
it("should replace other params in remote provider urls by calling Services.urlFormater.formatURL", () => {
const url = "https://www.example.com/";
const replacedUrl = "https://www.foo.bar/";
const stub = sandbox
.stub(global.Services.urlFormatter, "formatURL")
.withArgs(url)
.returns(replacedUrl);
const provider = { id: "foo", enabled: true, type: "remote", url };
setMessageProviderPref([provider]);
Router._updateMessageProviders();
assert.calledOnce(stub);
assert.calledWithExactly(stub, url);
assert.equal(Router.state.providers[0].url, replacedUrl);
});
it("should only add the providers that are enabled", () => {
const providers = [
{
id: "foo",
enabled: false,
type: "remote",
url: "https://www.foo.com/",
},
{
id: "bar",
enabled: true,
type: "remote",
url: "https://www.bar.com/",
},
];
setMessageProviderPref(providers);
Router._updateMessageProviders();
assert.equal(Router.state.providers.length, 1);
assert.equal(Router.state.providers[0].id, providers[1].id);
});
it("should return provider `foo` because both categories are enabled", () => {
const providers = [
{
id: "foo",
enabled: true,
categories: ["cfrFeatures", "cfrAddons"],
type: "remote",
url: "https://www.foo.com/",
},
];
sandbox.stub(ASRouterPreferences, "providers").value(providers);
sandbox
.stub(ASRouterPreferences, "getUserPreference")
.withArgs("cfrFeatures")
.returns(true)
.withArgs("cfrAddons")
.returns(true);
Router._updateMessageProviders();
assert.equal(Router.state.providers.length, 1);
assert.equal(Router.state.providers[0].id, providers[0].id);
});
it("should return provider `foo` because at least 1 category is enabled", () => {
const providers = [
{
id: "foo",
enabled: true,
categories: ["cfrFeatures", "cfrAddons"],
type: "remote",
url: "https://www.foo.com/",
},
];
sandbox.stub(ASRouterPreferences, "providers").value(providers);
sandbox
.stub(ASRouterPreferences, "getUserPreference")
.withArgs("cfrFeatures")
.returns(false)
.withArgs("cfrAddons")
.returns(true);
Router._updateMessageProviders();
assert.equal(Router.state.providers.length, 1);
assert.equal(Router.state.providers[0].id, providers[0].id);
});
it("should not return provider `foo` because no categories are enabled", () => {
const providers = [
{
id: "foo",
enabled: true,
categories: ["cfrFeatures", "cfrAddons"],
type: "remote",
url: "https://www.foo.com/",
},
];
sandbox.stub(ASRouterPreferences, "providers").value(providers);
sandbox
.stub(ASRouterPreferences, "getUserPreference")
.withArgs("cfrFeatures")
.returns(false)
.withArgs("cfrAddons")
.returns(false);
Router._updateMessageProviders();
assert.equal(Router.state.providers.length, 0);
});
});
describe("#handleMessageRequest", () => {
it("should not return a blocked message", async () => {
// Block all messages except the first
await Router.setState(() => ({
messages: [
{ id: "foo", provider: "snippets" },
{ id: "bar", provider: "snippets" },
],
messageBlockList: ["foo"],
}));
const result = await Router.handleMessageRequest({
provider: "snippets",
});
assert.equal(result.id, "bar");
});
it("should not return a message from a blocked campaign", async () => {
// Block all messages except the first
await Router.setState(() => ({
messages: [
{ id: "foo", provider: "snippets", campaign: "foocampaign" },
{ id: "bar", provider: "snippets" },
],
messageBlockList: ["foocampaign"],
}));
const result = await Router.handleMessageRequest({
provider: "snippets",
});
assert.equal(result.id, "bar");
});
it("should not return a message from a blocked provider", async () => {
// There are only two providers; block the FAKE_LOCAL_PROVIDER, leaving
// only FAKE_REMOTE_PROVIDER unblocked, which provides only one message
await Router.setState(() => ({
providerBlockList: ["snippets"],
}));
await Router.setState(() => ({
messages: [{ id: "foo", provider: "snippets" }],
messageBlockList: ["foocampaign"],
}));
const result = await Router.handleMessageRequest({
provider: "snippets",
});
assert.isNull(result);
});
it("should get unblocked messages that match the trigger", async () => {
const message1 = {
id: "1",
campaign: "foocampaign",
trigger: { id: "foo" },
};
const message2 = {
id: "2",
campaign: "foocampaign",
trigger: { id: "bar" },
};
await Router.setState({ messages: [message2, message1] });
// Just return the first message provided as arg
sandbox
.stub(ASRouterTargeting, "findMatchingMessage")
.callsFake(({ messages }) => messages[0]);
const result = Router.handleMessageRequest({ triggerId: "foo" });
assert.deepEqual(result, message1);
});
it("should get unblocked messages that match trigger and template", async () => {
const message1 = {
id: "1",
campaign: "foocampaign",
template: "badge",
trigger: { id: "foo" },
};
const message2 = {
id: "2",
campaign: "foocampaign",
template: "snippet",
trigger: { id: "foo" },
};
await Router.setState({ messages: [message2, message1] });
// Just return the first message provided as arg
sandbox
.stub(ASRouterTargeting, "findMatchingMessage")
.callsFake(({ messages }) => messages[0]);
const result = Router.handleMessageRequest({
triggerId: "foo",
template: "badge",
});
assert.deepEqual(result, message1);
});
it("should have messageImpressions in the message context", () => {
assert.propertyVal(
Router._getMessagesContext(),
"messageImpressions",
Router.state.messageImpressions
);
});
it("should return all unblocked messages that match the template, trigger if returnAll=true", async () => {
const message1 = {
id: "1",
template: "whatsnew_panel_message",
trigger: { id: "whatsNewPanelOpened" },
};
const message2 = {
id: "2",
template: "whatsnew_panel_message",
trigger: { id: "whatsNewPanelOpened" },
};
const message3 = {
id: "3",
template: "badge",
};
sandbox
.stub(ASRouterTargeting, "findMatchingMessage")
.callsFake(() => [message2, message1]);
await Router.setState({ messages: [message3, message2, message1] });
const result = await Router.handleMessageRequest({
template: "whatsnew-panel",
triggerId: "whatsNewPanelOpened",
returnAll: true,
});
assert.deepEqual(result, [message2, message1]);
});
it("should forward trigger param info", async () => {
const trigger = {
triggerId: "foo",
triggerParam: "bar",
triggerContext: "context",
};
const message1 = {
id: "1",
campaign: "foocampaign",
trigger: { id: "foo" },
};
const message2 = {
id: "2",
campaign: "foocampaign",
trigger: { id: "bar" },
};
await Router.setState({ messages: [message2, message1] });
// Just return the first message provided as arg
const stub = sandbox.stub(ASRouterTargeting, "findMatchingMessage");
Router.handleMessageRequest(trigger);
assert.calledOnce(stub);
const [options] = stub.firstCall.args;
assert.propertyVal(options.trigger, "id", trigger.triggerId);
assert.propertyVal(options.trigger, "param", trigger.triggerParam);
assert.propertyVal(options.trigger, "context", trigger.triggerContext);
assert.propertyVal(options, "shouldCache", false);
});
it("should cache snippets messages", async () => {
const trigger = {
triggerId: "foo",
triggerParam: "bar",
triggerContext: "context",
};
const message1 = {
id: "1",
provider: "snippets",
campaign: "foocampaign",
trigger: { id: "foo" },
};
const message2 = {
id: "2",
campaign: "foocampaign",
trigger: { id: "bar" },
};
await Router.setState({ messages: [message2, message1] });
// Just return the first message provided as arg
const stub = sandbox.stub(ASRouterTargeting, "findMatchingMessage");
Router.handleMessageRequest(trigger);
assert.calledOnce(stub);
const [options] = stub.firstCall.args;
assert.propertyVal(options, "shouldCache", true);
});
it("should filter out messages without a trigger (or different) when a triggerId is defined", async () => {
const trigger = { triggerId: "foo" };
const message1 = {
id: "1",
campaign: "foocampaign",
trigger: { id: "foo" },
};
const message2 = {
id: "2",
campaign: "foocampaign",
trigger: { id: "bar" },
};
const message3 = {
id: "3",
campaign: "bazcampaign",
};
await Router.setState({ messages: [message2, message1, message3] });
// Just return the first message provided as arg
sandbox
.stub(ASRouterTargeting, "findMatchingMessage")
.callsFake(args => args.messages);
const result = Router.handleMessageRequest(trigger);
assert.lengthOf(result, 1);
assert.deepEqual(result[0], message1);
});
});
describe("#uninit", () => {
it("should remove the message listener on the RemotePageManager", () => {
const [, listenerAdded] = channel.addMessageListener.firstCall.args;
assert.isFunction(listenerAdded);
Router.uninit();
assert.calledWith(
channel.removeMessageListener,
CHILD_TO_PARENT_MESSAGE_NAME,
listenerAdded
);
});
it("should unregister the trigger listeners", () => {
for (const listener of ASRouterTriggerListeners.values()) {
sandbox.spy(listener, "uninit");
}
Router.uninit();
for (const listener of ASRouterTriggerListeners.values()) {
assert.calledOnce(listener.uninit);
}
});
it("should set .dispatchToAS to null", () => {
Router.uninit();
assert.isNull(Router.dispatchToAS);
});
it("should save previousSessionEnd", () => {
Router.uninit();
assert.calledOnce(Router._storage.set);
assert.calledWithExactly(
Router._storage.set,
"previousSessionEnd",
sinon.match.number
);
});
it("should remove the observer for `intl:app-locales-changed`", () => {
sandbox.spy(global.Services.obs, "removeObserver");
Router.uninit();
assert.calledOnce(global.Services.obs.removeObserver);
assert.equal(
global.Services.obs.removeObserver.args[0][1],
"intl:app-locales-changed"
);
});
it("should remove the pref observer for `USE_REMOTE_L10N_PREF`", async () => {
sandbox.spy(global.Services.prefs, "removeObserver");
Router.uninit();
// Grab the last call as #uninit() also involves multiple calls of `Services.prefs.removeObserver`.
const call = global.Services.prefs.removeObserver.lastCall;
assert.calledWithExactly(call, USE_REMOTE_L10N_PREF, Router);
});
});
describe("onMessage", () => {
describe("#onMessage: NEWTAB_MESSAGE_REQUEST", () => {
it("should send a message back to the to the target", async () => {
// force the only message to be a regular message so getRandomItemFromArray picks it
await Router.setState({
messages: [{ id: "foo", provider: "snippets" }],
});
const msg = fakeAsyncMessage({ type: "NEWTAB_MESSAGE_REQUEST" });
await Router.onMessage(msg);
assert.calledWith(
msg.target.sendAsyncMessage,
PARENT_TO_CHILD_MESSAGE_NAME,
{ type: "SET_MESSAGE", data: { id: "foo", provider: "snippets" } }
);
});
it("should send a message back to the to the target if there is a bundle, too", async () => {
// force the only message to be a bundled message so getRandomItemFromArray picks it
sandbox.stub(Router, "_findProvider").returns(null);
await Router.setState({
messages: [
{
id: "foo1",
provider: "snippets",
template: "simple_template",
bundled: 1,
content: { title: "Foo1", body: "Foo123-1" },
},
],
});
const msg = fakeAsyncMessage({ type: "NEWTAB_MESSAGE_REQUEST" });
await Router.onMessage(msg);
assert.calledWith(
msg.target.sendAsyncMessage,
PARENT_TO_CHILD_MESSAGE_NAME
);
assert.equal(
msg.target.sendAsyncMessage.firstCall.args[1].type,
"SET_BUNDLED_MESSAGES"
);
});
it("should properly order the message's bundle if specified", async () => {
// force the only messages to be a bundled messages so getRandomItemFromArray picks one of them
sandbox.stub(Router, "_findProvider").returns(null);
const firstMessage = {
id: "foo2",
provider: "snippets",
template: "simple_template",
bundled: 2,
order: 1,
content: { title: "Foo2", body: "Foo123-2" },
};
const secondMessage = {
id: "foo1",
provider: "snippets",
template: "simple_template",
bundled: 2,
order: 2,
content: { title: "Foo1", body: "Foo123-1" },
};
await Router.setState({ messages: [secondMessage, firstMessage] });
const msg = fakeAsyncMessage({ type: "NEWTAB_MESSAGE_REQUEST" });
await Router.onMessage(msg);
assert.calledWith(
msg.target.sendAsyncMessage,
PARENT_TO_CHILD_MESSAGE_NAME
);
assert.equal(
msg.target.sendAsyncMessage.firstCall.args[1].type,
"SET_BUNDLED_MESSAGES"
);
assert.equal(
msg.target.sendAsyncMessage.firstCall.args[1].data.bundle[0].content,
firstMessage.content
);
assert.equal(
msg.target.sendAsyncMessage.firstCall.args[1].data.bundle[1].content,
secondMessage.content
);
});
it("should return a null bundle if we do not have enough messages to fill the bundle", async () => {
// force the only message to be a bundled message that needs 2 messages in the bundle
await Router.setState({
messages: [
{
id: "foo1",
template: "simple_template",
bundled: 2,
content: { title: "Foo1", body: "Foo123-1" },
},
],
});
const bundle = await Router._getBundledMessages(
Router.state.messages[0]
);
assert.equal(bundle, null);
});
it("should send down extra attributes in the bundle if they exist", async () => {
sandbox.stub(Router, "_findProvider").returns({
getExtraAttributes() {
return Promise.resolve({ header: "header" });
},
});
await Router.setState({
messages: [
{
id: "foo1",
template: "simple_template",
bundled: 1,
content: { title: "Foo1", body: "Foo123-1" },
},
],
});
const result = await Router._getBundledMessages(
Router.state.messages[0]
);
assert.equal(result.extraTemplateStrings.header, "header");
});
it("should send a CLEAR_ALL message if no bundle available", async () => {
// force the only message to be a bundled message that needs 2 messages in the bundle
await Router.setState({
messages: [
{
id: "foo1",
provider: "snippets",
template: "simple_template",
bundled: 2,
content: { title: "Foo1", body: "Foo123-1" },
},
],
});
const msg = fakeAsyncMessage({ type: "NEWTAB_MESSAGE_REQUEST" });
await Router.onMessage(msg);
assert.calledWith(
msg.target.sendAsyncMessage,
PARENT_TO_CHILD_MESSAGE_NAME,
{ type: "CLEAR_ALL" }
);
});
it("should send a CLEAR_ALL message if no messages are available", async () => {
await Router.setState({ messages: [] });
const msg = fakeAsyncMessage({ type: "NEWTAB_MESSAGE_REQUEST" });
await Router.onMessage(msg);
assert.calledWith(
msg.target.sendAsyncMessage,
PARENT_TO_CHILD_MESSAGE_NAME,
{ type: "CLEAR_ALL" }
);
});
it("should make a request to the provided endpoint on NEWTAB_MESSAGE_REQUEST", async () => {
const url = "https://snippets-admin.mozilla.org/foo";
const msg = fakeAsyncMessage({
type: "NEWTAB_MESSAGE_REQUEST",
data: { endpoint: { url } },
});
await Router.onMessage(msg);
assert.calledWith(global.fetch, url);
assert.lengthOf(Router.state.providers.filter(p => p.url === url), 0);
});
it("should make a request to the provided endpoint on ADMIN_CONNECT_STATE and remove the endpoint", async () => {
const url = "https://snippets-admin.mozilla.org/foo";
const msg = fakeAsyncMessage({
type: "ADMIN_CONNECT_STATE",
data: { endpoint: { url } },
});
await Router.onMessage(msg);
assert.calledWith(global.fetch, url);
assert.lengthOf(Router.state.providers.filter(p => p.url === url), 0);
});
it("should dispatch SNIPPETS_PREVIEW_MODE when adding a preview endpoint", async () => {
const url = "https://snippets-admin.mozilla.org/foo";
const msg = fakeAsyncMessage({
type: "NEWTAB_MESSAGE_REQUEST",
data: { endpoint: { url } },
});
await Router.onMessage(msg);
assert.calledWithExactly(
Router.dispatchToAS,
ac.OnlyToOneContent(
{ type: "SNIPPETS_PREVIEW_MODE" },
msg.target.portID
)
);
});
it("should not add a url that is not from a whitelisted host", async () => {
const url = "https://mozilla.org";
const msg = fakeAsyncMessage({
type: "NEWTAB_MESSAGE_REQUEST",
data: { endpoint: { url } },
});
await Router.onMessage(msg);
assert.lengthOf(Router.state.providers.filter(p => p.url === url), 0);
});
it("should reject bad urls", async () => {
const url = "foo";
const msg = fakeAsyncMessage({
type: "NEWTAB_MESSAGE_REQUEST",
data: { endpoint: { url } },
});
await Router.onMessage(msg);
assert.lengthOf(Router.state.providers.filter(p => p.url === url), 0);
});
it("should handle onboarding message provider", async () => {
const handleMessageRequestStub = sandbox.stub(
Router,
"handleMessageRequest"
);
handleMessageRequestStub
.withArgs({
template: "extended_triplets",
})
.resolves({ id: "foo" });
sandbox.stub(Router, "sendNewTabMessage").resolves();
const msg = fakeAsyncMessage({
type: "NEWTAB_MESSAGE_REQUEST",
data: {},
});
await Router.onMessage(msg);
assert.calledOnce(Router.sendNewTabMessage);
});
it("should fallback to snippets if onboarding message provider returned none", async () => {
const handleMessageRequestStub = sandbox.stub(
Router,
"handleMessageRequest"
);
handleMessageRequestStub
.withArgs({
template: "extended_triplets",
})
.resolves(null);
const msg = fakeAsyncMessage({
type: "NEWTAB_MESSAGE_REQUEST",
data: {},
});
await Router.onMessage(msg);
assert.calledTwice(handleMessageRequestStub);
assert.calledWithExactly(handleMessageRequestStub, {
template: "extended_triplets",
});
assert.calledWithExactly(handleMessageRequestStub, {
provider: "snippets",
});
});
});
describe("#onMessage: BLOCK_MESSAGE_BY_ID", () => {
it("should add the id to the messageBlockList and broadcast a CLEAR_MESSAGE message with the id", async () => {
const msg = fakeAsyncMessage({
type: "BLOCK_MESSAGE_BY_ID",
data: { id: "foo" },
});
await Router.onMessage(msg);
assert.isTrue(Router.state.messageBlockList.includes("foo"));
assert.calledWith(
channel.sendAsyncMessage,
PARENT_TO_CHILD_MESSAGE_NAME,
{ type: "CLEAR_MESSAGE", data: { id: "foo" } }
);
});
it("should only send CLEAR_MESSAGE to preloaded if action.data.preloadedOnly is true", async () => {
sandbox.stub(Router, "sendAsyncMessageToPreloaded");
const msg = fakeAsyncMessage({
type: "BLOCK_MESSAGE_BY_ID",
data: { id: "foo", preloadedOnly: true },
});
await Router.onMessage(msg);
assert.calledWith(Router.sendAsyncMessageToPreloaded, {
type: "CLEAR_MESSAGE",
data: { id: "foo" },
});
});
it("should add the campaign to the messageBlockList instead of id if .campaign is specified and not select messages of that campaign again", async () => {
await Router.setState({
messages: [
{ id: "1", campaign: "foocampaign" },
{ id: "2", campaign: "foocampaign" },
],
});
const msg = fakeAsyncMessage({
type: "BLOCK_MESSAGE_BY_ID",
data: { id: "1" },
});
await Router.onMessage(msg);
assert.isTrue(Router.state.messageBlockList.includes("foocampaign"));
assert.isEmpty(Router._getUnblockedMessages());
});
it("should not broadcast CLEAR_MESSAGE if preventDismiss is true", async () => {
const msg = fakeAsyncMessage({
type: "BLOCK_MESSAGE_BY_ID",
data: { id: "foo", preventDismiss: true },
});
await Router.onMessage(msg);
assert.notCalled(channel.sendAsyncMessage);
});
});
describe("#onMessage: DISMISS_MESSAGE_BY_ID", () => {
it("should reply with CLEAR_MESSAGE with the correct id", async () => {
const msg = fakeAsyncMessage({
type: "DISMISS_MESSAGE_BY_ID",
data: { id: "foo" },
});
await Router.onMessage(msg);
assert.calledWith(
channel.sendAsyncMessage,
PARENT_TO_CHILD_MESSAGE_NAME,
{ type: "CLEAR_MESSAGE", data: { id: "foo" } }
);
});
});
describe("#onMessage: BLOCK_PROVIDER_BY_ID", () => {
it("should add the provider id to the providerBlockList and broadcast a CLEAR_PROVIDER with the provider id", async () => {
const msg = fakeAsyncMessage({
type: "BLOCK_PROVIDER_BY_ID",
data: { id: "bar" },
});
await Router.onMessage(msg);
assert.isTrue(Router.state.providerBlockList.includes("bar"));
assert.calledWith(
channel.sendAsyncMessage,
PARENT_TO_CHILD_MESSAGE_NAME,
{ type: "CLEAR_PROVIDER", data: { id: "bar" } }
);
});
});
describe("#onMessage: UNBLOCK_MESSAGE_BY_ID", () => {
it("should remove the id from the messageBlockList", async () => {
await Router.onMessage(
fakeAsyncMessage({ type: "BLOCK_MESSAGE_BY_ID", data: { id: "foo" } })
);
assert.isTrue(Router.state.messageBlockList.includes("foo"));
await Router.onMessage(
fakeAsyncMessage({
type: "UNBLOCK_MESSAGE_BY_ID",
data: { id: "foo" },
})
);
assert.isFalse(Router.state.messageBlockList.includes("foo"));
});
it("should remove the campaign from the messageBlockList if it is defined", async () => {
await Router.setState({ messages: [{ id: "1", campaign: "foo" }] });
await Router.onMessage(
fakeAsyncMessage({ type: "BLOCK_MESSAGE_BY_ID", data: { id: "1" } })
);
assert.isTrue(
Router.state.messageBlockList.includes("foo"),
"blocklist has campaign id"
);
await Router.onMessage(
fakeAsyncMessage({ type: "UNBLOCK_MESSAGE_BY_ID", data: { id: "1" } })
);
assert.isFalse(
Router.state.messageBlockList.includes("foo"),
"campaign id removed from blocklist"
);
});
it("should save the messageBlockList", async () => {
await Router.onMessage(
fakeAsyncMessage({
type: "UNBLOCK_MESSAGE_BY_ID",
data: { id: "foo" },
})
);
assert.calledWithExactly(Router._storage.set, "messageBlockList", []);
});
});
describe("#onMessage: UNBLOCK_PROVIDER_BY_ID", () => {
it("should remove the id from the providerBlockList", async () => {
await Router.onMessage(
fakeAsyncMessage({
type: "BLOCK_PROVIDER_BY_ID",
data: { id: "foo" },
})
);
assert.isTrue(Router.state.providerBlockList.includes("foo"));
await Router.onMessage(
fakeAsyncMessage({
type: "UNBLOCK_PROVIDER_BY_ID",
data: { id: "foo" },
})
);
assert.isFalse(Router.state.providerBlockList.includes("foo"));
});
it("should save the providerBlockList", async () => {
await Router.onMessage(
fakeAsyncMessage({
type: "UNBLOCK_PROVIDER_BY_ID",
data: { id: "foo" },
})
);
assert.calledWithExactly(Router._storage.set, "providerBlockList", []);
});
});
describe("#onMessage: UNBLOCK_BUNDLE", () => {
it("should remove all the ids in the bundle from the messageBlockList", async () => {
await Router.onMessage(
fakeAsyncMessage({
type: "BLOCK_BUNDLE",
data: { bundle: FAKE_BUNDLE },
})
);
assert.isTrue(
Router.state.messageBlockList.includes(FAKE_BUNDLE[0].id)
);
assert.isTrue(
Router.state.messageBlockList.includes(FAKE_BUNDLE[1].id)
);
await Router.onMessage(
fakeAsyncMessage({
type: "UNBLOCK_BUNDLE",
data: { bundle: FAKE_BUNDLE },
})
);
assert.isFalse(
Router.state.messageBlockList.includes(FAKE_BUNDLE[0].id)
);
assert.isFalse(
Router.state.messageBlockList.includes(FAKE_BUNDLE[1].id)
);
});
it("should save the messageBlockList", async () => {
await Router.onMessage(
fakeAsyncMessage({
type: "UNBLOCK_BUNDLE",
data: { bundle: FAKE_BUNDLE },
})
);
assert.calledWithExactly(Router._storage.set, "messageBlockList", []);
});
});
describe("#onMessage: ADMIN_CONNECT_STATE", () => {
it("should send a message containing the whole state", async () => {
sandbox.stub(Router, "getTargetingParameters").resolves({});
const msg = fakeAsyncMessage({ type: "ADMIN_CONNECT_STATE" });
await Router.onMessage(msg);
assert.calledOnce(msg.target.sendAsyncMessage);
assert.deepEqual(msg.target.sendAsyncMessage.firstCall.args[1], {
type: "ADMIN_SET_STATE",
data: Object.assign({}, Router.state, {
providerPrefs: ASRouterPreferences.providers,
userPrefs: ASRouterPreferences.getAllUserPreferences(),
targetingParameters: {},
trailhead: ASRouterPreferences.trailhead,
errors: Router.errors,
}),
});
});
});
describe("#onMessage: NEWTAB_MESSAGE_REQUEST", () => {
it("should call sendNewTabMessage on NEWTAB_MESSAGE_REQUEST", async () => {
sandbox.stub(Router, "sendNewTabMessage").resolves();
const data = { endpoint: "foo" };
const msg = fakeAsyncMessage({ type: "NEWTAB_MESSAGE_REQUEST", data });
await Router.onMessage(msg);
assert.calledOnce(Router.sendNewTabMessage);
assert.calledWithExactly(
Router.sendNewTabMessage,
sinon.match.instanceOf(FakeRemotePageManager),
data
);
});
it("should return the preview message if that's available and remove it from Router.state", async () => {
const expectedObj = { provider: "preview" };
Router.setState({ messages: [expectedObj] });
await Router.sendNewTabMessage(channel, { endpoint: "foo.com" });
assert.calledWith(
channel.sendAsyncMessage,
PARENT_TO_CHILD_MESSAGE_NAME,
{ type: "SET_MESSAGE", data: expectedObj }
);
assert.isUndefined(
Router.state.messages.find(m => m.provider === "preview")
);
});
it("should call _getBundledMessages if we request a message that needs to be bundled", async () => {
sandbox.stub(Router, "_getBundledMessages").resolves();
// forcefully pick a message which needs to be bundled (the second message in FAKE_LOCAL_MESSAGES)
const [, testMessage] = Router.state.messages;
const msg = fakeAsyncMessage({
type: "OVERRIDE_MESSAGE",
data: { id: testMessage.id },
});
await Router.onMessage(msg);
assert.calledOnce(Router._getBundledMessages);
});
it("should properly pick another message of the same template if it is bundled; force = true", async () => {
// forcefully pick a message which needs to be bundled (the second message in FAKE_LOCAL_MESSAGES)
const [, testMessage1, testMessage2] = Router.state.messages;
const msg = fakeAsyncMessage({
type: "OVERRIDE_MESSAGE",
data: { id: testMessage1.id },
});
await Router.onMessage(msg);
// Expected object should have some properties of the original message it picked (testMessage1)
// plus the bundled content of the others that it picked of the same template (testMessage2)
const expectedObj = {
template: testMessage1.template,
provider: testMessage1.provider,
bundle: [
{ content: testMessage1.content, id: testMessage1.id, order: 1 },
{ content: testMessage2.content, id: testMessage2.id },
],
};
assert.calledWith(
msg.target.sendAsyncMessage,
PARENT_TO_CHILD_MESSAGE_NAME,
{ type: "SET_BUNDLED_MESSAGES", data: expectedObj }
);
});
it("should properly pick another message of the same template if it is bundled; force = false", async () => {
// forcefully pick a message which needs to be bundled (the second message in FAKE_LOCAL_MESSAGES)
const [, testMessage1, testMessage2] = Router.state.messages;
const msg = fakeAsyncMessage({
type: "OVERRIDE_MESSAGE",
data: { id: testMessage1.id },
});
await Router.setMessageById(testMessage1.id, msg.target, false);
// Expected object should have some properties of the original message it picked (testMessage1)
// plus the bundled content of the others that it picked of the same template (testMessage2)
const expectedObj = {
template: testMessage1.template,
provider: testMessage1.provider,
bundle: [
{ content: testMessage1.content, id: testMessage1.id, order: 1 },
{
content: testMessage2.content,
id: testMessage2.id,
order: 2,
blockOnClick: false,
},
],
};
assert.calledWith(
msg.target.sendAsyncMessage,
PARENT_TO_CHILD_MESSAGE_NAME,
{ type: "SET_BUNDLED_MESSAGES", data: expectedObj }
);
});
it("should get the bundle and send the message if the message has a bundle", async () => {
sandbox.stub(Router, "sendNewTabMessage").resolves();
const msg = fakeAsyncMessage({ type: "NEWTAB_MESSAGE_REQUEST" });
msg.bundled = 2; // force this message to want to be bundled
await Router.onMessage(msg);
assert.calledOnce(Router.sendNewTabMessage);
});
});
describe("#onMessage: TRIGGER", () => {
it("should pass the trigger to ASRouterTargeting on TRIGGER message", async () => {
sandbox.stub(ASRouterTargeting, "findMatchingMessage").resolves();
const msg = fakeAsyncMessage({
type: "TRIGGER",
data: { trigger: { id: "firstRun" } },
});
await Router.onMessage(msg);
assert.calledOnce(ASRouterTargeting.findMatchingMessage);
assert.deepEqual(
ASRouterTargeting.findMatchingMessage.firstCall.args[0].trigger,
{
id: "firstRun",
param: undefined,
context: undefined,
}
);
});
it("should pick a message with the right targeting and trigger", async () => {
let messages = [
{
id: "foo1",
template: "simple_template",
bundled: 2,
trigger: { id: "foo" },
content: { title: "Foo1", body: "Foo123-1" },
},
{
id: "foo2",
template: "simple_template",
bundled: 2,
trigger: { id: "bar" },
content: { title: "Foo2", body: "Foo123-2" },
},
{
id: "foo3",
template: "simple_template",
bundled: 2,
trigger: { id: "foo" },
content: { title: "Foo3", body: "Foo123-3" },
},
];
sandbox.stub(Router, "_findProvider").returns(null);
await Router.setState({ messages });
const { target } = fakeAsyncMessage({
type: "TRIGGER",
data: { trigger: { id: "foo" } },
});
let { bundle } = await Router._getBundledMessages(messages[0], target, {
id: "foo",
});
assert.equal(bundle.length, 2);
// it should have picked foo1 and foo3 only
assert.isTrue(
bundle.every(elem => elem.id === "foo1" || elem.id === "foo3")
);
});
it("should have previousSessionEnd in the message context", () => {
assert.propertyVal(
Router._getMessagesContext(),
"previousSessionEnd",
100
);
});
});
describe(".includeBundle", () => {
let msg;
beforeEach(async () => {
let messages = [
{
id: "trailhead",
template: "trailhead",
includeBundle: {
length: 3,
template: "foo",
trigger: { id: "foo" },
},
trigger: { id: "firstRun" },
content: {},
},
{
id: "foo2",
template: "foo",
bundled: 3,
order: 2,
trigger: { id: "foo" },
content: { title: "Foo2", body: "Foo123-2" },
},
{
id: "foo3",
template: "foo",
bundled: 3,
order: 3,
trigger: { id: "foo" },
content: { title: "Foo3", body: "Foo123-3" },
},
{
id: "foo4",
template: "foo",
bundled: 3,
order: 1,
trigger: { id: "foo" },
content: { title: "Foo4", body: "Foo123-4" },
},
{
id: "foo5",
template: "foo",
bundled: 3,
order: 4,
trigger: { id: "foo" },
content: { title: "Foo5", body: "Foo123-5" },
},
];
sandbox.stub(Router, "_findProvider").returns(null);
await Router.setState({ messages });
msg = fakeAsyncMessage({
type: "TRIGGER",
data: { trigger: { id: "firstRun" } },
});
});
it("should send a message with .includeBundle property with specified length and template", async () => {
await Router.onMessage(msg);
const [, resp] = msg.target.sendAsyncMessage.firstCall.args;
assert.propertyVal(resp, "type", "SET_MESSAGE");
assert.isArray(resp.data.bundle, "resp.data.bundle");
assert.lengthOf(resp.data.bundle, 3, "resp.data.bundle");
});
it("should set blockOnClick property by default false on returned ordered bundle messages", async () => {
const expectedBundle = [
{
content: { title: "Foo4", body: "Foo123-4" },
id: "foo4",
order: 1,
blockOnClick: false,
},
{
content: { title: "Foo2", body: "Foo123-2" },
id: "foo2",
order: 2,
blockOnClick: false,
},
{
content: { title: "Foo3", body: "Foo123-3" },
id: "foo3",
order: 3,
blockOnClick: false,
},
];
await Router.onMessage(msg);
const [, resp] = msg.target.sendAsyncMessage.firstCall.args;
for (let i = 0; i < 3; i++) {
assert.deepEqual(resp.data.bundle[i], expectedBundle[i]);
}
});
it("should set blockOnClick property true for dynamic triplet and matching messages more than 3", async () => {
sandbox.replaceGetter(ASRouterPreferences, "trailhead", function() {
return {
trailheadInterrupt: "join",
trailheadTriplet: "dynamic",
};
});
await Router.onMessage(msg);
const [, resp] = msg.target.sendAsyncMessage.firstCall.args;
const expectedBundle = [
{
content: { title: "Foo4", body: "Foo123-4" },
id: "foo4",
order: 1,
blockOnClick: true,
},
{
content: { title: "Foo2", body: "Foo123-2" },
id: "foo2",
order: 2,
blockOnClick: true,
},
{
content: { title: "Foo3", body: "Foo123-3" },
id: "foo3",
order: 3,
blockOnClick: true,
},
];
for (let i = 0; i < 3; i++) {
assert.deepEqual(resp.data.bundle[i], expectedBundle[i]);
}
});
it("should set blockOnClick property true for triplet branch name that starts with 'dynamic' and matching messages more than 3", async () => {
sandbox.replaceGetter(ASRouterPreferences, "trailhead", function() {
return {
trailheadInterrupt: "join",
trailheadTriplet: "dynamic_test",
};
});
await Router.onMessage(msg);
const [, resp] = msg.target.sendAsyncMessage.firstCall.args;
const expectedBundle = [
{
content: { title: "Foo4", body: "Foo123-4" },
id: "foo4",
order: 1,
blockOnClick: true,
},
{
content: { title: "Foo2", body: "Foo123-2" },
id: "foo2",
order: 2,
blockOnClick: true,
},
{
content: { title: "Foo3", body: "Foo123-3" },
id: "foo3",
order: 3,
blockOnClick: true,
},
];
for (let i = 0; i < 3; i++) {
assert.deepEqual(resp.data.bundle[i], expectedBundle[i]);
}
});
});
describe("#onMessage: OVERRIDE_MESSAGE", () => {
it("should broadcast a SET_MESSAGE message to all clients with a particular id", async () => {
const [testMessage] = Router.state.messages;
const msg = fakeAsyncMessage({
type: "OVERRIDE_MESSAGE",
data: { id: testMessage.id },
});
await Router.onMessage(msg);
assert.calledWith(
msg.target.sendAsyncMessage,
PARENT_TO_CHILD_MESSAGE_NAME,
{ type: "SET_MESSAGE", data: testMessage }
);
});
it("should call CFRPageActions.forceRecommendation if the template is cfr_action and force is true", async () => {
sandbox.stub(CFRPageActions, "forceRecommendation");
const testMessage = { id: "foo", template: "cfr_doorhanger" };
await Router.setState({ messages: [testMessage] });
const msg = fakeAsyncMessage({
type: "OVERRIDE_MESSAGE",
data: { id: testMessage.id },
});
await Router.onMessage(msg);
assert.notCalled(msg.target.sendAsyncMessage);
assert.calledOnce(CFRPageActions.forceRecommendation);
});
it("should call BookmarkPanelHub._forceShowMessage the provider is cfr-fxa", async () => {
const testMessage = { id: "foo", template: "fxa_bookmark_panel" };
await Router.setState({ messages: [testMessage] });
const msg = fakeAsyncMessage({
type: "OVERRIDE_MESSAGE",
data: { id: testMessage.id },
});
await Router.onMessage(msg);
assert.notCalled(msg.target.sendAsyncMessage);
assert.calledOnce(FakeBookmarkPanelHub._forceShowMessage);
});
it("should call CFRPageActions.addRecommendation if the template is cfr_action and force is false", async () => {
sandbox.stub(CFRPageActions, "addRecommendation");
const testMessage = { id: "foo", template: "cfr_doorhanger" };
await Router.setState({ messages: [testMessage] });
await Router._sendMessageToTarget(
testMessage,
{},
{ param: {} },
false
);
assert.calledOnce(CFRPageActions.addRecommendation);
});
it("should broadcast CLEAR_ALL if provided id did not resolve to a message", async () => {
const msg = fakeAsyncMessage({
type: "OVERRIDE_MESSAGE",
data: { id: -1 },
});
await Router.onMessage(msg);
assert.calledWith(
msg.target.sendAsyncMessage,
PARENT_TO_CHILD_MESSAGE_NAME,
{ type: "CLEAR_ALL" }
);
});
});
describe("#onMessage: Onboarding actions", () => {
it("should call OpenBrowserWindow with a private window on OPEN_PRIVATE_BROWSER_WINDOW", async () => {
let [testMessage] = Router.state.messages;
const msg = fakeExecuteUserAction({
type: "OPEN_PRIVATE_BROWSER_WINDOW",
data: testMessage,
});
await Router.onMessage(msg);
assert.calledWith(msg.target.browser.ownerGlobal.OpenBrowserWindow, {
private: true,
});
});
it("should call openLinkIn with the correct params on OPEN_URL", async () => {
let [testMessage] = Router.state.messages;
testMessage.button_action = {
type: "OPEN_URL",
data: { args: "some/url.com", where: "tabshifted" },
};
const msg = fakeExecuteUserAction(testMessage.button_action);
await Router.onMessage(msg);
assert.calledOnce(msg.target.browser.ownerGlobal.openLinkIn);
assert.calledWith(
msg.target.browser.ownerGlobal.openLinkIn,
"some/url.com",
"tabshifted",
{ private: false, triggeringPrincipal: undefined, csp: null }
);
});
it("should call openLinkIn with the correct params on OPEN_ABOUT_PAGE", async () => {
let [testMessage] = Router.state.messages;
testMessage.button_action = {
type: "OPEN_ABOUT_PAGE",
data: { args: "something" },
};
const msg = fakeExecuteUserAction(testMessage.button_action);
await Router.onMessage(msg);
assert.calledOnce(msg.target.browser.ownerGlobal.openTrustedLinkIn);
assert.calledWith(
msg.target.browser.ownerGlobal.openTrustedLinkIn,
"about:something",
"tab"
);
});
it("should call MigrationUtils.showMigrationWizard on SHOW_MIGRATION_WIZARD", async () => {
let [testMessage] = Router.state.messages;
testMessage.button_action = {
type: "SHOW_MIGRATION_WIZARD",
};
const msg = fakeExecuteUserAction(testMessage.button_action);
globals.set("MigrationUtils", {
showMigrationWizard: sandbox
.stub()
.withArgs(msg.target.browser.ownerGlobal, ["test"]),
MIGRATION_ENTRYPOINT_NEWTAB: "test",
});
await Router.onMessage(msg);
assert.calledOnce(MigrationUtils.showMigrationWizard);
assert.calledWith(
MigrationUtils.showMigrationWizard,
msg.target.browser.ownerGlobal,
[MigrationUtils.MIGRATION_ENTRYPOINT_NEWTAB]
);
});
});
describe("#onMessage: SHOW_FIREFOX_ACCOUNTS", () => {
beforeEach(() => {
globals.set("FxAccounts", {
config: {
promiseConnectAccountURI: sandbox.stub().resolves("some/url"),
},
});
});
it("should call openLinkIn with the correct params on OPEN_URL", async () => {
let [testMessage] = Router.state.messages;
testMessage.button_action = { type: "SHOW_FIREFOX_ACCOUNTS" };
const msg = fakeExecuteUserAction(testMessage.button_action);
await Router.onMessage(msg);
assert.calledOnce(msg.target.browser.ownerGlobal.openLinkIn);
assert.calledWith(
msg.target.browser.ownerGlobal.openLinkIn,
"some/url",
"current",
{ private: false, triggeringPrincipal: undefined, csp: null }
);
});
});
describe("#onMessage: OPEN_PREFERENCES_PAGE", () => {
it("should call openPreferences with the correct params on OPEN_PREFERENCES_PAGE", async () => {
let [testMessage] = Router.state.messages;
testMessage.button_action = {
type: "OPEN_PREFERENCES_PAGE",
data: { category: "something" },
};
const msg = fakeExecuteUserAction(testMessage.button_action);
await Router.onMessage(msg);
assert.calledOnce(msg.target.browser.ownerGlobal.openPreferences);
assert.calledWith(
msg.target.browser.ownerGlobal.openPreferences,
"something"
);
});
});
describe("#onMessage: INSTALL_ADDON_FROM_URL", () => {
it("should call installAddonFromURL with correct arguments", async () => {
sandbox.stub(MessageLoaderUtils, "installAddonFromURL").resolves(null);
const msg = fakeExecuteUserAction({
type: "INSTALL_ADDON_FROM_URL",
data: { url: "foo.com", telemetrySource: "foo" },
});
await Router.onMessage(msg);
assert.calledOnce(MessageLoaderUtils.installAddonFromURL);
assert.calledWithExactly(
MessageLoaderUtils.installAddonFromURL,
msg.target.browser,
"foo.com",
"foo"
);
});
it("should add/remove observers for `webextension-install-notify`", async () => {
sandbox.spy(global.Services.obs, "addObserver");
sandbox.spy(global.Services.obs, "removeObserver");
sandbox.stub(MessageLoaderUtils, "installAddonFromURL").resolves(null);
const msg = fakeExecuteUserAction({
type: "INSTALL_ADDON_FROM_URL",
data: { url: "foo.com" },
});
await Router.onMessage(msg);
assert.calledOnce(global.Services.obs.addObserver);
const [cb] = global.Services.obs.addObserver.firstCall.args;
cb();
assert.calledOnce(global.Services.obs.removeObserver);
assert.calledOnce(channel.sendAsyncMessage);
});
});
describe("#onMessage: PIN_CURRENT_TAB", () => {
it("should call pin tab with the selectedTab", async () => {
const msg = fakeExecuteUserAction({ type: "PIN_CURRENT_TAB" });
const { gBrowser, ConfirmationHint } = msg.target.browser.ownerGlobal;
await Router.onMessage(msg);
assert.calledOnce(gBrowser.pinTab);
assert.calledWithExactly(gBrowser.pinTab, gBrowser.selectedTab);
assert.calledOnce(ConfirmationHint.show);
assert.calledWithExactly(
ConfirmationHint.show,
gBrowser.selectedTab,
"pinTab",
{ showDescription: true }
);
});
});
describe("#onMessage: OPEN_PROTECTION_PANEL", () => {
it("should open protection panel", async () => {
const msg = fakeExecuteUserAction({ type: "OPEN_PROTECTION_PANEL" });
let { gProtectionsHandler } = msg.target.browser.ownerGlobal;
await Router.onMessage(msg);
assert.calledOnce(gProtectionsHandler.showProtectionsPopup);
assert.calledWithExactly(gProtectionsHandler.showProtectionsPopup, {});
});
});
describe("#onMessage: OPEN_PROTECTION_REPORT", () => {
it("should open protection report", async () => {
const msg = fakeExecuteUserAction({ type: "OPEN_PROTECTION_REPORT" });
let { gProtectionsHandler } = msg.target.browser.ownerGlobal;
await Router.onMessage(msg);
assert.calledOnce(gProtectionsHandler.openProtections);
});
});
describe("#onMessage: DISABLE_STP_DOORHANGERS", () => {
it("should block STP related messages", async () => {
const msg = fakeExecuteUserAction({ type: "DISABLE_STP_DOORHANGERS" });
assert.deepEqual(Router.state.messageBlockList, []);
await Router.onMessage(msg);
assert.deepEqual(Router.state.messageBlockList, [
"SOCIAL_TRACKING_PROTECTION",
"FINGERPRINTERS_PROTECTION",
"CRYPTOMINERS_PROTECTION",
]);
});
});
describe("#dispatch(action, target)", () => {
it("should an action and target to onMessage", async () => {
// use the IMPRESSION action to make sure actions are actually getting processed
sandbox.stub(Router, "addImpression");
sandbox.spy(Router, "onMessage");
const target = {};
const action = { type: "IMPRESSION" };
Router.dispatch(action, target);
assert.calledWith(Router.onMessage, { data: action, target });
assert.calledOnce(Router.addImpression);
});
});
describe("#onMessage: DOORHANGER_TELEMETRY", () => {
it("should dispatch an AS_ROUTER_TELEMETRY_USER_EVENT on DOORHANGER_TELEMETRY message", async () => {
const msg = fakeAsyncMessage({
type: "DOORHANGER_TELEMETRY",
data: { message_id: "foo" },
});
dispatchStub.reset();
await Router.onMessage(msg);
assert.calledOnce(dispatchStub);
const [action] = dispatchStub.firstCall.args;
assert.equal(action.type, "AS_ROUTER_TELEMETRY_USER_EVENT");
assert.equal(action.data.message_id, "foo");
});
});
describe("#onMessage: EXPIRE_QUERY_CACHE", () => {
it("should clear all QueryCache getters", async () => {
const msg = fakeAsyncMessage({ type: "EXPIRE_QUERY_CACHE" });
sandbox.stub(QueryCache, "expireAll");
await Router.onMessage(msg);
assert.calledOnce(QueryCache.expireAll);
});
});
describe("#onMessage: ENABLE_PROVIDER", () => {
it("should enable the provider via ASRouterPreferences", async () => {
const msg = fakeAsyncMessage({ type: "ENABLE_PROVIDER", data: "foo" });
sandbox.stub(ASRouterPreferences, "enableOrDisableProvider");
await Router.onMessage(msg);
assert.calledWith(
ASRouterPreferences.enableOrDisableProvider,
"foo",
true
);
});
});
describe("#onMessage: DISABLE_PROVIDER", () => {
it("should disable the provider via ASRouterPreferences", async () => {
const msg = fakeAsyncMessage({ type: "DISABLE_PROVIDER", data: "foo" });
sandbox.stub(ASRouterPreferences, "enableOrDisableProvider");
await Router.onMessage(msg);
assert.calledWith(
ASRouterPreferences.enableOrDisableProvider,
"foo",
false
);
});
});
describe("#onMessage: RESET_PROVIDER_PREF", () => {
it("should reset provider pref via ASRouterPreferences", async () => {
const msg = fakeAsyncMessage({
type: "RESET_PROVIDER_PREF",
data: "foo",
});
sandbox.stub(ASRouterPreferences, "resetProviderPref");
await Router.onMessage(msg);
assert.calledOnce(ASRouterPreferences.resetProviderPref);
});
});
describe("#onMessage: SET_PROVIDER_USER_PREF", () => {
it("should set provider user pref via ASRouterPreferences", async () => {
const msg = fakeAsyncMessage({
type: "SET_PROVIDER_USER_PREF",
data: { id: "foo", value: true },
});
sandbox.stub(ASRouterPreferences, "setUserPreference");
await Router.onMessage(msg);
assert.calledWith(ASRouterPreferences.setUserPreference, "foo", true);
});
});
describe("#onMessage: EVALUATE_JEXL_EXPRESSION", () => {
it("should call evaluateExpression", async () => {
const msg = fakeAsyncMessage({
type: "EVALUATE_JEXL_EXPRESSION",
data: { foo: true },
});
sandbox.stub(Router, "evaluateExpression");
await Router.onMessage(msg);
assert.calledOnce(Router.evaluateExpression);
assert.calledWithExactly(
Router.evaluateExpression,
msg.target,
msg.data.data
);
});
});
describe("#onMessage: FORCE_ATTRIBUTION", () => {
beforeEach(() => {
global.Cc["@mozilla.org/mac-attribution;1"] = {
getService: () => ({ setReferrerUrl: sinon.spy() }),
};
global.Cc["@mozilla.org/process/environment;1"] = {
getService: () => ({ set: sandbox.stub() }),
};
});
afterEach(() => {
globals.restore();
});
it("should call forceAttribution", async () => {
const msg = fakeAsyncMessage({
type: "FORCE_ATTRIBUTION",
data: { foo: true },
});
sandbox.stub(Router, "forceAttribution");
await Router.onMessage(msg);
assert.calledOnce(Router.forceAttribution);
assert.calledWithExactly(Router.forceAttribution, msg.data.data);
});
it("should force attribution and update providers", async () => {
sandbox.stub(Router, "_updateMessageProviders");
sandbox.stub(Router, "loadMessagesFromAllProviders");
sandbox.stub(fakeAttributionCode, "_clearCache");
sandbox.stub(fakeAttributionCode, "getAttrDataAsync");
const msg = fakeAsyncMessage({
type: "FORCE_ATTRIBUTION",
data: { foo: true },
});
await Router.onMessage(msg);
assert.calledOnce(fakeAttributionCode._clearCache);
assert.calledOnce(fakeAttributionCode.getAttrDataAsync);
assert.calledOnce(Router._updateMessageProviders);
assert.calledOnce(Router.loadMessagesFromAllProviders);
});
});
describe("#onMessage: default", () => {
it("should report unknown messages", () => {
const msg = fakeAsyncMessage({ type: "FOO" });
sandbox.stub(Cu, "reportError");
Router.onMessage(msg);
assert.calledOnce(Cu.reportError);
});
});
});
describe("_triggerHandler", () => {
it("should call #onMessage with the correct trigger", () => {
const getter = sandbox.stub();
getter.returns(false);
sandbox.stub(global.BrowserHandler, "kiosk").get(getter);
sinon.spy(Router, "onMessage");
const target = {};
const trigger = { id: "FAKE_TRIGGER", param: "some fake param" };
Router._triggerHandler(target, trigger);
assert.calledOnce(Router.onMessage);
assert.calledWithExactly(Router.onMessage, {
target,
data: { type: "TRIGGER", data: { trigger } },
});
});
});
describe("_triggerHandler_kiosk", () => {
it("should not call #onMessage", () => {
const getter = sandbox.stub();
getter.returns(true);
sandbox.stub(global.BrowserHandler, "kiosk").get(getter);
sinon.spy(Router, "onMessage");
const target = {};
const trigger = { id: "FAKE_TRIGGER", param: "some fake param" };
Router._triggerHandler(target, trigger);
assert.notCalled(Router.onMessage);
});
});
describe("#UITour", () => {
let showMenuStub;
const highlightTarget = { target: "target" };
beforeEach(() => {
showMenuStub = sandbox.stub();
globals.set("UITour", {
showMenu: showMenuStub,
getTarget: sandbox
.stub()
.withArgs(sinon.match.object, "pageAaction-sendToDevice")
.resolves(highlightTarget),
showHighlight: sandbox.stub(),
});
});
it("should call UITour.showMenu with the correct params on OPEN_APPLICATIONS_MENU", async () => {
const msg = fakeExecuteUserAction({
type: "OPEN_APPLICATIONS_MENU",
data: { args: "appMenu" },
});
await Router.onMessage(msg);
assert.calledOnce(showMenuStub);
assert.calledWith(
showMenuStub,
msg.target.browser.ownerGlobal,
"appMenu"
);
});
it("should call UITour.showHighlight with the correct params on HIGHLIGHT_FEATURE", async () => {
const msg = fakeExecuteUserAction({
type: "HIGHLIGHT_FEATURE",
data: { args: "pageAction-sendToDevice" },
});
await Router.onMessage(msg);
assert.calledOnce(UITour.getTarget);
assert.calledOnce(UITour.showHighlight);
assert.calledWith(
UITour.showHighlight,
msg.target.browser.ownerGlobal,
highlightTarget
);
});
});
describe("valid preview endpoint", () => {
it("should report an error if url protocol is not https", () => {
sandbox.stub(Cu, "reportError");
assert.equal(false, Router._validPreviewEndpoint("http://foo.com"));
assert.calledTwice(Cu.reportError);
});
});
describe("impressions", () => {
async function addProviderWithFrequency(id, frequency) {
await Router.setState(state => {
const newProvider = { id, frequency };
const providers = [...state.providers, newProvider];
return { providers };
});
}
describe("frequency normalisation", () => {
beforeEach(async () => {
const messages = [
{ frequency: { custom: [{ period: "daily", cap: 10 }] } },
];
const provider = {
id: "foo",
frequency: { custom: [{ period: "daily", cap: 100 }] },
messages,
enabled: true,
};
await createRouterAndInit([provider]);
});
it("period aliases in provider frequency caps should be normalised", () => {
const [provider] = Router.state.providers;
assert.equal(provider.frequency.custom[0].period, ONE_DAY_IN_MS);
});
it("period aliases in message frequency caps should be normalised", async () => {
const [message] = Router.state.messages;
assert.equal(message.frequency.custom[0].period, ONE_DAY_IN_MS);
});
});
describe("#addImpression", () => {
it("should add a message impression and update _storage with the current time if the message has frequency caps", async () => {
clock.tick(42);
const msg = fakeAsyncMessage({
type: "IMPRESSION",
data: {
id: "foo",
provider: FAKE_LOCAL_PROVIDER.id,
frequency: { lifetime: 5 },
},
});
await Router.onMessage(msg);
assert.isArray(Router.state.messageImpressions.foo);
assert.deepEqual(Router.state.messageImpressions.foo, [42]);
assert.calledWith(Router._storage.set, "messageImpressions", {
foo: [42],
});
});
it("should not add a message impression if the message doesn't have frequency caps", async () => {
// Note that storage.set is called during initialization, so it needs to be reset
Router._storage.set.reset();
clock.tick(42);
const msg = fakeAsyncMessage({
type: "IMPRESSION",
data: { id: "foo" },
});
await Router.onMessage(msg);
assert.notProperty(Router.state.messageImpressions, "foo");
assert.notCalled(Router._storage.set);
});
it("should add a provider impression and update _storage with the current time if the message's provider has frequency caps", async () => {
clock.tick(42);
await addProviderWithFrequency("foo", { lifetime: 5 });
const msg = fakeAsyncMessage({
type: "IMPRESSION",
data: { id: "bar", provider: "foo" },
});
await Router.onMessage(msg);
assert.isArray(Router.state.providerImpressions.foo);
assert.deepEqual(Router.state.providerImpressions.foo, [42]);
assert.calledWith(Router._storage.set, "providerImpressions", {
foo: [42],
});
});
it("should not add a provider impression if the message's provider doesn't have frequency caps", async () => {
// Note that storage.set is called during initialization, so it needs to be reset
Router._storage.set.reset();
clock.tick(42);
// Add "foo" provider with no frequency
await addProviderWithFrequency("foo", null);
const msg = fakeAsyncMessage({
type: "IMPRESSION",
data: { id: "bar", provider: "foo" },
});
await Router.onMessage(msg);
assert.notProperty(Router.state.providerImpressions, "foo");
assert.notCalled(Router._storage.set);
});
it("should only send impressions for one message", async () => {
const getElementById = sandbox.stub().returns({
setAttribute: sandbox.stub(),
style: { setProperty: sandbox.stub() },
addEventListener: sandbox.stub(),
});
const data = {
param: { host: "mozilla.com", url: "https://mozilla.com" },
};
const target = {
sendAsyncMessage: sandbox.stub(),
documentURI: { scheme: "https", host: "mozilla.com" },
};
target.ownerGlobal = {
gBrowser: { selectedBrowser: target },
document: { getElementById },
promiseDocumentFlushed: sandbox.stub().resolves([{ width: 0 }]),
setTimeout: sandbox.stub(),
A11yUtils: { announce: sandbox.stub() },
};
const firstMessage = { ...FAKE_RECOMMENDATION, id: "first_message" };
const secondMessage = { ...FAKE_RECOMMENDATION, id: "second_message" };
await Router.setState({ messages: [firstMessage, secondMessage] });
global.DOMLocalization = class DOMLocalization {};
sandbox.spy(CFRPageActions, "addRecommendation");
sandbox.stub(Router, "addImpression").resolves();
await Router.setMessageById("first_message", target, false, { data });
await Router.setMessageById("second_message", target, false, { data });
assert.calledTwice(CFRPageActions.addRecommendation);
const [
firstReturn,
secondReturn,
] = CFRPageActions.addRecommendation.returnValues;
assert.isTrue(await firstReturn);
// Adding the second message should fail.
assert.isFalse(await secondReturn);
assert.calledOnce(Router.addImpression);
});
});
describe("#isBelowFrequencyCaps", () => {
it("should call #_isBelowItemFrequencyCap for the message and for the provider with the correct impressions and arguments", async () => {
sinon.spy(Router, "_isBelowItemFrequencyCap");
const MAX_MESSAGE_LIFETIME_CAP = 100; // Defined in ASRouter
const fooMessageImpressions = [0, 1];
const barProviderImpressions = [0, 1, 2];
const message = {
id: "foo",
provider: "bar",
frequency: { lifetime: 3 },
};
const provider = { id: "bar", frequency: { lifetime: 5 } };
await Router.setState(state => {
// Add provider
const providers = [...state.providers, provider];
// Add fooMessageImpressions
// eslint-disable-next-line no-shadow
const messageImpressions = Object.assign(
{},
state.messageImpressions
);
messageImpressions.foo = fooMessageImpressions;
// Add barProviderImpressions
// eslint-disable-next-line no-shadow
const providerImpressions = Object.assign(
{},
state.providerImpressions
);
providerImpressions.bar = barProviderImpressions;
return { providers, messageImpressions, providerImpressions };
});
await Router.isBelowFrequencyCaps(message);
assert.calledTwice(Router._isBelowItemFrequencyCap);
assert.calledWithExactly(
Router._isBelowItemFrequencyCap,
message,
fooMessageImpressions,
MAX_MESSAGE_LIFETIME_CAP
);
assert.calledWithExactly(
Router._isBelowItemFrequencyCap,
provider,
barProviderImpressions
);
});
});
describe("#_isBelowItemFrequencyCap", () => {
it("should return false if the # of impressions exceeds the maxLifetimeCap", () => {
const item = { id: "foo", frequency: { lifetime: 5 } };
const impressions = [0, 1];
const maxLifetimeCap = 1;
const result = Router._isBelowItemFrequencyCap(
item,
impressions,
maxLifetimeCap
);
assert.isFalse(result);
});
describe("lifetime frequency caps", () => {
it("should return true if .frequency is not defined on the item", () => {
const item = { id: "foo" };
const impressions = [0, 1];
const result = Router._isBelowItemFrequencyCap(item, impressions);
assert.isTrue(result);
});
it("should return true if there are no impressions", () => {
const item = {
id: "foo",
frequency: {
lifetime: 10,
custom: [{ period: ONE_DAY_IN_MS, cap: 2 }],
},
};
const impressions = [];
const result = Router._isBelowItemFrequencyCap(item, impressions);
assert.isTrue(result);
});
it("should return true if the # of impressions is less than .frequency.lifetime of the item", () => {
const item = { id: "foo", frequency: { lifetime: 3 } };
const impressions = [0, 1];
const result = Router._isBelowItemFrequencyCap(item, impressions);
assert.isTrue(result);
});
it("should return false if the # of impressions is equal to .frequency.lifetime of the item", async () => {
const item = { id: "foo", frequency: { lifetime: 3 } };
const impressions = [0, 1, 2];
const result = Router._isBelowItemFrequencyCap(item, impressions);
assert.isFalse(result);
});
it("should return false if the # of impressions is greater than .frequency.lifetime of the item", async () => {
const item = { id: "foo", frequency: { lifetime: 3 } };
const impressions = [0, 1, 2, 3];
const result = Router._isBelowItemFrequencyCap(item, impressions);
assert.isFalse(result);
});
});
describe("custom frequency caps", () => {
it("should return true if impressions in the time period < the cap and total impressions < the lifetime cap", () => {
clock.tick(ONE_DAY_IN_MS + 10);
const item = {
id: "foo",
frequency: {
custom: [{ period: ONE_DAY_IN_MS, cap: 2 }],
lifetime: 3,
},
};
const impressions = [0, ONE_DAY_IN_MS + 1];
const result = Router._isBelowItemFrequencyCap(item, impressions);
assert.isTrue(result);
});
it("should return false if impressions in the time period > the cap and total impressions < the lifetime cap", () => {
clock.tick(200);
const item = {
id: "msg1",
frequency: { custom: [{ period: 100, cap: 2 }], lifetime: 3 },
};
const impressions = [0, 160, 161];
const result = Router._isBelowItemFrequencyCap(item, impressions);
assert.isFalse(result);
});
it("should return false if impressions in one of the time periods > the cap and total impressions < the lifetime cap", () => {
clock.tick(ONE_DAY_IN_MS + 200);
const itemTrue = {
id: "msg2",
frequency: { custom: [{ period: 100, cap: 2 }] },
};
const itemFalse = {
id: "msg1",
frequency: {
custom: [
{ period: 100, cap: 2 },
{ period: ONE_DAY_IN_MS, cap: 3 },
],
},
};
const impressions = [
0,
ONE_DAY_IN_MS + 160,
ONE_DAY_IN_MS - 100,
ONE_DAY_IN_MS - 200,
];
assert.isTrue(Router._isBelowItemFrequencyCap(itemTrue, impressions));
assert.isFalse(
Router._isBelowItemFrequencyCap(itemFalse, impressions)
);
});
it("should return false if impressions in the time period < the cap and total impressions > the lifetime cap", () => {
clock.tick(ONE_DAY_IN_MS + 10);
const item = {
id: "msg1",
frequency: {
custom: [{ period: ONE_DAY_IN_MS, cap: 2 }],
lifetime: 3,
},
};
const impressions = [0, 1, 2, 3, ONE_DAY_IN_MS + 1];
const result = Router._isBelowItemFrequencyCap(item, impressions);
assert.isFalse(result);
});
it("should return true if daily impressions < the daily cap and there is no lifetime cap", () => {
clock.tick(ONE_DAY_IN_MS + 10);
const item = {
id: "msg1",
frequency: { custom: [{ period: ONE_DAY_IN_MS, cap: 2 }] },
};
const impressions = [0, 1, 2, 3, ONE_DAY_IN_MS + 1];
const result = Router._isBelowItemFrequencyCap(item, impressions);
assert.isTrue(result);
});
it("should return false if daily impressions > the daily cap and there is no lifetime cap", () => {
clock.tick(ONE_DAY_IN_MS + 10);
const item = {
id: "msg1",
frequency: { custom: [{ period: ONE_DAY_IN_MS, cap: 2 }] },
};
const impressions = [
0,
1,
2,
3,
ONE_DAY_IN_MS + 1,
ONE_DAY_IN_MS + 2,
ONE_DAY_IN_MS + 3,
];
const result = Router._isBelowItemFrequencyCap(item, impressions);
assert.isFalse(result);
});
});
});
describe("#getLongestPeriod", () => {
it("should return the period if there is only one definition", () => {
const message = {
id: "foo",
frequency: { custom: [{ period: 200, cap: 2 }] },
};
assert.equal(Router.getLongestPeriod(message), 200);
});
it("should return the longest period if there are more than one definitions", () => {
const message = {
id: "foo",
frequency: {
custom: [
{ period: 1000, cap: 3 },
{ period: ONE_DAY_IN_MS, cap: 5 },
{ period: 100, cap: 2 },
],
},
};
assert.equal(Router.getLongestPeriod(message), ONE_DAY_IN_MS);
});
it("should return null if there are is no .frequency", () => {
const message = { id: "foo" };
assert.isNull(Router.getLongestPeriod(message));
});
it("should return null if there are is no .frequency.custom", () => {
const message = { id: "foo", frequency: { lifetime: 10 } };
assert.isNull(Router.getLongestPeriod(message));
});
});
describe("cleanup on init", () => {
it("should clear messageImpressions for messages which do not exist in state.messages", async () => {
const messages = [{ id: "foo", frequency: { lifetime: 10 } }];
messageImpressions = { foo: [0], bar: [0, 1] };
// Impressions for "bar" should be removed since that id does not exist in messages
const result = { foo: [0] };
await createRouterAndInit([
{ id: "onboarding", type: "local", messages, enabled: true },
]);
assert.calledWith(Router._storage.set, "messageImpressions", result);
assert.deepEqual(Router.state.messageImpressions, result);
});
it("should clear messageImpressions older than the period if no lifetime impression cap is included", async () => {
const CURRENT_TIME = ONE_DAY_IN_MS * 2;
clock.tick(CURRENT_TIME);
const messages = [
{
id: "foo",
frequency: { custom: [{ period: ONE_DAY_IN_MS, cap: 5 }] },
},
];
messageImpressions = { foo: [0, 1, CURRENT_TIME - 10] };
// Only 0 and 1 are more than 24 hours before CURRENT_TIME
const result = { foo: [CURRENT_TIME - 10] };
await createRouterAndInit([
{ id: "onboarding", type: "local", messages, enabled: true },
]);
assert.calledWith(Router._storage.set, "messageImpressions", result);
assert.deepEqual(Router.state.messageImpressions, result);
});
it("should clear messageImpressions older than the longest period if no lifetime impression cap is included", async () => {
const CURRENT_TIME = ONE_DAY_IN_MS * 2;
clock.tick(CURRENT_TIME);
const messages = [
{
id: "foo",
frequency: {
custom: [
{ period: ONE_DAY_IN_MS, cap: 5 },
{ period: 100, cap: 2 },
],
},
},
];
messageImpressions = { foo: [0, 1, CURRENT_TIME - 10] };
// Only 0 and 1 are more than 24 hours before CURRENT_TIME
const result = { foo: [CURRENT_TIME - 10] };
await createRouterAndInit([
{ id: "onboarding", type: "local", messages, enabled: true },
]);
assert.calledWith(Router._storage.set, "messageImpressions", result);
assert.deepEqual(Router.state.messageImpressions, result);
});
it("should clear messageImpressions if they are not properly formatted", async () => {
const messages = [{ id: "foo", frequency: { lifetime: 10 } }];
// this is impromperly formatted since messageImpressions are supposed to be an array
messageImpressions = { foo: 0 };
const result = {};
await createRouterAndInit([
{ id: "onboarding", type: "local", messages, enabled: true },
]);
assert.calledWith(Router._storage.set, "messageImpressions", result);
assert.deepEqual(Router.state.messageImpressions, result);
});
it("should not clear messageImpressions for messages which do exist in state.messages", async () => {
const messages = [
{ id: "foo", frequency: { lifetime: 10 } },
{ id: "bar", frequency: { lifetime: 10 } },
];
messageImpressions = { foo: [0], bar: [] };
await createRouterAndInit([
{ id: "onboarding", type: "local", messages, enabled: true },
]);
assert.notCalled(Router._storage.set);
assert.deepEqual(Router.state.messageImpressions, messageImpressions);
});
});
});
describe("handle targeting errors", () => {
it("should dispatch an event when a targeting expression throws an error", async () => {
sandbox
.stub(global.FilterExpressions, "eval")
.returns(Promise.reject(new Error("fake error")));
await Router.setState({
messages: [{ id: "foo", provider: "snippets", targeting: "foo2.[[(" }],
});
const msg = fakeAsyncMessage({ type: "NEWTAB_MESSAGE_REQUEST" });
dispatchStub.reset();
await Router.onMessage(msg);
assert.calledOnce(dispatchStub);
const [action] = dispatchStub.firstCall.args;
assert.equal(action.type, "AS_ROUTER_TELEMETRY_USER_EVENT");
assert.equal(action.data.message_id, "foo");
});
});
describe("#_onLocaleChanged", () => {
it("should call _maybeUpdateL10nAttachment in the handler", async () => {
sandbox.spy(Router, "_maybeUpdateL10nAttachment");
await Router._onLocaleChanged();
assert.calledOnce(Router._maybeUpdateL10nAttachment);
});
});
describe("#_maybeUpdateL10nAttachment", () => {
it("should update the l10n attachment if the locale was changed", async () => {
const getter = sandbox.stub();
getter.onFirstCall().returns("en-US");
getter.onSecondCall().returns("fr");
sandbox.stub(global.Services.locale, "appLocaleAsLangTag").get(getter);
const provider = {
id: "cfr",
enabled: true,
type: "remote-settings",
bucket: "cfr",
};
await createRouterAndInit([provider]);
sandbox.spy(Router, "setState");
sandbox.spy(Router, "loadMessagesFromAllProviders");
await Router._maybeUpdateL10nAttachment();
assert.calledWith(Router.setState, {
localeInUse: "fr",
providers: [
{
id: "cfr",
enabled: true,
type: "remote-settings",
bucket: "cfr",
lastUpdated: undefined,
errors: [],
},
],
});
assert.calledOnce(Router.loadMessagesFromAllProviders);
});
it("should not update the l10n attachment if the provider doesn't need l10n attachment", async () => {
const getter = sandbox.stub();
getter.onFirstCall().returns("en-US");
getter.onSecondCall().returns("fr");
sandbox.stub(global.Services.locale, "appLocaleAsLangTag").get(getter);
const provider = {
id: "localProvider",
enabled: true,
type: "local",
};
await createRouterAndInit([provider]);
sandbox.spy(Router, "setState");
sandbox.spy(Router, "loadMessagesFromAllProviders");
await Router._maybeUpdateL10nAttachment();
assert.notCalled(Router.setState);
assert.notCalled(Router.loadMessagesFromAllProviders);
});
});
describe("#observe", () => {
it("should reload l10n for CFRPageActions when the `USE_REMOTE_L10N_PREF` pref is changed", () => {
sandbox.spy(CFRPageActions, "reloadL10n");
Router.observe("", "", USE_REMOTE_L10N_PREF);
assert.calledOnce(CFRPageActions.reloadL10n);
});
it("should not react to other pref changes", () => {
sandbox.spy(CFRPageActions, "reloadL10n");
Router.observe("", "", "foo");
assert.notCalled(CFRPageActions.reloadL10n);
});
});
describe("#sendAsyncMessageToPreloaded", () => {
it("should send the message to the preloaded browser if there's data and a preloaded browser exists", () => {
const port = {
browser: {
getAttribute() {
return "preloaded";
},
},
sendAsyncMessage: sinon.spy(),
};
Router.messageChannel.messagePorts.push(port);
const action = { type: "FOO" };
Router.sendAsyncMessageToPreloaded(action);
assert.calledWith(port.sendAsyncMessage, OUTGOING_MESSAGE_NAME, action);
});
it("should send the message to all the preloaded browsers if there's data and they exist", () => {
const port = {
browser: {
getAttribute() {
return "preloaded";
},
},
sendAsyncMessage: sinon.spy(),
};
Router.messageChannel.messagePorts.push(port);
Router.messageChannel.messagePorts.push(port);
Router.sendAsyncMessageToPreloaded({ type: "FOO" });
assert.calledTwice(port.sendAsyncMessage);
});
it("should not send the message to the preloaded browser if there's no data and a preloaded browser does not exists", () => {
const port = {
browser: {
getAttribute() {
return "consumed";
},
},
sendAsyncMessage: sinon.spy(),
};
Router.messageChannel.messagePorts.push(port);
Router.sendAsyncMessageToPreloaded({ type: "FOO" });
assert.notCalled(port.sendAsyncMessage);
});
});
});
================================================
FILE: test/unit/asrouter/ASRouterFeed.test.js
================================================
import { _ASRouter, ASRouter } from "lib/ASRouter.jsm";
import { FAKE_LOCAL_PROVIDER, FakeRemotePageManager } from "./constants";
import { ASRouterFeed } from "lib/ASRouterFeed.jsm";
import { actionTypes as at } from "common/Actions.jsm";
import { GlobalOverrider } from "test/unit/utils";
import { ASRouterPreferences } from "lib/ASRouterPreferences.jsm";
describe("ASRouterFeed", () => {
let Router;
let feed;
let channel;
let sandbox;
let storage;
let globals;
let FakeBookmarkPanelHub;
let FakeToolbarBadgeHub;
let FakeToolbarPanelHub;
beforeEach(() => {
sandbox = sinon.createSandbox();
globals = new GlobalOverrider();
FakeBookmarkPanelHub = {
init: sandbox.stub(),
uninit: sandbox.stub(),
};
FakeToolbarBadgeHub = {
init: sandbox.stub(),
};
FakeToolbarPanelHub = {
init: sandbox.stub(),
uninit: sandbox.stub(),
};
globals.set({
ASRouterPreferences,
BookmarkPanelHub: FakeBookmarkPanelHub,
ToolbarBadgeHub: FakeToolbarBadgeHub,
ToolbarPanelHub: FakeToolbarPanelHub,
});
Router = new _ASRouter({ providers: [FAKE_LOCAL_PROVIDER] });
FakeToolbarPanelHub = {
init: sandbox.stub(),
uninit: sandbox.stub(),
};
storage = {
get: sandbox.stub().returns(Promise.resolve([])),
set: sandbox.stub().returns(Promise.resolve()),
};
feed = new ASRouterFeed({ router: Router }, storage);
channel = new FakeRemotePageManager();
feed.store = {
_messageChannel: { channel },
getState: () => ({}),
dbStorage: { getDbTable: sandbox.stub().returns({}) },
};
});
afterEach(() => {
sandbox.restore();
});
it("should set .router to the ASRouter singleton if none is specified in options", () => {
feed = new ASRouterFeed();
assert.equal(feed.router, ASRouter);
feed = new ASRouterFeed({});
assert.equal(feed.router, ASRouter);
});
describe("#onAction: INIT", () => {
it("should initialize the ASRouter if it is not initialized", () => {
sandbox.stub(feed, "enable");
feed.onAction({ type: at.INIT });
assert.calledOnce(feed.enable);
});
it("should initialize ASRouter", async () => {
sandbox.stub(Router, "init").returns(Promise.resolve());
await feed.enable();
assert.calledWith(Router.init, channel);
assert.calledOnce(feed.store.dbStorage.getDbTable);
assert.calledWithExactly(feed.store.dbStorage.getDbTable, "snippets");
});
it("should not re-initialize the ASRouter if it is already initialized", async () => {
// Router starts initialized
await Router.init(new FakeRemotePageManager(), storage, () => {});
sinon.stub(Router, "init");
// call .onAction with INIT
feed.onAction({ type: at.INIT });
assert.notCalled(Router.init);
});
});
describe("#onAction: UNINIT", () => {
it("should uninitialize the ASRouter", async () => {
await Router.init(new FakeRemotePageManager(), storage, () => {});
sinon.stub(Router, "uninit");
feed.onAction({ type: at.UNINIT });
assert.calledOnce(Router.uninit);
});
});
});
================================================
FILE: test/unit/asrouter/ASRouterPreferences.test.js
================================================
import {
_ASRouterPreferences,
ASRouterPreferences as ASRouterPreferencesSingleton,
getTrailheadConfigFromPref,
TEST_PROVIDERS,
} from "lib/ASRouterPreferences.jsm";
const FAKE_PROVIDERS = [{ id: "foo" }, { id: "bar" }];
const PROVIDER_PREF_BRANCH =
"browser.newtabpage.activity-stream.asrouter.providers.";
const DEVTOOLS_PREF =
"browser.newtabpage.activity-stream.asrouter.devtoolsEnabled";
const SNIPPETS_USER_PREF = "browser.newtabpage.activity-stream.feeds.snippets";
const CFR_USER_PREF_ADDONS =
"browser.newtabpage.activity-stream.asrouter.userprefs.cfr.addons";
const CFR_USER_PREF_FEATURES =
"browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features";
/** NUMBER_OF_PREFS_TO_OBSERVE includes:
* 1. asrouter.providers. pref branch
* 2. asrouter.devtoolsEnabled
* 3. browser.newtabpage.activity-stream.feeds.snippets (user preference - snippets)
* 4. browser.newtabpage.activity-stream.asrouter.userprefs.cfr.addons (user preference - cfr)
* 4. browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features (user preference - cfr)
* 5. services.sync.username
*/
const NUMBER_OF_PREFS_TO_OBSERVE = 6;
describe("ASRouterPreferences", () => {
let ASRouterPreferences;
let sandbox;
let addObserverStub;
let stringPrefStub;
let boolPrefStub;
beforeEach(() => {
ASRouterPreferences = new _ASRouterPreferences();
sandbox = sinon.createSandbox();
addObserverStub = sandbox.stub(global.Services.prefs, "addObserver");
stringPrefStub = sandbox.stub(global.Services.prefs, "getStringPref");
FAKE_PROVIDERS.forEach(provider => {
stringPrefStub
.withArgs(`${PROVIDER_PREF_BRANCH}${provider.id}`)
.returns(JSON.stringify(provider));
});
sandbox
.stub(global.Services.prefs, "getChildList")
.withArgs(PROVIDER_PREF_BRANCH)
.returns(
FAKE_PROVIDERS.map(provider => `${PROVIDER_PREF_BRANCH}${provider.id}`)
);
boolPrefStub = sandbox
.stub(global.Services.prefs, "getBoolPref")
.returns(false);
});
afterEach(() => {
sandbox.restore();
});
function getPrefNameForProvider(providerId) {
return `${PROVIDER_PREF_BRANCH}${providerId}`;
}
function setPrefForProvider(providerId, value) {
stringPrefStub
.withArgs(getPrefNameForProvider(providerId))
.returns(JSON.stringify(value));
}
it("ASRouterPreferences should be an instance of _ASRouterPreferences", () => {
assert.instanceOf(ASRouterPreferencesSingleton, _ASRouterPreferences);
});
describe("#init", () => {
it("should set ._initialized to true", () => {
ASRouterPreferences.init();
assert.isTrue(ASRouterPreferences._initialized);
});
it(`should set ${NUMBER_OF_PREFS_TO_OBSERVE} observers and not re-initialize if already initialized`, () => {
ASRouterPreferences.init();
assert.callCount(addObserverStub, NUMBER_OF_PREFS_TO_OBSERVE);
ASRouterPreferences.init();
ASRouterPreferences.init();
assert.callCount(addObserverStub, NUMBER_OF_PREFS_TO_OBSERVE);
});
});
describe("#uninit", () => {
it("should set ._initialized to false", () => {
ASRouterPreferences.init();
ASRouterPreferences.uninit();
assert.isFalse(ASRouterPreferences._initialized);
});
it("should clear cached values for ._initialized, .devtoolsEnabled", () => {
ASRouterPreferences.init();
// trigger caching
// eslint-disable-next-line no-unused-vars
const result = [
ASRouterPreferences.providers,
ASRouterPreferences.devtoolsEnabled,
];
assert.isNotNull(
ASRouterPreferences._providers,
"providers should not be null"
);
assert.isNotNull(
ASRouterPreferences._devtoolsEnabled,
"devtolosEnabled should not be null"
);
ASRouterPreferences.uninit();
assert.isNull(ASRouterPreferences._providers);
assert.isNull(ASRouterPreferences._devtoolsEnabled);
});
it("should clear all listeners and remove observers (only once)", () => {
const removeStub = sandbox.stub(global.Services.prefs, "removeObserver");
ASRouterPreferences.init();
ASRouterPreferences.addListener(() => {});
ASRouterPreferences.addListener(() => {});
assert.equal(ASRouterPreferences._callbacks.size, 2);
ASRouterPreferences.uninit();
// Tests to make sure we don't remove observers that weren't set
ASRouterPreferences.uninit();
assert.callCount(removeStub, NUMBER_OF_PREFS_TO_OBSERVE);
assert.calledWith(removeStub, PROVIDER_PREF_BRANCH);
assert.calledWith(removeStub, DEVTOOLS_PREF);
assert.isEmpty(ASRouterPreferences._callbacks);
});
});
describe(".providers", () => {
it("should return the value the first time .providers is accessed", () => {
ASRouterPreferences.init();
const result = ASRouterPreferences.providers;
assert.deepEqual(result, FAKE_PROVIDERS);
// once per pref
assert.calledTwice(stringPrefStub);
});
it("should return the cached value the second time .providers is accessed", () => {
ASRouterPreferences.init();
const [, secondCall] = [
ASRouterPreferences.providers,
ASRouterPreferences.providers,
];
assert.deepEqual(secondCall, FAKE_PROVIDERS);
// once per pref
assert.calledTwice(stringPrefStub);
});
it("should just parse the pref each time if ASRouterPreferences hasn't been initialized yet", () => {
// Intentionally not initialized
const [firstCall, secondCall] = [
ASRouterPreferences.providers,
ASRouterPreferences.providers,
];
assert.deepEqual(firstCall, FAKE_PROVIDERS);
assert.deepEqual(secondCall, FAKE_PROVIDERS);
assert.callCount(stringPrefStub, 4);
});
it("should skip the pref without throwing if a pref is not parsable", () => {
stringPrefStub.withArgs(`${PROVIDER_PREF_BRANCH}foo`).returns("not json");
ASRouterPreferences.init();
assert.deepEqual(ASRouterPreferences.providers, [{ id: "bar" }]);
});
it("should include TEST_PROVIDERS if devtools is turned on", () => {
boolPrefStub.withArgs(DEVTOOLS_PREF).returns(true);
ASRouterPreferences.init();
assert.deepEqual(ASRouterPreferences.providers, [
...TEST_PROVIDERS,
...FAKE_PROVIDERS,
]);
});
});
describe(".devtoolsEnabled", () => {
it("should read the pref the first time .devtoolsEnabled is accessed", () => {
ASRouterPreferences.init();
const result = ASRouterPreferences.devtoolsEnabled;
assert.deepEqual(result, false);
assert.calledOnce(boolPrefStub);
});
it("should return the cached value the second time .devtoolsEnabled is accessed", () => {
ASRouterPreferences.init();
const [, secondCall] = [
ASRouterPreferences.devtoolsEnabled,
ASRouterPreferences.devtoolsEnabled,
];
assert.deepEqual(secondCall, false);
assert.calledOnce(boolPrefStub);
});
it("should just parse the pref each time if ASRouterPreferences hasn't been initialized yet", () => {
// Intentionally not initialized
const [firstCall, secondCall] = [
ASRouterPreferences.devtoolsEnabled,
ASRouterPreferences.devtoolsEnabled,
];
assert.deepEqual(firstCall, false);
assert.deepEqual(secondCall, false);
assert.calledTwice(boolPrefStub);
});
});
describe("#getUserPreference(providerId)", () => {
it("should return the user preference for snippets", () => {
boolPrefStub.withArgs(SNIPPETS_USER_PREF).returns(true);
assert.isTrue(ASRouterPreferences.getUserPreference("snippets"));
});
});
describe("#getAllUserPreferences", () => {
it("should return all user preferences", () => {
boolPrefStub.withArgs(SNIPPETS_USER_PREF).returns(true);
boolPrefStub.withArgs(CFR_USER_PREF_ADDONS).returns(false);
boolPrefStub.withArgs(CFR_USER_PREF_FEATURES).returns(true);
const result = ASRouterPreferences.getAllUserPreferences();
assert.deepEqual(result, {
snippets: true,
cfrAddons: false,
cfrFeatures: true,
});
});
});
describe("#enableOrDisableProvider", () => {
it("should enable an existing provider if second param is true", () => {
const setStub = sandbox.stub(global.Services.prefs, "setStringPref");
setPrefForProvider("foo", { id: "foo", enabled: false });
assert.isFalse(ASRouterPreferences.providers[0].enabled);
ASRouterPreferences.enableOrDisableProvider("foo", true);
assert.calledWith(
setStub,
getPrefNameForProvider("foo"),
JSON.stringify({ id: "foo", enabled: true })
);
});
it("should disable an existing provider if second param is false", () => {
const setStub = sandbox.stub(global.Services.prefs, "setStringPref");
setPrefForProvider("foo", { id: "foo", enabled: true });
assert.isTrue(ASRouterPreferences.providers[0].enabled);
ASRouterPreferences.enableOrDisableProvider("foo", false);
assert.calledWith(
setStub,
getPrefNameForProvider("foo"),
JSON.stringify({ id: "foo", enabled: false })
);
});
it("should not throw if the id does not exist", () => {
assert.doesNotThrow(() => {
ASRouterPreferences.enableOrDisableProvider("does_not_exist", true);
});
});
it("should not throw if pref is not parseable", () => {
stringPrefStub
.withArgs(getPrefNameForProvider("foo"))
.returns("not valid");
assert.doesNotThrow(() => {
ASRouterPreferences.enableOrDisableProvider("foo", true);
});
});
});
describe("#setUserPreference", () => {
it("should do nothing if the pref doesn't exist", () => {
ASRouterPreferences.setUserPreference("foo", true);
assert.notCalled(boolPrefStub);
});
it("should set the given pref", () => {
const setStub = sandbox.stub(global.Services.prefs, "setBoolPref");
ASRouterPreferences.setUserPreference("snippets", true);
assert.calledWith(setStub, SNIPPETS_USER_PREF, true);
});
});
describe("#resetProviderPref", () => {
it("should reset the pref and user prefs", () => {
const resetStub = sandbox.stub(global.Services.prefs, "clearUserPref");
ASRouterPreferences.resetProviderPref();
FAKE_PROVIDERS.forEach(provider => {
assert.calledWith(resetStub, getPrefNameForProvider(provider.id));
});
assert.calledWith(resetStub, SNIPPETS_USER_PREF);
assert.calledWith(resetStub, CFR_USER_PREF_ADDONS);
assert.calledWith(resetStub, CFR_USER_PREF_FEATURES);
});
});
describe("observer, listeners", () => {
it("should invalidate .providers when the pref is changed", () => {
const testProvider = { id: "newstuff" };
const newProviders = [...FAKE_PROVIDERS, testProvider];
ASRouterPreferences.init();
assert.deepEqual(ASRouterPreferences.providers, FAKE_PROVIDERS);
stringPrefStub
.withArgs(getPrefNameForProvider(testProvider.id))
.returns(JSON.stringify(testProvider));
global.Services.prefs.getChildList
.withArgs(PROVIDER_PREF_BRANCH)
.returns(
newProviders.map(provider => getPrefNameForProvider(provider.id))
);
ASRouterPreferences.observe(
null,
null,
getPrefNameForProvider(testProvider.id)
);
// Cache should be invalidated so we access the new value of the pref now
assert.deepEqual(ASRouterPreferences.providers, newProviders);
});
it("should invalidate .devtoolsEnabled and .providers when the pref is changed", () => {
ASRouterPreferences.init();
assert.isFalse(ASRouterPreferences.devtoolsEnabled);
boolPrefStub.withArgs(DEVTOOLS_PREF).returns(true);
global.Services.prefs.getChildList
.withArgs(PROVIDER_PREF_BRANCH)
.returns([]);
ASRouterPreferences.observe(null, null, DEVTOOLS_PREF);
// Cache should be invalidated so we access the new value of the pref now
// Note that providers needs to be invalidated because devtools adds test content to it.
assert.isTrue(ASRouterPreferences.devtoolsEnabled);
assert.deepEqual(ASRouterPreferences.providers, TEST_PROVIDERS);
});
it("should call listeners added with .addListener", () => {
const callback1 = sinon.stub();
const callback2 = sinon.stub();
ASRouterPreferences.init();
ASRouterPreferences.addListener(callback1);
ASRouterPreferences.addListener(callback2);
ASRouterPreferences.observe(null, null, getPrefNameForProvider("foo"));
assert.calledWith(callback1, getPrefNameForProvider("foo"));
ASRouterPreferences.observe(null, null, DEVTOOLS_PREF);
assert.calledWith(callback2, DEVTOOLS_PREF);
});
it("should not call listeners after they are removed with .removeListeners", () => {
const callback = sinon.stub();
ASRouterPreferences.init();
ASRouterPreferences.addListener(callback);
ASRouterPreferences.observe(null, null, getPrefNameForProvider("foo"));
assert.calledWith(callback, getPrefNameForProvider("foo"));
callback.reset();
ASRouterPreferences.removeListener(callback);
ASRouterPreferences.observe(null, null, DEVTOOLS_PREF);
assert.notCalled(callback);
});
});
describe("#_transformPersonalizedCfrScores", () => {
it("should report JSON.parse errors", () => {
sandbox.stub(global.Cu, "reportError");
ASRouterPreferences._transformPersonalizedCfrScores("");
assert.calledOnce(global.Cu.reportError);
});
it("should return an object parsed from a string", () => {
const scores = { FOO: 3000, BAR: 4000 };
assert.deepEqual(
ASRouterPreferences._transformPersonalizedCfrScores(
JSON.stringify(scores)
),
scores
);
});
});
describe("#getTrailheadConfigFromPref", () => {
it("should return trailHeadTriplet and trailHeadInterrupt", () => {
let result = getTrailheadConfigFromPref("foo-bar");
assert.propertyVal(result, "trailheadInterrupt", "foo");
assert.propertyVal(result, "trailheadTriplet", "bar");
});
it("should return default values when pref is empty", () => {
let result = getTrailheadConfigFromPref("");
assert.propertyVal(result, "trailheadInterrupt", "join");
assert.propertyVal(result, "trailheadTriplet", "supercharge");
});
it("should return default trailHeadTriplet and trailHeadInterrupt when no hyphen", () => {
let result = getTrailheadConfigFromPref("control");
assert.propertyVal(result, "trailheadInterrupt", "control");
assert.propertyVal(result, "trailheadTriplet", "supercharge");
});
it("should return trailHeadTriplet and default trailHeadInterrupt when prefixed with hyphen", () => {
let result = getTrailheadConfigFromPref("-control");
assert.propertyVal(result, "trailheadInterrupt", "join");
assert.propertyVal(result, "trailheadTriplet", "control");
});
});
});
================================================
FILE: test/unit/asrouter/ASRouterTargeting.test.js
================================================
import {
ASRouterTargeting,
CachedTargetingGetter,
getSortedMessages,
} from "lib/ASRouterTargeting.jsm";
import { OnboardingMessageProvider } from "lib/OnboardingMessageProvider.jsm";
import { ASRouterPreferences } from "lib/ASRouterPreferences.jsm";
import { GlobalOverrider } from "test/unit/utils";
// Note that tests for the ASRouterTargeting environment can be found in
// test/functional/mochitest/browser_asrouter_targeting.js
describe("#CachedTargetingGetter", () => {
const sixHours = 6 * 60 * 60 * 1000;
let sandbox;
let clock;
let frecentStub;
let topsitesCache;
beforeEach(() => {
sandbox = sinon.createSandbox();
clock = sinon.useFakeTimers();
frecentStub = sandbox.stub(
global.NewTabUtils.activityStreamProvider,
"getTopFrecentSites"
);
sandbox.stub(global.Cu, "reportError");
topsitesCache = new CachedTargetingGetter("getTopFrecentSites");
});
afterEach(() => {
sandbox.restore();
clock.restore();
});
it("should only make a request every 6 hours", async () => {
frecentStub.resolves();
clock.tick(sixHours);
await topsitesCache.get();
await topsitesCache.get();
assert.calledOnce(
global.NewTabUtils.activityStreamProvider.getTopFrecentSites
);
clock.tick(sixHours);
await topsitesCache.get();
assert.calledTwice(
global.NewTabUtils.activityStreamProvider.getTopFrecentSites
);
});
it("throws when failing getter", async () => {
frecentStub.rejects(new Error("fake error"));
clock.tick(sixHours);
// assert.throws expect a function as the first parameter, try/catch is a
// workaround
let rejected = false;
try {
await topsitesCache.get();
} catch (e) {
rejected = true;
}
assert(rejected);
});
it("should check targeted message before message without targeting", async () => {
const messages = await OnboardingMessageProvider.getUntranslatedMessages();
const stub = sandbox
.stub(ASRouterTargeting, "checkMessageTargeting")
.resolves();
const context = {
attributionData: {
campaign: "non-fx-button",
source: "addons.mozilla.org",
},
};
await ASRouterTargeting.findMatchingMessage({
messages,
trigger: { id: "firstRun" },
context,
});
const messageCount = messages.filter(
message => message.trigger && message.trigger.id === "firstRun"
).length;
assert.equal(stub.callCount, messageCount);
const calls = stub.getCalls().map(call => call.args[0]);
const lastCall = calls[calls.length - 1];
assert.equal(lastCall.id, "TRAILHEAD_1");
});
describe("sortMessagesByPriority", () => {
it("should sort messages in descending priority order", async () => {
const [
m1,
m2,
m3,
] = await OnboardingMessageProvider.getUntranslatedMessages();
const checkMessageTargetingStub = sandbox
.stub(ASRouterTargeting, "checkMessageTargeting")
.resolves(false);
sandbox.stub(ASRouterTargeting, "isTriggerMatch").resolves(true);
await ASRouterTargeting.findMatchingMessage({
messages: [
{ ...m1, priority: 0 },
{ ...m2, priority: 1 },
{ ...m3, priority: 2 },
],
trigger: "testing",
});
assert.equal(checkMessageTargetingStub.callCount, 3);
const [arg_m1] = checkMessageTargetingStub.firstCall.args;
assert.equal(arg_m1.id, m3.id);
const [arg_m2] = checkMessageTargetingStub.secondCall.args;
assert.equal(arg_m2.id, m2.id);
const [arg_m3] = checkMessageTargetingStub.thirdCall.args;
assert.equal(arg_m3.id, m1.id);
});
it("should sort messages with no priority last", async () => {
const [
m1,
m2,
m3,
] = await OnboardingMessageProvider.getUntranslatedMessages();
const checkMessageTargetingStub = sandbox
.stub(ASRouterTargeting, "checkMessageTargeting")
.resolves(false);
sandbox.stub(ASRouterTargeting, "isTriggerMatch").resolves(true);
await ASRouterTargeting.findMatchingMessage({
messages: [
{ ...m1, priority: 0 },
{ ...m2, priority: undefined },
{ ...m3, priority: 2 },
],
trigger: "testing",
});
assert.equal(checkMessageTargetingStub.callCount, 3);
const [arg_m1] = checkMessageTargetingStub.firstCall.args;
assert.equal(arg_m1.id, m3.id);
const [arg_m2] = checkMessageTargetingStub.secondCall.args;
assert.equal(arg_m2.id, m1.id);
const [arg_m3] = checkMessageTargetingStub.thirdCall.args;
assert.equal(arg_m3.id, m2.id);
});
it("should keep the order of messages with same priority unchanged", async () => {
const [
m1,
m2,
m3,
] = await OnboardingMessageProvider.getUntranslatedMessages();
const checkMessageTargetingStub = sandbox
.stub(ASRouterTargeting, "checkMessageTargeting")
.resolves(false);
sandbox.stub(ASRouterTargeting, "isTriggerMatch").resolves(true);
await ASRouterTargeting.findMatchingMessage({
messages: [
{ ...m1, priority: 2, targeting: undefined, rank: 1 },
{ ...m2, priority: undefined, targeting: undefined, rank: 1 },
{ ...m3, priority: 2, targeting: undefined, rank: 1 },
],
trigger: "testing",
});
assert.equal(checkMessageTargetingStub.callCount, 3);
const [arg_m1] = checkMessageTargetingStub.firstCall.args;
assert.equal(arg_m1.id, m1.id);
const [arg_m2] = checkMessageTargetingStub.secondCall.args;
assert.equal(arg_m2.id, m3.id);
const [arg_m3] = checkMessageTargetingStub.thirdCall.args;
assert.equal(arg_m3.id, m2.id);
});
});
describe("combineContexts", () => {
it("should combine the properties of the two objects", () => {
const joined = ASRouterTargeting.combineContexts(
{
get foo() {
return "foo";
},
},
{
get bar() {
return "bar";
},
}
);
assert.propertyVal(joined, "foo", "foo");
assert.propertyVal(joined, "bar", "bar");
});
it("should warn when properties overlap", () => {
ASRouterTargeting.combineContexts(
{
get foo() {
return "foo";
},
},
{
get foo() {
return "bar";
},
}
);
assert.calledOnce(global.Cu.reportError);
});
});
});
describe("ASRouterTargeting", () => {
let evalStub;
let sandbox;
let clock;
beforeEach(() => {
sandbox = sinon.createSandbox();
evalStub = sandbox.stub(global.FilterExpressions, "eval");
sandbox.replace(ASRouterTargeting, "Environment", {});
clock = sinon.useFakeTimers();
});
afterEach(() => {
clock.restore();
sandbox.restore();
});
it("should cache evaluation result", async () => {
evalStub.resolves(true);
await ASRouterTargeting.checkMessageTargeting(
{ targeting: "jexl1" },
{},
sandbox.stub(),
true
);
await ASRouterTargeting.checkMessageTargeting(
{ targeting: "jexl2" },
{},
sandbox.stub(),
true
);
await ASRouterTargeting.checkMessageTargeting(
{ targeting: "jexl1" },
{},
sandbox.stub(),
true
);
assert.calledTwice(evalStub);
});
it("should not cache evaluation result", async () => {
evalStub.resolves(true);
await ASRouterTargeting.checkMessageTargeting(
{ targeting: "jexl" },
{},
sandbox.stub(),
false
);
await ASRouterTargeting.checkMessageTargeting(
{ targeting: "jexl" },
{},
sandbox.stub(),
false
);
await ASRouterTargeting.checkMessageTargeting(
{ targeting: "jexl" },
{},
sandbox.stub(),
false
);
assert.calledThrice(evalStub);
});
it("should expire cache entries", async () => {
evalStub.resolves(true);
await ASRouterTargeting.checkMessageTargeting(
{ targeting: "jexl" },
{},
sandbox.stub(),
true
);
await ASRouterTargeting.checkMessageTargeting(
{ targeting: "jexl" },
{},
sandbox.stub(),
true
);
clock.tick(60 * 1000 + 1);
await ASRouterTargeting.checkMessageTargeting(
{ targeting: "jexl" },
{},
sandbox.stub(),
true
);
assert.calledTwice(evalStub);
});
describe("#findMatchingMessage", () => {
let matchStub;
let messages = [
{ id: "FOO", targeting: "match" },
{ id: "BAR", targeting: "match" },
{ id: "BAZ" },
];
beforeEach(() => {
matchStub = sandbox
.stub(ASRouterTargeting, "_isMessageMatch")
.callsFake(message => message.targeting === "match");
});
it("should return an array of matches if returnAll is true", async () => {
assert.deepEqual(
await ASRouterTargeting.findMatchingMessage({
messages,
returnAll: true,
}),
[{ id: "FOO", targeting: "match" }, { id: "BAR", targeting: "match" }]
);
});
it("should return an empty array if no matches were found and returnAll is true", async () => {
matchStub.returns(false);
assert.deepEqual(
await ASRouterTargeting.findMatchingMessage({
messages,
returnAll: true,
}),
[]
);
});
it("should return the first match if returnAll is false", async () => {
assert.deepEqual(
await ASRouterTargeting.findMatchingMessage({
messages,
}),
messages[0]
);
});
it("should return null if if no matches were found and returnAll is false", async () => {
matchStub.returns(false);
assert.deepEqual(
await ASRouterTargeting.findMatchingMessage({
messages,
}),
null
);
});
});
});
/**
* Messages should be sorted in the following order:
* 1. Rank
* 2. Priority
* 3. If the message has targeting
* 4. Order or randomization, depending on input
*/
describe("getSortedMessages", () => {
let globals = new GlobalOverrider();
let sandbox;
let thresholdStub;
beforeEach(() => {
globals.set({ ASRouterPreferences });
sandbox = sinon.createSandbox();
thresholdStub = sandbox.stub();
sandbox.replaceGetter(
ASRouterPreferences,
"personalizedCfrThreshold",
thresholdStub
);
});
afterEach(() => {
sandbox.restore();
globals.restore();
});
/**
* assertSortsCorrectly - Tests to see if an array, when sorted with getSortedMessages,
* returns the items in the expected order.
*
* @param {Message[]} expectedOrderArray - The array of messages in its expected order
* @param {{}} options - The options param for getSortedMessages
* @returns
*/
function assertSortsCorrectly(expectedOrderArray, options) {
const input = [...expectedOrderArray].reverse();
const result = getSortedMessages(input, options);
const indexes = result.map(message => expectedOrderArray.indexOf(message));
return assert.equal(
indexes.join(","),
[...expectedOrderArray.keys()].join(","),
"Messsages are out of order"
);
}
it("should sort messages by priority, then by targeting", () => {
assertSortsCorrectly([
{ priority: 100, targeting: "isFoo" },
{ priority: 100 },
{ priority: 99 },
{ priority: 1, targeting: "isFoo" },
{ priority: 1 },
{},
]);
});
it("should sort messages by score first if defined", () => {
assertSortsCorrectly([
{ score: 7001 },
{ score: 7000, priority: 1 },
{ score: 7000, targeting: "isFoo" },
{ score: 7000 },
{ score: 6000, priority: 1000 },
{ priority: 99999 },
{},
]);
});
it("should sort messages by priority, then targeting, then order if ordered param is true", () => {
assertSortsCorrectly(
[
{ priority: 100, order: 4 },
{ priority: 100, order: 5 },
{ priority: 1, order: 3, targeting: "isFoo" },
{ priority: 1, order: 0 },
{ priority: 1, order: 1 },
{ priority: 1, order: 2 },
{ order: 0 },
],
{ ordered: true }
);
});
it("should filter messages below the personalizedCfrThreshold", () => {
thresholdStub.returns(5000);
const result = getSortedMessages([{ score: 5000 }, { score: 4999 }, {}]);
assert.deepEqual(result, [{ score: 5000 }, {}]);
});
it("should not filter out messages without a score", () => {
thresholdStub.returns(5000);
const result = getSortedMessages([{ score: 4999 }, { id: "FOO" }]);
assert.deepEqual(result, [{ id: "FOO" }]);
});
it("should not apply filter if the threshold is an invalid value", () => {
let result;
thresholdStub.returns(undefined);
result = getSortedMessages([{ score: 5000 }, { score: 4999 }]);
assert.deepEqual(result, [{ score: 5000 }, { score: 4999 }]);
thresholdStub.returns("foo");
result = getSortedMessages([{ score: 5000 }, { score: 4999 }]);
assert.deepEqual(result, [{ score: 5000 }, { score: 4999 }]);
thresholdStub.returns(5000);
result = getSortedMessages([{ score: 5000 }, { score: 4999 }]);
assert.deepEqual(result, [{ score: 5000 }]);
});
});
================================================
FILE: test/unit/asrouter/ASRouterTriggerListeners.test.js
================================================
import { ASRouterTriggerListeners } from "lib/ASRouterTriggerListeners.jsm";
import { GlobalOverrider } from "test/unit/utils";
describe("ASRouterTriggerListeners", () => {
let sandbox;
let globals;
let existingWindow;
let isWindowPrivateStub;
const triggerHandler = () => {};
const openURLListener = ASRouterTriggerListeners.get("openURL");
const frequentVisitsListener = ASRouterTriggerListeners.get("frequentVisits");
const bookmarkedURLListener = ASRouterTriggerListeners.get(
"openBookmarkedURL"
);
const openArticleURLListener = ASRouterTriggerListeners.get("openArticleURL");
const hosts = ["www.mozilla.com", "www.mozilla.org"];
beforeEach(async () => {
sandbox = sinon.createSandbox();
globals = new GlobalOverrider();
existingWindow = {
gBrowser: {
addTabsProgressListener: sandbox.stub(),
removeTabsProgressListener: sandbox.stub(),
currentURI: { host: "" },
},
addEventListener: sinon.stub(),
removeEventListener: sinon.stub(),
};
sandbox.spy(openURLListener, "init");
sandbox.spy(openURLListener, "uninit");
isWindowPrivateStub = sandbox.stub();
// Assume no window is private so that we execute the action
isWindowPrivateStub.returns(false);
globals.set("PrivateBrowsingUtils", {
isWindowPrivate: isWindowPrivateStub,
});
const ewUninit = new Map();
globals.set("EveryWindow", {
registerCallback: (id, init, uninit) => {
init(existingWindow);
ewUninit.set(id, uninit);
},
unregisterCallback: id => {
ewUninit.get(id)(existingWindow);
},
});
});
afterEach(() => {
sandbox.restore();
globals.restore();
});
describe("openBookmarkedURL", () => {
let observerStub;
describe("#init", () => {
beforeEach(() => {
observerStub = sandbox.stub(global.Services.obs, "addObserver");
sandbox
.stub(global.Services.wm, "getMostRecentBrowserWindow")
.returns({ gBrowser: { selectedBrowser: {} } });
});
afterEach(() => {
bookmarkedURLListener.uninit();
});
it("should set hosts to the recentBookmarks", async () => {
await bookmarkedURLListener.init(sandbox.stub());
assert.calledOnce(observerStub);
assert.calledWithExactly(
observerStub,
bookmarkedURLListener,
"bookmark-icon-updated"
);
});
it("should provide id to triggerHandler", async () => {
const newTriggerHandler = sinon.stub();
const subject = {};
await bookmarkedURLListener.init(newTriggerHandler);
bookmarkedURLListener.observe(
subject,
"bookmark-icon-updated",
"starred"
);
assert.calledOnce(newTriggerHandler);
assert.calledWithExactly(newTriggerHandler, subject, {
id: bookmarkedURLListener.id,
});
});
});
});
describe("openArticleURL", () => {
describe("#init", () => {
beforeEach(() => {
globals.set(
"MatchPatternSet",
sandbox.stub().callsFake(patterns => ({
patterns,
matches: url => patterns.has(url),
}))
);
sandbox.stub(global.Services.mm, "addMessageListener");
sandbox.stub(global.Services.mm, "removeMessageListener");
});
afterEach(() => {
openArticleURLListener.uninit();
});
it("setup an event listener on init", () => {
openArticleURLListener.init(sandbox.stub(), hosts, hosts);
assert.calledOnce(global.Services.mm.addMessageListener);
assert.calledWithExactly(
global.Services.mm.addMessageListener,
openArticleURLListener.readerModeEvent,
sinon.match.object
);
});
it("should call triggerHandler correctly for matches [host match]", () => {
const stub = sandbox.stub();
const target = { currentURI: { host: hosts[0], spec: hosts[1] } };
openArticleURLListener.init(stub, hosts, hosts);
const [
,
{ receiveMessage },
] = global.Services.mm.addMessageListener.firstCall.args;
receiveMessage({ data: { isArticle: true }, target });
assert.calledOnce(stub);
assert.calledWithExactly(stub, target, {
id: openArticleURLListener.id,
param: { host: hosts[0], url: hosts[1] },
});
});
it("should call triggerHandler correctly for matches [pattern match]", () => {
const stub = sandbox.stub();
const target = { currentURI: { host: null, spec: hosts[1] } };
openArticleURLListener.init(stub, hosts, hosts);
const [
,
{ receiveMessage },
] = global.Services.mm.addMessageListener.firstCall.args;
receiveMessage({ data: { isArticle: true }, target });
assert.calledOnce(stub);
assert.calledWithExactly(stub, target, {
id: openArticleURLListener.id,
param: { host: null, url: hosts[1] },
});
});
it("should remove the message listener", () => {
openArticleURLListener.init(sandbox.stub(), hosts, hosts);
openArticleURLListener.uninit();
assert.calledOnce(global.Services.mm.removeMessageListener);
});
});
});
describe("frequentVisits", () => {
let _triggerHandler;
beforeEach(() => {
_triggerHandler = sandbox.stub();
sandbox.useFakeTimers();
frequentVisitsListener.init(_triggerHandler, hosts);
});
afterEach(() => {
sandbox.clock.restore();
frequentVisitsListener.uninit();
});
it("should be initialized", () => {
assert.isTrue(frequentVisitsListener._initialized);
});
it("should listen for TabSelect events", () => {
assert.calledOnce(existingWindow.addEventListener);
assert.calledWith(
existingWindow.addEventListener,
"TabSelect",
frequentVisitsListener.onTabSwitch
);
});
it("should call _triggerHandler if the visit is valid (is recoreded)", () => {
frequentVisitsListener.triggerHandler({}, "www.mozilla.com");
assert.calledOnce(_triggerHandler);
});
it("should call _triggerHandler only once", () => {
frequentVisitsListener.triggerHandler({}, "www.mozilla.com");
frequentVisitsListener.triggerHandler({}, "www.mozilla.com");
assert.calledOnce(_triggerHandler);
});
it("should call _triggerHandler again after 15 minutes", () => {
frequentVisitsListener.triggerHandler({}, "www.mozilla.com");
sandbox.clock.tick(15 * 60 * 1000 + 1);
frequentVisitsListener.triggerHandler({}, "www.mozilla.com");
assert.calledTwice(_triggerHandler);
});
it("should call triggerHandler on valid hosts", () => {
const stub = sandbox.stub(frequentVisitsListener, "triggerHandler");
existingWindow.gBrowser.currentURI.host = hosts[0]; // eslint-disable-line prefer-destructuring
frequentVisitsListener.onTabSwitch({
target: { ownerGlobal: existingWindow },
});
assert.calledOnce(stub);
});
it("should not call triggerHandler on invalid hosts", () => {
const stub = sandbox.stub(frequentVisitsListener, "triggerHandler");
existingWindow.gBrowser.currentURI.host = "foo.com";
frequentVisitsListener.onTabSwitch({
target: { ownerGlobal: existingWindow },
});
assert.notCalled(stub);
});
describe("MatchPattern", () => {
beforeEach(() => {
globals.set(
"MatchPatternSet",
sandbox.stub().callsFake(patterns => ({ patterns: patterns || [] }))
);
});
afterEach(() => {
frequentVisitsListener.uninit();
});
it("should create a matchPatternSet", () => {
frequentVisitsListener.init(_triggerHandler, hosts, ["pattern"]);
assert.calledOnce(window.MatchPatternSet);
assert.calledWithExactly(window.MatchPatternSet, new Set(["pattern"]), {
ignorePath: true,
});
});
it("should allow to add multiple patterns and dedupe", () => {
frequentVisitsListener.init(_triggerHandler, hosts, ["pattern"]);
frequentVisitsListener.init(_triggerHandler, hosts, ["foo"]);
assert.calledTwice(window.MatchPatternSet);
assert.calledWithExactly(
window.MatchPatternSet,
new Set(["pattern", "foo"]),
{ ignorePath: true }
);
});
it("should handle bad arguments to MatchPatternSet", () => {
const badArgs = ["www.example.com"];
window.MatchPatternSet.withArgs(new Set(badArgs)).throws();
frequentVisitsListener.init(_triggerHandler, hosts, badArgs);
// Fails with an empty MatchPatternSet
assert.property(frequentVisitsListener._matchPatternSet, "patterns");
// Second try is succesful
frequentVisitsListener.init(_triggerHandler, hosts, ["foo"]);
assert.property(frequentVisitsListener._matchPatternSet, "patterns");
assert.isTrue(
frequentVisitsListener._matchPatternSet.patterns.has("foo")
);
});
});
});
describe("openURL listener", () => {
it("should exist and initially be uninitialised", () => {
assert.ok(openURLListener);
assert.notOk(openURLListener._initialized);
});
describe("#init", () => {
beforeEach(() => {
openURLListener.init(triggerHandler, hosts);
});
afterEach(() => {
openURLListener.uninit();
});
it("should set ._initialized to true and save the triggerHandler and hosts", () => {
assert.ok(openURLListener._initialized);
assert.deepEqual(openURLListener._hosts, new Set(hosts));
assert.equal(openURLListener._triggerHandler, triggerHandler);
});
it("should add tab progress listeners to all existing browser windows", () => {
assert.calledOnce(existingWindow.gBrowser.addTabsProgressListener);
assert.calledWithExactly(
existingWindow.gBrowser.addTabsProgressListener,
openURLListener
);
});
it("if already initialised, should only update the trigger handler and add the new hosts", () => {
const newHosts = ["www.example.com"];
const newTriggerHandler = () => {};
existingWindow.gBrowser.addTabsProgressListener.reset();
openURLListener.init(newTriggerHandler, newHosts);
assert.ok(openURLListener._initialized);
assert.deepEqual(
openURLListener._hosts,
new Set([...hosts, ...newHosts])
);
assert.equal(openURLListener._triggerHandler, newTriggerHandler);
assert.notCalled(existingWindow.gBrowser.addTabsProgressListener);
});
});
describe("#uninit", () => {
beforeEach(async () => {
openURLListener.init(triggerHandler, hosts);
openURLListener.uninit();
});
it("should set ._initialized to false and clear the triggerHandler and hosts", () => {
assert.notOk(openURLListener._initialized);
assert.equal(openURLListener._hosts, null);
assert.equal(openURLListener._triggerHandler, null);
});
it("should remove tab progress listeners from all existing browser windows", () => {
assert.calledOnce(existingWindow.gBrowser.removeTabsProgressListener);
assert.calledWithExactly(
existingWindow.gBrowser.removeTabsProgressListener,
openURLListener
);
});
it("should do nothing if already uninitialised", () => {
existingWindow.gBrowser.removeTabsProgressListener.reset();
openURLListener.uninit();
assert.notOk(openURLListener._initialized);
assert.notCalled(existingWindow.gBrowser.removeTabsProgressListener);
});
});
describe("#onLocationChange", () => {
afterEach(() => {
openURLListener.uninit();
frequentVisitsListener.uninit();
});
it("should call the ._triggerHandler with the right arguments", () => {
const newTriggerHandler = sinon.stub();
openURLListener.init(newTriggerHandler, hosts);
const browser = {};
const webProgress = { isTopLevel: true };
const location = "www.mozilla.org";
openURLListener.onLocationChange(browser, webProgress, undefined, {
host: location,
spec: location,
});
assert.calledOnce(newTriggerHandler);
assert.calledWithExactly(newTriggerHandler, browser, {
id: "openURL",
param: { host: "www.mozilla.org", url: "www.mozilla.org" },
});
});
it("should call triggerHandler for a redirect (openURL + frequentVisits)", () => {
for (let trigger of [openURLListener, frequentVisitsListener]) {
const newTriggerHandler = sinon.stub();
trigger.init(newTriggerHandler, hosts);
const browser = {};
const webProgress = { isTopLevel: true };
const aLocationURI = {
host: "subdomain.mozilla.org",
spec: "subdomain.mozilla.org",
};
const aRequest = {
QueryInterface: sandbox.stub().returns({
originalURI: { spec: "www.mozilla.org", host: "www.mozilla.org" },
}),
};
trigger.onLocationChange(
browser,
webProgress,
aRequest,
aLocationURI
);
assert.calledOnce(aRequest.QueryInterface);
assert.calledOnce(newTriggerHandler);
}
});
it("should call triggerHandler with the right arguments (redirect)", () => {
const newTriggerHandler = sinon.stub();
openURLListener.init(newTriggerHandler, hosts);
const browser = {};
const webProgress = { isTopLevel: true };
const aLocationURI = {
host: "subdomain.mozilla.org",
spec: "subdomain.mozilla.org",
};
const aRequest = {
QueryInterface: sandbox.stub().returns({
originalURI: { spec: "www.mozilla.org", host: "www.mozilla.org" },
}),
};
openURLListener.onLocationChange(
browser,
webProgress,
aRequest,
aLocationURI
);
assert.calledWithExactly(newTriggerHandler, browser, {
id: "openURL",
param: { host: "www.mozilla.org", url: "www.mozilla.org" },
});
});
it("should call triggerHandler for a redirect (openURL + frequentVisits)", () => {
for (let trigger of [openURLListener, frequentVisitsListener]) {
const newTriggerHandler = sinon.stub();
trigger.init(newTriggerHandler, hosts);
const browser = {};
const webProgress = { isTopLevel: true };
const aLocationURI = {
host: "subdomain.mozilla.org",
spec: "subdomain.mozilla.org",
};
const aRequest = {
QueryInterface: sandbox.stub().returns({
originalURI: { spec: "www.mozilla.org", host: "www.mozilla.org" },
}),
};
trigger.onLocationChange(
browser,
webProgress,
aRequest,
aLocationURI
);
assert.calledOnce(aRequest.QueryInterface);
assert.calledOnce(newTriggerHandler);
}
});
it("should call triggerHandler with the right arguments (redirect)", () => {
const newTriggerHandler = sinon.stub();
openURLListener.init(newTriggerHandler, hosts);
const browser = {};
const webProgress = { isTopLevel: true };
const aLocationURI = {
host: "subdomain.mozilla.org",
spec: "subdomain.mozilla.org",
};
const aRequest = {
QueryInterface: sandbox.stub().returns({
originalURI: { spec: "www.mozilla.org", host: "www.mozilla.org" },
}),
};
openURLListener.onLocationChange(
browser,
webProgress,
aRequest,
aLocationURI
);
assert.calledWithExactly(newTriggerHandler, browser, {
id: "openURL",
param: { host: "www.mozilla.org", url: "www.mozilla.org" },
});
});
it("should fail for subdomains (not redirect)", () => {
const newTriggerHandler = sinon.stub();
openURLListener.init(newTriggerHandler, hosts);
const browser = {};
const webProgress = { isTopLevel: true };
const aLocationURI = {
host: "subdomain.mozilla.org",
spec: "subdomain.mozilla.org",
};
const aRequest = {
QueryInterface: sandbox.stub().returns({
originalURI: {
spec: "subdomain.mozilla.org",
host: "subdomain.mozilla.org",
},
}),
};
openURLListener.onLocationChange(
browser,
webProgress,
aRequest,
aLocationURI
);
assert.calledOnce(aRequest.QueryInterface);
assert.notCalled(newTriggerHandler);
});
});
});
});
================================================
FILE: test/unit/asrouter/CFRMessageProvider.test.js
================================================
import { CFRMessageProvider } from "lib/CFRMessageProvider.jsm";
const messages = CFRMessageProvider.getMessages();
const REGULAR_IDS = [
"FACEBOOK_CONTAINER",
"GOOGLE_TRANSLATE",
"YOUTUBE_ENHANCE",
// These are excluded for now.
// "WIKIPEDIA_CONTEXT_MENU_SEARCH",
// "REDDIT_ENHANCEMENT",
];
describe("CFRMessageProvider", () => {
it("should have a total of 9 messages", () => {
assert.lengthOf(messages, 9);
});
it("should have one message each for the three regular addons", () => {
for (const id of REGULAR_IDS) {
const cohort3 = messages.find(msg => msg.id === `${id}_3`);
assert.ok(cohort3, `contains three day cohort for ${id}`);
assert.deepEqual(
cohort3.frequency,
{ lifetime: 3 },
"three day cohort has the right frequency cap"
);
assert.notInclude(cohort3.targeting, `providerCohorts.cfr`);
}
});
it("should always have xpinstallEnabled as targeting if it is an addon", () => {
for (const message of messages) {
// Ensure that the CFR messages that are recommending an addon have this targeting.
// In the future when we can do targeting based on category, this test will change.
// See bug 1494778 and 1497653
if (!message.content.layout) {
assert.include(message.targeting, `(xpinstallEnabled == true)`);
}
}
});
it("should restrict all messages to `en` locale for now (PIN TAB is handled separately)", () => {
for (const message of messages.filter(m => !m.content.layout)) {
assert.include(message.targeting, `localeLanguageCode == "en"`);
}
});
it("should restrict locale for PIN_TAB message", () => {
const pinTabMessage = messages.find(m => m.id === "PIN_TAB");
// 6 en-* locales, fr and de
assert.lengthOf(pinTabMessage.targeting.match(/en-|fr|de/g), 8);
});
it("should contain `www.` version of the hosts", () => {
const pinTabMessage = messages.find(m => m.id === "PIN_TAB");
assert.isTrue(
!!pinTabMessage.trigger.params.filter(host => host.startsWith("www."))
.length
);
});
});
================================================
FILE: test/unit/asrouter/CFRPageActions.test.js
================================================
import { CFRPageActions, PageAction } from "lib/CFRPageActions.jsm";
import { FAKE_RECOMMENDATION } from "./constants";
import { GlobalOverrider } from "test/unit/utils";
describe("CFRPageActions", () => {
let sandbox;
let clock;
let fakeRecommendation;
let fakeHost;
let fakeBrowser;
let dispatchStub;
let globals;
let containerElem;
let elements;
let announceStub;
let remoteL10n;
const elementIDs = [
"urlbar",
"urlbar-input",
"contextual-feature-recommendation",
"cfr-button",
"cfr-label",
"contextual-feature-recommendation-notification",
"cfr-notification-header-label",
"cfr-notification-header-link",
"cfr-notification-header-image",
"cfr-notification-author",
"cfr-notification-footer",
"cfr-notification-footer-text",
"cfr-notification-footer-filled-stars",
"cfr-notification-footer-empty-stars",
"cfr-notification-footer-users",
"cfr-notification-footer-spacer",
"cfr-notification-footer-learn-more-link",
"cfr-notification-footer-pintab-animation-container",
"cfr-notification-footer-animation-button",
"cfr-notification-footer-animation-label",
];
const elementClassNames = ["popup-notification-body-container"];
beforeEach(() => {
sandbox = sinon.createSandbox();
clock = sandbox.useFakeTimers();
announceStub = sandbox.stub();
const A11yUtils = { announce: announceStub };
fakeRecommendation = { ...FAKE_RECOMMENDATION };
fakeHost = "mozilla.org";
fakeBrowser = {
documentURI: {
scheme: "https",
host: fakeHost,
},
ownerGlobal: window,
};
dispatchStub = sandbox.stub();
remoteL10n = {
l10n: {},
reloadL10n: sandbox.stub(),
};
globals = new GlobalOverrider();
globals.set({
RemoteL10n: remoteL10n,
promiseDocumentFlushed: sandbox
.stub()
.callsFake(fn => Promise.resolve(fn())),
PopupNotifications: {
show: sandbox.stub(),
remove: sandbox.stub(),
},
PrivateBrowsingUtils: { isWindowPrivate: sandbox.stub().returns(false) },
gBrowser: { selectedBrowser: fakeBrowser },
A11yUtils,
});
document.createXULElement = document.createElement;
elements = {};
const [body] = document.getElementsByTagName("body");
containerElem = document.createElement("div");
body.appendChild(containerElem);
for (const id of elementIDs) {
const elem = document.createElement("div");
elem.setAttribute("id", id);
containerElem.appendChild(elem);
elements[id] = elem;
}
for (const className of elementClassNames) {
const elem = document.createElement("div");
elem.setAttribute("class", className);
containerElem.appendChild(elem);
elements[className] = elem;
}
});
afterEach(() => {
CFRPageActions.clearRecommendations();
containerElem.remove();
sandbox.restore();
globals.restore();
});
describe("PageAction", () => {
let pageAction;
beforeEach(() => {
pageAction = new PageAction(window, dispatchStub);
});
describe("#addImpression", () => {
it("should call _sendTelemetry with the impression payload", () => {
const recommendation = {
id: "foo",
content: { bucket_id: "bar" },
};
sandbox.spy(pageAction, "_sendTelemetry");
pageAction.addImpression(recommendation);
assert.calledWith(pageAction._sendTelemetry, {
message_id: "foo",
bucket_id: "bar",
event: "IMPRESSION",
});
});
it("should include modelVersion if presented in the message", () => {
const recommendation = {
id: "foo",
content: { bucket_id: "bar" },
personalizedModelVersion: "model_version_1",
};
sandbox.spy(pageAction, "_sendTelemetry");
pageAction.addImpression(recommendation);
assert.calledWith(pageAction._sendTelemetry, {
message_id: "foo",
bucket_id: "bar",
event: "IMPRESSION",
event_context: {
modelVersion: "model_version_1",
},
});
});
});
describe("#showAddressBarNotifier", () => {
it("should un-hideAddressBarNotifier the element and set the right label value", async () => {
await pageAction.showAddressBarNotifier(fakeRecommendation);
assert.isFalse(pageAction.container.hidden);
assert.equal(
pageAction.label.value,
fakeRecommendation.content.notification_text
);
});
it("should wait for the document layout to flush", async () => {
sandbox.spy(pageAction.label, "getClientRects");
await pageAction.showAddressBarNotifier(fakeRecommendation);
assert.calledOnce(global.promiseDocumentFlushed);
assert.callOrder(
global.promiseDocumentFlushed,
pageAction.label.getClientRects
);
});
it("should set the CSS variable --cfr-label-width correctly", async () => {
await pageAction.showAddressBarNotifier(fakeRecommendation);
const expectedWidth = pageAction.label.getClientRects()[0].width;
assert.equal(
pageAction.urlbar.style.getPropertyValue("--cfr-label-width"),
`${expectedWidth}px`
);
});
it("should cause an expansion, and dispatch an impression iff `expand` is true", async () => {
sandbox.spy(pageAction, "_clearScheduledStateChanges");
sandbox.spy(pageAction, "_expand");
sandbox.spy(pageAction, "_dispatchImpression");
await pageAction.showAddressBarNotifier(fakeRecommendation);
assert.notCalled(pageAction._dispatchImpression);
clock.tick(1001);
assert.notEqual(
pageAction.urlbar.getAttribute("cfr-recommendation-state"),
"expanded"
);
await pageAction.showAddressBarNotifier(fakeRecommendation, true);
assert.calledOnce(pageAction._clearScheduledStateChanges);
clock.tick(1001);
assert.equal(
pageAction.urlbar.getAttribute("cfr-recommendation-state"),
"expanded"
);
assert.calledOnce(pageAction._dispatchImpression);
assert.calledWith(pageAction._dispatchImpression, fakeRecommendation);
});
it("should send telemetry if `expand` is true and the id and bucket_id are provided", async () => {
await pageAction.showAddressBarNotifier(fakeRecommendation, true);
assert.calledWith(dispatchStub, {
type: "DOORHANGER_TELEMETRY",
data: {
action: "cfr_user_event",
source: "CFR",
message_id: fakeRecommendation.id,
bucket_id: fakeRecommendation.content.bucket_id,
event: "IMPRESSION",
},
});
});
});
describe("#hideAddressBarNotifier", () => {
it("should hideAddressBarNotifier the container, cancel any state changes, and remove the state attribute", () => {
sandbox.spy(pageAction, "_clearScheduledStateChanges");
pageAction.hideAddressBarNotifier();
assert.isTrue(pageAction.container.hidden);
assert.calledOnce(pageAction._clearScheduledStateChanges);
assert.isNull(
pageAction.urlbar.getAttribute("cfr-recommendation-state")
);
});
it("should remove the `currentNotification`", () => {
const notification = {};
pageAction.currentNotification = notification;
pageAction.hideAddressBarNotifier();
assert.calledWith(global.PopupNotifications.remove, notification);
});
});
describe("#_expand", () => {
beforeEach(() => {
pageAction._clearScheduledStateChanges();
pageAction.urlbar.removeAttribute("cfr-recommendation-state");
});
it("without a delay, should clear other state changes and set the state to 'expanded'", () => {
sandbox.spy(pageAction, "_clearScheduledStateChanges");
pageAction._expand();
assert.calledOnce(pageAction._clearScheduledStateChanges);
assert.equal(
pageAction.urlbar.getAttribute("cfr-recommendation-state"),
"expanded"
);
});
it("with a delay, should set the expanded state after the correct amount of time", () => {
const delay = 1234;
pageAction._expand(delay);
// We expect that an expansion has been scheduled
assert.lengthOf(pageAction.stateTransitionTimeoutIDs, 1);
clock.tick(delay + 1);
assert.equal(
pageAction.urlbar.getAttribute("cfr-recommendation-state"),
"expanded"
);
});
});
describe("#_collapse", () => {
beforeEach(() => {
pageAction._clearScheduledStateChanges();
pageAction.urlbar.removeAttribute("cfr-recommendation-state");
});
it("without a delay, should clear other state changes and set the state to collapsed only if it's already expanded", () => {
sandbox.spy(pageAction, "_clearScheduledStateChanges");
pageAction._collapse();
assert.calledOnce(pageAction._clearScheduledStateChanges);
assert.isNull(
pageAction.urlbar.getAttribute("cfr-recommendation-state")
);
pageAction.urlbar.setAttribute("cfr-recommendation-state", "expanded");
pageAction._collapse();
assert.equal(
pageAction.urlbar.getAttribute("cfr-recommendation-state"),
"collapsed"
);
});
it("with a delay, should set the collapsed state after the correct amount of time", () => {
const delay = 1234;
pageAction._collapse(delay);
clock.tick(delay + 1);
// The state was _not_ "expanded" and so should not have been set to "collapsed"
assert.isNull(
pageAction.urlbar.getAttribute("cfr-recommendation-state")
);
pageAction._expand();
pageAction._collapse(delay);
// We expect that a collapse has been scheduled
assert.lengthOf(pageAction.stateTransitionTimeoutIDs, 1);
clock.tick(delay + 1);
// This time it was "expanded" so should now (after the delay) be "collapsed"
assert.equal(
pageAction.urlbar.getAttribute("cfr-recommendation-state"),
"collapsed"
);
});
});
describe("#_clearScheduledStateChanges", () => {
it("should call .clearTimeout on all stored timeoutIDs", () => {
pageAction.stateTransitionTimeoutIDs = [42, 73, 1997];
sandbox.spy(pageAction.window, "clearTimeout");
pageAction._clearScheduledStateChanges();
assert.calledThrice(pageAction.window.clearTimeout);
assert.calledWith(pageAction.window.clearTimeout, 42);
assert.calledWith(pageAction.window.clearTimeout, 73);
assert.calledWith(pageAction.window.clearTimeout, 1997);
});
});
describe("#_popupStateChange", () => {
it("should collapse and remove the notification on 'dismissed'", () => {
pageAction._expand();
const fakeNotification = {};
pageAction.currentNotification = fakeNotification;
pageAction._popupStateChange("dismissed");
assert.equal(
pageAction.urlbar.getAttribute("cfr-recommendation-state"),
"collapsed"
);
assert.calledOnce(global.PopupNotifications.remove);
assert.calledWith(global.PopupNotifications.remove, fakeNotification);
});
it("should collapse and remove the notification on 'removed'", () => {
pageAction._expand();
const fakeNotification = {};
pageAction.currentNotification = fakeNotification;
pageAction._popupStateChange("removed");
assert.equal(
pageAction.urlbar.getAttribute("cfr-recommendation-state"),
"collapsed"
);
assert.calledOnce(global.PopupNotifications.remove);
assert.calledWith(global.PopupNotifications.remove, fakeNotification);
});
it("should do nothing for other states", () => {
pageAction._popupStateChange("opened");
assert.notCalled(global.PopupNotifications.remove);
});
});
describe("#dispatchUserAction", () => {
it("should call ._dispatchToASRouter with the right action", () => {
const fakeAction = {};
pageAction.dispatchUserAction(fakeAction);
assert.calledOnce(dispatchStub);
assert.calledWith(
dispatchStub,
{ type: "USER_ACTION", data: fakeAction },
{ browser: fakeBrowser }
);
});
});
describe("#_dispatchImpression", () => {
it("should call ._dispatchToASRouter with the right action", () => {
pageAction._dispatchImpression("fake impression");
assert.calledWith(dispatchStub, {
type: "IMPRESSION",
data: "fake impression",
});
});
});
describe("#_sendTelemetry", () => {
it("should call ._dispatchToASRouter with the right action", () => {
const fakePing = { message_id: 42 };
pageAction._sendTelemetry(fakePing);
assert.calledWith(dispatchStub, {
type: "DOORHANGER_TELEMETRY",
data: { action: "cfr_user_event", source: "CFR", message_id: 42 },
});
});
});
describe("#_blockMessage", () => {
it("should call ._dispatchToASRouter with the right action", () => {
pageAction._blockMessage("fake id");
assert.calledOnce(dispatchStub);
assert.calledWith(dispatchStub, {
type: "BLOCK_MESSAGE_BY_ID",
data: { id: "fake id" },
});
});
});
describe("#getStrings", () => {
let formatMessagesStub;
const localeStrings = [
{
value: "你好世界",
attributes: [
{ name: "first_attr", value: 42 },
{ name: "second_attr", value: "some string" },
{ name: "third_attr", value: [1, 2, 3] },
],
},
];
beforeEach(() => {
formatMessagesStub = sandbox
.stub()
.withArgs({ id: "hello_world" })
.resolves(localeStrings);
global.RemoteL10n.l10n.formatMessages = formatMessagesStub;
});
it("should return the argument if a string_id is not defined", async () => {
assert.deepEqual(await pageAction.getStrings({}), {});
assert.equal(await pageAction.getStrings("some string"), "some string");
});
it("should get the right locale string", async () => {
assert.equal(
await pageAction.getStrings({ string_id: "hello_world" }),
localeStrings[0].value
);
});
it("should return the right sub-attribute if specified", async () => {
assert.equal(
await pageAction.getStrings(
{ string_id: "hello_world" },
"second_attr"
),
"some string"
);
});
it("should attach attributes to string overrides", async () => {
const fromJson = { value: "Add Now", attributes: { accesskey: "A" } };
const result = await pageAction.getStrings(fromJson);
assert.equal(result, fromJson.value);
assert.propertyVal(result.attributes, "accesskey", "A");
});
it("should return subAttributes when doing string overrides", async () => {
const fromJson = { value: "Add Now", attributes: { accesskey: "A" } };
const result = await pageAction.getStrings(fromJson, "accesskey");
assert.equal(result, "A");
});
it("should resolve ftl strings and attach subAttributes", async () => {
const fromFtl = { string_id: "cfr-doorhanger-extension-ok-button" };
formatMessagesStub.resolves([
{ value: "Add Now", attributes: [{ name: "accesskey", value: "A" }] },
]);
const result = await pageAction.getStrings(fromFtl);
assert.equal(result, "Add Now");
assert.propertyVal(result.attributes, "accesskey", "A");
});
it("should return subAttributes from ftl ids", async () => {
const fromFtl = { string_id: "cfr-doorhanger-extension-ok-button" };
formatMessagesStub.resolves([
{ value: "Add Now", attributes: [{ name: "accesskey", value: "A" }] },
]);
const result = await pageAction.getStrings(fromFtl, "accesskey");
assert.equal(result, "A");
});
it("should report an error when no attributes are present but subAttribute is requested", async () => {
const fromJson = { value: "Foo" };
const stub = sandbox.stub(global.Cu, "reportError");
await pageAction.getStrings(fromJson, "accesskey");
assert.calledOnce(stub);
stub.restore();
});
});
describe("#_showPopupOnClick", () => {
let translateElementsStub;
let setAttributesStub;
let getStringsStub;
beforeEach(async () => {
CFRPageActions.PageActionMap.set(fakeBrowser.ownerGlobal, pageAction);
await CFRPageActions.addRecommendation(
fakeBrowser,
fakeHost,
fakeRecommendation,
dispatchStub
);
getStringsStub = sandbox.stub(pageAction, "getStrings").resolves("");
getStringsStub
.callsFake(async a => a) // eslint-disable-line max-nested-callbacks
.withArgs({ string_id: "primary_button_id" })
.resolves({ value: "Primary Button", attributes: { accesskey: "p" } })
.withArgs({ string_id: "secondary_button_id" })
.resolves({
value: "Secondary Button",
attributes: { accesskey: "s" },
})
.withArgs({ string_id: "secondary_button_id_2" })
.resolves({
value: "Secondary Button 2",
attributes: { accesskey: "a" },
})
.withArgs({ string_id: "secondary_button_id_3" })
.resolves({
value: "Secondary Button 3",
attributes: { accesskey: "g" },
})
.withArgs(
sinon.match({
string_id: "cfr-doorhanger-extension-learn-more-link",
})
)
.resolves("Learn more")
.withArgs(
sinon.match({ string_id: "cfr-doorhanger-extension-total-users" })
)
.callsFake(async ({ args }) => `${args.total} users`); // eslint-disable-line max-nested-callbacks
translateElementsStub = sandbox.stub().resolves();
setAttributesStub = sandbox.stub();
global.RemoteL10n.l10n.setAttributes = setAttributesStub;
global.RemoteL10n.l10n.translateElements = translateElementsStub;
});
it("should call `.hideAddressBarNotifier` and do nothing if there is no recommendation for the selected browser", async () => {
sandbox.spy(pageAction, "hideAddressBarNotifier");
CFRPageActions.RecommendationMap.delete(fakeBrowser);
await pageAction._showPopupOnClick({});
assert.calledOnce(pageAction.hideAddressBarNotifier);
assert.notCalled(global.PopupNotifications.show);
});
it("should cancel any planned state changes", async () => {
sandbox.spy(pageAction, "_clearScheduledStateChanges");
assert.notCalled(pageAction._clearScheduledStateChanges);
await pageAction._showPopupOnClick({});
assert.calledOnce(pageAction._clearScheduledStateChanges);
});
it("should set the right text values", async () => {
await pageAction._showPopupOnClick({});
const headerLabel = elements["cfr-notification-header-label"];
const headerLink = elements["cfr-notification-header-link"];
const headerImage = elements["cfr-notification-header-image"];
const footerText = elements["cfr-notification-footer-text"];
const footerLink = elements["cfr-notification-footer-learn-more-link"];
assert.equal(
headerLabel.value,
fakeRecommendation.content.heading_text
);
assert.isTrue(
headerLink
.getAttribute("href")
.endsWith(fakeRecommendation.content.info_icon.sumo_path)
);
assert.equal(
headerImage.getAttribute("tooltiptext"),
fakeRecommendation.content.info_icon.label
);
assert.equal(footerText.textContent, fakeRecommendation.content.text);
assert.equal(footerLink.value, "Learn more");
assert.equal(
footerLink.getAttribute("href"),
fakeRecommendation.content.addon.amo_url
);
});
it("should add the rating correctly", async () => {
await pageAction._showPopupOnClick();
const footerFilledStars =
elements["cfr-notification-footer-filled-stars"];
const footerEmptyStars =
elements["cfr-notification-footer-empty-stars"];
// .toFixed to sort out some floating precision errors
assert.equal(
footerFilledStars.style.width,
`${(4.2 * 17).toFixed(1)}px`
);
assert.equal(
footerEmptyStars.style.width,
`${(0.8 * 17).toFixed(1)}px`
);
});
it("should add the number of users correctly", async () => {
await pageAction._showPopupOnClick();
const footerUsers = elements["cfr-notification-footer-users"];
assert.isNull(footerUsers.getAttribute("hidden"));
assert.equal(
footerUsers.getAttribute("value"),
`${fakeRecommendation.content.addon.users} users`
);
});
it("should send the right telemetry", async () => {
await pageAction._showPopupOnClick();
assert.calledWith(dispatchStub, {
type: "DOORHANGER_TELEMETRY",
data: {
action: "cfr_user_event",
source: "CFR",
message_id: fakeRecommendation.id,
bucket_id: fakeRecommendation.content.bucket_id,
event: "CLICK_DOORHANGER",
},
});
});
it("should send modelVersion if presented in the message", async () => {
const recommendationWithModelVersion = {
...fakeRecommendation,
personalizedModelVersion: "model_version_1",
};
CFRPageActions.clearRecommendations();
await CFRPageActions.addRecommendation(
fakeBrowser,
fakeHost,
recommendationWithModelVersion,
dispatchStub
);
await pageAction._showPopupOnClick();
assert.calledWith(dispatchStub, {
type: "DOORHANGER_TELEMETRY",
data: {
action: "cfr_user_event",
source: "CFR",
message_id: fakeRecommendation.id,
bucket_id: fakeRecommendation.content.bucket_id,
event: "CLICK_DOORHANGER",
event_context: { modelVersion: "model_version_1" },
},
});
});
it("should set the main action correctly", async () => {
sinon
.stub(CFRPageActions, "_fetchLatestAddonVersion")
.resolves("latest-addon.xpi");
await pageAction._showPopupOnClick();
const mainAction = global.PopupNotifications.show.firstCall.args[4]; // eslint-disable-line prefer-destructuring
assert.deepEqual(mainAction.label, {
value: "Primary Button",
attributes: { accesskey: "p" },
});
sandbox.spy(pageAction, "hideAddressBarNotifier");
await mainAction.callback();
assert.calledOnce(pageAction.hideAddressBarNotifier);
// Should block the message
assert.calledWith(dispatchStub, {
type: "BLOCK_MESSAGE_BY_ID",
data: { id: fakeRecommendation.id },
});
// Should trigger the action
assert.calledWith(
dispatchStub,
{
type: "USER_ACTION",
data: { id: "primary_action", data: { url: "latest-addon.xpi" } },
},
{ browser: fakeBrowser }
);
// Should send telemetry
assert.calledWith(dispatchStub, {
type: "DOORHANGER_TELEMETRY",
data: {
action: "cfr_user_event",
source: "CFR",
message_id: fakeRecommendation.id,
bucket_id: fakeRecommendation.content.bucket_id,
event: "INSTALL",
},
});
// Should remove the recommendation
assert.isFalse(CFRPageActions.RecommendationMap.has(fakeBrowser));
});
it("should set the secondary action correctly", async () => {
await pageAction._showPopupOnClick();
// eslint-disable-next-line prefer-destructuring
const [
secondaryAction,
] = global.PopupNotifications.show.firstCall.args[5];
assert.deepEqual(secondaryAction.label, {
value: "Secondary Button",
attributes: { accesskey: "s" },
});
sandbox.spy(pageAction, "hideAddressBarNotifier");
CFRPageActions.RecommendationMap.set(fakeBrowser, {});
secondaryAction.callback();
// Should send telemetry
assert.calledWith(dispatchStub, {
type: "DOORHANGER_TELEMETRY",
data: {
action: "cfr_user_event",
source: "CFR",
message_id: fakeRecommendation.id,
bucket_id: fakeRecommendation.content.bucket_id,
event: "DISMISS",
},
});
// Don't remove the recommendation on `DISMISS` action
assert.isTrue(CFRPageActions.RecommendationMap.has(fakeBrowser));
assert.notCalled(pageAction.hideAddressBarNotifier);
});
it("should send right telemetry for BLOCK secondary action", async () => {
await pageAction._showPopupOnClick();
// eslint-disable-next-line prefer-destructuring
const blockAction = global.PopupNotifications.show.firstCall.args[5][1];
assert.deepEqual(blockAction.label, {
value: "Secondary Button 2",
attributes: { accesskey: "a" },
});
sandbox.spy(pageAction, "hideAddressBarNotifier");
sandbox.spy(pageAction, "_blockMessage");
CFRPageActions.RecommendationMap.set(fakeBrowser, {});
blockAction.callback();
assert.calledOnce(pageAction.hideAddressBarNotifier);
assert.calledOnce(pageAction._blockMessage);
// Should send telemetry
assert.calledWith(dispatchStub, {
type: "DOORHANGER_TELEMETRY",
data: {
action: "cfr_user_event",
source: "CFR",
message_id: fakeRecommendation.id,
bucket_id: fakeRecommendation.content.bucket_id,
event: "BLOCK",
},
});
// Should remove the recommendation
assert.isFalse(CFRPageActions.RecommendationMap.has(fakeBrowser));
});
it("should send right telemetry for MANAGE secondary action", async () => {
await pageAction._showPopupOnClick();
// eslint-disable-next-line prefer-destructuring
const manageAction =
global.PopupNotifications.show.firstCall.args[5][2];
assert.deepEqual(manageAction.label, {
value: "Secondary Button 3",
attributes: { accesskey: "g" },
});
sandbox.spy(pageAction, "hideAddressBarNotifier");
CFRPageActions.RecommendationMap.set(fakeBrowser, {});
manageAction.callback();
// Should send telemetry
assert.calledWith(dispatchStub, {
type: "DOORHANGER_TELEMETRY",
data: {
action: "cfr_user_event",
source: "CFR",
message_id: fakeRecommendation.id,
bucket_id: fakeRecommendation.content.bucket_id,
event: "MANAGE",
},
});
// Don't remove the recommendation on `MANAGE` action
assert.isTrue(CFRPageActions.RecommendationMap.has(fakeBrowser));
assert.notCalled(pageAction.hideAddressBarNotifier);
});
it("should call PopupNotifications.show with the right arguments", async () => {
await pageAction._showPopupOnClick();
assert.calledWith(
global.PopupNotifications.show,
fakeBrowser,
"contextual-feature-recommendation",
fakeRecommendation.content.addon.title,
"cfr",
sinon.match.any, // Corresponds to the main action, tested above
sinon.match.any, // Corresponds to the secondary action, tested above
{
popupIconURL: fakeRecommendation.content.addon.icon,
hideClose: true,
eventCallback: pageAction._popupStateChange,
}
);
});
it("should show the bullet list details", async () => {
fakeRecommendation.content.layout = "message_and_animation";
await pageAction._showPopupOnClick();
assert.calledOnce(translateElementsStub);
});
it("should set the data-l10n-id on the list element", async () => {
fakeRecommendation.content.layout = "message_and_animation";
await pageAction._showPopupOnClick();
assert.calledOnce(setAttributesStub);
assert.calledWith(
setAttributesStub,
sinon.match.any,
fakeRecommendation.content.descriptionDetails.steps[0].string_id
);
});
it("should set the correct data-notification-category", async () => {
fakeRecommendation.content.layout = "message_and_animation";
await pageAction._showPopupOnClick();
assert.equal(
elements["contextual-feature-recommendation-notification"].dataset
.notificationCategory,
fakeRecommendation.content.layout
);
});
it("should send PIN event on primary action click", async () => {
fakeRecommendation.content.layout = "message_and_animation";
sandbox.stub(pageAction, "_sendTelemetry");
await pageAction._showPopupOnClick();
const [
,
,
,
,
{ callback },
] = global.PopupNotifications.show.firstCall.args;
callback();
// First call is triggered by `_showPopupOnClick`
assert.propertyVal(
pageAction._sendTelemetry.secondCall.args[0],
"event",
"PIN"
);
});
});
});
describe("CFRPageActions", () => {
beforeEach(() => {
// Spy on the prototype methods to inspect calls for any PageAction instance
sandbox.spy(PageAction.prototype, "showAddressBarNotifier");
sandbox.spy(PageAction.prototype, "hideAddressBarNotifier");
});
describe("updatePageActions", () => {
let savedRec;
beforeEach(() => {
const win = fakeBrowser.ownerGlobal;
CFRPageActions.PageActionMap.set(
win,
new PageAction(win, dispatchStub)
);
const { id, content } = fakeRecommendation;
savedRec = {
id,
host: fakeHost,
content,
};
CFRPageActions.RecommendationMap.set(fakeBrowser, savedRec);
});
it("should do nothing if a pageAction doesn't exist for the window", () => {
const win = fakeBrowser.ownerGlobal;
CFRPageActions.PageActionMap.delete(win);
CFRPageActions.updatePageActions(fakeBrowser);
assert.notCalled(PageAction.prototype.showAddressBarNotifier);
assert.notCalled(PageAction.prototype.hideAddressBarNotifier);
});
it("should do nothing if the browser is not the `selectedBrowser`", () => {
const someOtherFakeBrowser = {};
CFRPageActions.updatePageActions(someOtherFakeBrowser);
assert.notCalled(PageAction.prototype.showAddressBarNotifier);
assert.notCalled(PageAction.prototype.hideAddressBarNotifier);
});
it("should hideAddressBarNotifier the pageAction if a recommendation doesn't exist for the given browser", () => {
CFRPageActions.RecommendationMap.delete(fakeBrowser);
CFRPageActions.updatePageActions(fakeBrowser);
assert.calledOnce(PageAction.prototype.hideAddressBarNotifier);
});
it("should show the pageAction if a recommendation exists and the host matches", () => {
CFRPageActions.updatePageActions(fakeBrowser);
assert.calledOnce(PageAction.prototype.showAddressBarNotifier);
assert.calledWith(
PageAction.prototype.showAddressBarNotifier,
savedRec
);
});
it("should show the pageAction if a recommendation exists and it doesn't have a host defined", () => {
const recNoHost = { ...savedRec, host: undefined };
CFRPageActions.RecommendationMap.set(fakeBrowser, recNoHost);
CFRPageActions.updatePageActions(fakeBrowser);
assert.calledOnce(PageAction.prototype.showAddressBarNotifier);
assert.calledWith(
PageAction.prototype.showAddressBarNotifier,
recNoHost
);
});
it("should hideAddressBarNotifier the pageAction and delete the recommendation if the recommendation exists but the host doesn't match", () => {
const someOtherFakeHost = "subdomain.mozilla.com";
fakeBrowser.documentURI.host = someOtherFakeHost;
assert.isTrue(CFRPageActions.RecommendationMap.has(fakeBrowser));
CFRPageActions.updatePageActions(fakeBrowser);
assert.calledOnce(PageAction.prototype.hideAddressBarNotifier);
assert.isFalse(CFRPageActions.RecommendationMap.has(fakeBrowser));
});
it("should not call `delete` if retain is true", () => {
savedRec.retain = true;
fakeBrowser.documentURI.host = "subdomain.mozilla.com";
assert.isTrue(CFRPageActions.RecommendationMap.has(fakeBrowser));
CFRPageActions.updatePageActions(fakeBrowser);
assert.propertyVal(savedRec, "retain", false);
assert.calledOnce(PageAction.prototype.hideAddressBarNotifier);
assert.isTrue(CFRPageActions.RecommendationMap.has(fakeBrowser));
});
it("should call `delete` if retain is false", () => {
savedRec.retain = false;
fakeBrowser.documentURI.host = "subdomain.mozilla.com";
assert.isTrue(CFRPageActions.RecommendationMap.has(fakeBrowser));
CFRPageActions.updatePageActions(fakeBrowser);
assert.propertyVal(savedRec, "retain", false);
assert.calledOnce(PageAction.prototype.hideAddressBarNotifier);
assert.isFalse(CFRPageActions.RecommendationMap.has(fakeBrowser));
});
});
describe("forceRecommendation", () => {
it("should succeed and add an element to the RecommendationMap", async () => {
assert.isTrue(
await CFRPageActions.forceRecommendation(
{ browser: fakeBrowser },
fakeRecommendation,
dispatchStub
)
);
assert.deepInclude(CFRPageActions.RecommendationMap.get(fakeBrowser), {
id: fakeRecommendation.id,
content: fakeRecommendation.content,
});
});
it("should create a PageAction if one doesn't exist for the window, save it in the PageActionMap, and call `show`", async () => {
const win = fakeBrowser.ownerGlobal;
assert.isFalse(CFRPageActions.PageActionMap.has(win));
await CFRPageActions.forceRecommendation(
{ browser: fakeBrowser },
fakeRecommendation,
dispatchStub
);
const pageAction = CFRPageActions.PageActionMap.get(win);
assert.equal(win, pageAction.window);
assert.equal(dispatchStub, pageAction._dispatchToASRouter);
assert.calledOnce(PageAction.prototype.showAddressBarNotifier);
});
});
describe("addRecommendation", () => {
it("should fail and not add a recommendation if the browser is part of a private window", async () => {
global.PrivateBrowsingUtils.isWindowPrivate.returns(true);
assert.isFalse(
await CFRPageActions.addRecommendation(
fakeBrowser,
fakeHost,
fakeRecommendation,
dispatchStub
)
);
assert.isFalse(CFRPageActions.RecommendationMap.has(fakeBrowser));
});
it("should fail and not add a recommendation if the browser is not the selected browser", async () => {
global.gBrowser.selectedBrowser = {}; // Some other browser
assert.isFalse(
await CFRPageActions.addRecommendation(
fakeBrowser,
fakeHost,
fakeRecommendation,
dispatchStub
)
);
});
it("should fail and not add a recommendation if the host doesn't match", async () => {
const someOtherFakeHost = "subdomain.mozilla.com";
assert.isFalse(
await CFRPageActions.addRecommendation(
fakeBrowser,
someOtherFakeHost,
fakeRecommendation,
dispatchStub
)
);
});
it("should otherwise succeed and add an element to the RecommendationMap", async () => {
assert.isTrue(
await CFRPageActions.addRecommendation(
fakeBrowser,
fakeHost,
fakeRecommendation,
dispatchStub
)
);
assert.deepInclude(CFRPageActions.RecommendationMap.get(fakeBrowser), {
id: fakeRecommendation.id,
host: fakeHost,
content: fakeRecommendation.content,
});
});
it("should create a PageAction if one doesn't exist for the window, save it in the PageActionMap, and call `show`", async () => {
const win = fakeBrowser.ownerGlobal;
assert.isFalse(CFRPageActions.PageActionMap.has(win));
await CFRPageActions.addRecommendation(
fakeBrowser,
fakeHost,
fakeRecommendation,
dispatchStub
);
const pageAction = CFRPageActions.PageActionMap.get(win);
assert.equal(win, pageAction.window);
assert.equal(dispatchStub, pageAction._dispatchToASRouter);
assert.calledOnce(PageAction.prototype.showAddressBarNotifier);
});
it("should add the right url if we fetched and addon install URL", async () => {
fakeRecommendation.template = "cfr_doorhanger";
await CFRPageActions.addRecommendation(
fakeBrowser,
fakeHost,
fakeRecommendation,
dispatchStub
);
const recommendation = CFRPageActions.RecommendationMap.get(
fakeBrowser
);
// sanity check - just go through some of the rest of the attributes to make sure they were untouched
assert.equal(recommendation.id, fakeRecommendation.id);
assert.equal(
recommendation.content.heading_text,
fakeRecommendation.content.heading_text
);
assert.equal(
recommendation.content.addon,
fakeRecommendation.content.addon
);
assert.equal(
recommendation.content.text,
fakeRecommendation.content.text
);
assert.equal(
recommendation.content.buttons.secondary,
fakeRecommendation.content.buttons.secondary
);
assert.equal(
recommendation.content.buttons.primary.action.id,
fakeRecommendation.content.buttons.primary.action.id
);
delete fakeRecommendation.template;
});
it("should prevent a second message if one is currently displayed", async () => {
const secondMessage = { ...fakeRecommendation, id: "second_message" };
let messageAdded = await CFRPageActions.addRecommendation(
fakeBrowser,
fakeHost,
fakeRecommendation,
dispatchStub
);
assert.isTrue(messageAdded);
assert.deepInclude(CFRPageActions.RecommendationMap.get(fakeBrowser), {
id: fakeRecommendation.id,
host: fakeHost,
content: fakeRecommendation.content,
});
messageAdded = await CFRPageActions.addRecommendation(
fakeBrowser,
fakeHost,
secondMessage,
dispatchStub
);
// Adding failed
assert.isFalse(messageAdded);
// First message is still there
assert.deepInclude(CFRPageActions.RecommendationMap.get(fakeBrowser), {
id: fakeRecommendation.id,
host: fakeHost,
content: fakeRecommendation.content,
});
});
it("should send impressions just for the first message", async () => {
const secondMessage = { ...fakeRecommendation, id: "second_message" };
await CFRPageActions.addRecommendation(
fakeBrowser,
fakeHost,
fakeRecommendation,
dispatchStub
);
await CFRPageActions.addRecommendation(
fakeBrowser,
fakeHost,
secondMessage,
dispatchStub
);
// Doorhanger telemetry + Impression for just 1 message
assert.calledTwice(dispatchStub);
const [firstArgs] = dispatchStub.firstCall.args;
const [secondArgs] = dispatchStub.secondCall.args;
assert.equal(firstArgs.data.id, secondArgs.data.message_id);
});
});
describe("clearRecommendations", () => {
const createFakePageAction = () => ({
hideAddressBarNotifier: sandbox.stub(),
});
const windows = [{}, {}, { closed: true }];
const browsers = [{}, {}, {}, {}];
beforeEach(() => {
CFRPageActions.PageActionMap.set(windows[0], createFakePageAction());
CFRPageActions.PageActionMap.set(windows[2], createFakePageAction());
for (const browser of browsers) {
CFRPageActions.RecommendationMap.set(browser, {});
}
globals.set({ Services: { wm: { getEnumerator: () => windows } } });
});
it("should hideAddressBarNotifier the PageActions of any existing, non-closed windows", () => {
const pageActions = windows.map(win =>
CFRPageActions.PageActionMap.get(win)
);
CFRPageActions.clearRecommendations();
// Only the first window had a PageAction and wasn't closed
assert.calledOnce(pageActions[0].hideAddressBarNotifier);
assert.isUndefined(pageActions[1]);
assert.notCalled(pageActions[2].hideAddressBarNotifier);
});
it("should clear the PageActionMap and the RecommendationMap", () => {
CFRPageActions.clearRecommendations();
// Both are WeakMaps and so are not iterable, cannot be cleared, and
// cannot have their length queried directly, so we have to check
// whether previous elements still exist
assert.lengthOf(windows, 3);
for (const win of windows) {
assert.isFalse(CFRPageActions.PageActionMap.has(win));
}
assert.lengthOf(browsers, 4);
for (const browser of browsers) {
assert.isFalse(CFRPageActions.RecommendationMap.has(browser));
}
});
});
describe("reloadL10n", () => {
const createFakePageAction = () => ({
hideAddressBarNotifier() {},
reloadL10n: sandbox.stub(),
});
const windows = [{}, {}, { closed: true }];
beforeEach(() => {
CFRPageActions.PageActionMap.set(windows[0], createFakePageAction());
CFRPageActions.PageActionMap.set(windows[2], createFakePageAction());
globals.set({ Services: { wm: { getEnumerator: () => windows } } });
});
it("should call reloadL10n for all the PageActions of any existing, non-closed windows", () => {
const pageActions = windows.map(win =>
CFRPageActions.PageActionMap.get(win)
);
CFRPageActions.reloadL10n();
// Only the first window had a PageAction and wasn't closed
assert.calledOnce(pageActions[0].reloadL10n);
assert.isUndefined(pageActions[1]);
assert.notCalled(pageActions[2].reloadL10n);
});
});
});
});
================================================
FILE: test/unit/asrouter/MessageLoaderUtils.test.js
================================================
import { GlobalOverrider } from "test/unit/utils";
import { MessageLoaderUtils } from "lib/ASRouter.jsm";
const { STARTPAGE_VERSION } = MessageLoaderUtils;
const FAKE_OPTIONS = {
storage: {
set() {
return Promise.resolve();
},
get() {
return Promise.resolve();
},
},
dispatchToAS: () => {},
};
const FAKE_RESPONSE_HEADERS = { get() {} };
describe("MessageLoaderUtils", () => {
let fetchStub;
let clock;
let sandbox;
beforeEach(() => {
sandbox = sinon.createSandbox();
clock = sinon.useFakeTimers();
fetchStub = sinon.stub(global, "fetch");
});
afterEach(() => {
sandbox.restore();
clock.restore();
fetchStub.restore();
});
describe("#loadMessagesForProvider", () => {
it("should return messages for a local provider with hardcoded messages", async () => {
const sourceMessage = { id: "foo" };
const provider = {
id: "provider123",
type: "local",
messages: [sourceMessage],
};
const result = await MessageLoaderUtils.loadMessagesForProvider(
provider,
FAKE_OPTIONS
);
assert.isArray(result.messages);
// Does the message have the right properties?
const [message] = result.messages;
assert.propertyVal(message, "id", "foo");
assert.propertyVal(message, "provider", "provider123");
});
it("should filter out local messages listed in the `exclude` field", async () => {
const sourceMessage = { id: "foo" };
const provider = {
id: "provider123",
type: "local",
messages: [sourceMessage],
exclude: ["foo"],
};
const result = await MessageLoaderUtils.loadMessagesForProvider(
provider,
FAKE_OPTIONS
);
assert.lengthOf(result.messages, 0);
});
it("should return messages for remote provider", async () => {
const sourceMessage = { id: "foo" };
fetchStub.resolves({
ok: true,
status: 200,
json: () => Promise.resolve({ messages: [sourceMessage] }),
headers: FAKE_RESPONSE_HEADERS,
});
const provider = {
id: "provider123",
type: "remote",
url: "https://foo.com",
};
const result = await MessageLoaderUtils.loadMessagesForProvider(
provider,
FAKE_OPTIONS
);
assert.isArray(result.messages);
// Does the message have the right properties?
const [message] = result.messages;
assert.propertyVal(message, "id", "foo");
assert.propertyVal(message, "provider", "provider123");
assert.propertyVal(message, "provider_url", "https://foo.com");
});
describe("remote provider HTTP codes", () => {
const testMessage = { id: "foo" };
const provider = {
id: "provider123",
type: "remote",
url: "https://foo.com",
updateCycleInMs: 300,
};
const respJson = { messages: [testMessage] };
function assertReturnsCorrectMessages(actual) {
assert.isArray(actual.messages);
// Does the message have the right properties?
const [message] = actual.messages;
assert.propertyVal(message, "id", testMessage.id);
assert.propertyVal(message, "provider", provider.id);
assert.propertyVal(message, "provider_url", provider.url);
}
it("should return messages for 200 response", async () => {
fetchStub.resolves({
ok: true,
status: 200,
json: () => Promise.resolve(respJson),
headers: FAKE_RESPONSE_HEADERS,
});
assertReturnsCorrectMessages(
await MessageLoaderUtils.loadMessagesForProvider(
provider,
FAKE_OPTIONS
)
);
});
it("should return messages for a 302 response with json", async () => {
fetchStub.resolves({
ok: true,
status: 302,
json: () => Promise.resolve(respJson),
headers: FAKE_RESPONSE_HEADERS,
});
assertReturnsCorrectMessages(
await MessageLoaderUtils.loadMessagesForProvider(
provider,
FAKE_OPTIONS
)
);
});
it("should return an empty array for a 204 response", async () => {
fetchStub.resolves({
ok: true,
status: 204,
json: () => "",
headers: FAKE_RESPONSE_HEADERS,
});
const result = await MessageLoaderUtils.loadMessagesForProvider(
provider,
FAKE_OPTIONS
);
assert.deepEqual(result.messages, []);
});
it("should return an empty array for a 500 response", async () => {
fetchStub.resolves({
ok: false,
status: 500,
json: () => "",
headers: FAKE_RESPONSE_HEADERS,
});
const result = await MessageLoaderUtils.loadMessagesForProvider(
provider,
FAKE_OPTIONS
);
assert.deepEqual(result.messages, []);
});
it("should return cached messages for a 304 response", async () => {
clock.tick(302);
const messages = [{ id: "message-1" }, { id: "message-2" }];
const fakeStorage = {
set() {
return Promise.resolve();
},
get() {
return Promise.resolve({
[provider.id]: {
version: STARTPAGE_VERSION,
url: provider.url,
messages,
etag: "etag0987654321",
lastFetched: 1,
},
});
},
};
fetchStub.resolves({
ok: true,
status: 304,
json: () => "",
headers: FAKE_RESPONSE_HEADERS,
});
const result = await MessageLoaderUtils.loadMessagesForProvider(
provider,
{ ...FAKE_OPTIONS, storage: fakeStorage }
);
assert.equal(result.messages.length, messages.length);
messages.forEach(message => {
assert.ok(result.messages.find(m => m.id === message.id));
});
});
it("should return an empty array if json doesn't parse properly", async () => {
fetchStub.resolves({
ok: false,
status: 200,
json: () => "",
headers: FAKE_RESPONSE_HEADERS,
});
const result = await MessageLoaderUtils.loadMessagesForProvider(
provider,
FAKE_OPTIONS
);
assert.deepEqual(result.messages, []);
});
it("should report response parsing errors with MessageLoaderUtils.reportError", async () => {
const err = {};
sandbox.spy(MessageLoaderUtils, "reportError");
fetchStub.resolves({
ok: true,
status: 200,
json: sandbox.stub().rejects(err),
headers: FAKE_RESPONSE_HEADERS,
});
await MessageLoaderUtils.loadMessagesForProvider(
provider,
FAKE_OPTIONS
);
assert.calledOnce(MessageLoaderUtils.reportError);
// Report that json parsing failed
assert.calledWith(MessageLoaderUtils.reportError, err);
});
it("should report missing `messages` with MessageLoaderUtils.reportError", async () => {
sandbox.spy(MessageLoaderUtils, "reportError");
fetchStub.resolves({
ok: true,
status: 200,
json: sandbox.stub().resolves({}),
headers: FAKE_RESPONSE_HEADERS,
});
await MessageLoaderUtils.loadMessagesForProvider(
provider,
FAKE_OPTIONS
);
assert.calledOnce(MessageLoaderUtils.reportError);
// Report no messages returned
assert.calledWith(
MessageLoaderUtils.reportError,
"No messages returned from https://foo.com."
);
});
it("should report bad status responses with MessageLoaderUtils.reportError", async () => {
sandbox.spy(MessageLoaderUtils, "reportError");
fetchStub.resolves({
ok: false,
status: 500,
json: sandbox.stub().resolves({}),
headers: FAKE_RESPONSE_HEADERS,
});
await MessageLoaderUtils.loadMessagesForProvider(
provider,
FAKE_OPTIONS
);
assert.calledOnce(MessageLoaderUtils.reportError);
// Report no messages returned
assert.calledWith(
MessageLoaderUtils.reportError,
"Invalid response status 500 from https://foo.com."
);
});
it("should return an empty array if the request rejects", async () => {
fetchStub.rejects(new Error("something went wrong"));
const result = await MessageLoaderUtils.loadMessagesForProvider(
provider,
FAKE_OPTIONS
);
assert.deepEqual(result.messages, []);
});
});
describe("remote provider caching", () => {
const provider = {
id: "provider123",
type: "remote",
url: "https://foo.com",
updateCycleInMs: 300,
};
it("should return cached results if they aren't expired", async () => {
clock.tick(1);
const messages = [{ id: "message-1" }, { id: "message-2" }];
const fakeStorage = {
set() {
return Promise.resolve();
},
get() {
return Promise.resolve({
[provider.id]: {
version: STARTPAGE_VERSION,
url: provider.url,
messages,
etag: "etag0987654321",
lastFetched: Date.now(),
},
});
},
};
const result = await MessageLoaderUtils.loadMessagesForProvider(
provider,
{ ...FAKE_OPTIONS, storage: fakeStorage }
);
assert.equal(result.messages.length, messages.length);
messages.forEach(message => {
assert.ok(result.messages.find(m => m.id === message.id));
});
});
it("should return fetch results if the cache messages are expired", async () => {
clock.tick(302);
const testMessage = { id: "foo" };
const respJson = { messages: [testMessage] };
const fakeStorage = {
set() {
return Promise.resolve();
},
get() {
return Promise.resolve({
[provider.id]: {
version: STARTPAGE_VERSION,
url: provider.url,
messages: [{ id: "message-1" }, { id: "message-2" }],
etag: "etag0987654321",
lastFetched: 1,
},
});
},
};
fetchStub.resolves({
ok: true,
status: 200,
json: () => Promise.resolve(respJson),
headers: FAKE_RESPONSE_HEADERS,
});
const result = await MessageLoaderUtils.loadMessagesForProvider(
provider,
{ ...FAKE_OPTIONS, storage: fakeStorage }
);
assert.equal(result.messages.length, 1);
assert.equal(result.messages[0].id, testMessage.id);
});
});
it("should return an empty array for a remote provider with a blank URL without attempting a request", async () => {
const provider = { id: "provider123", type: "remote", url: "" };
const result = await MessageLoaderUtils.loadMessagesForProvider(
provider,
FAKE_OPTIONS
);
assert.notCalled(fetchStub);
assert.deepEqual(result.messages, []);
});
it("should return .lastUpdated with the time at which the messages were fetched", async () => {
const sourceMessage = { id: "foo" };
const provider = {
id: "provider123",
type: "remote",
url: "foo.com",
};
fetchStub.resolves({
ok: true,
status: 200,
json: () =>
new Promise(resolve => {
clock.tick(42);
resolve({ messages: [sourceMessage] });
}),
headers: FAKE_RESPONSE_HEADERS,
});
const result = await MessageLoaderUtils.loadMessagesForProvider(
provider,
FAKE_OPTIONS
);
assert.propertyVal(result, "lastUpdated", 42);
});
});
describe("#shouldProviderUpdate", () => {
it("should return true if the provider does not had a .lastUpdated property", () => {
assert.isTrue(MessageLoaderUtils.shouldProviderUpdate({ id: "foo" }));
});
it("should return false if the provider does not had a .updateCycleInMs property and has a .lastUpdated", () => {
clock.tick(1);
assert.isFalse(
MessageLoaderUtils.shouldProviderUpdate({ id: "foo", lastUpdated: 0 })
);
});
it("should return true if the time since .lastUpdated is greater than .updateCycleInMs", () => {
clock.tick(301);
assert.isTrue(
MessageLoaderUtils.shouldProviderUpdate({
id: "foo",
lastUpdated: 0,
updateCycleInMs: 300,
})
);
});
it("should return false if the time since .lastUpdated is less than .updateCycleInMs", () => {
clock.tick(299);
assert.isFalse(
MessageLoaderUtils.shouldProviderUpdate({
id: "foo",
lastUpdated: 0,
updateCycleInMs: 300,
})
);
});
});
describe("#_loadAddonIconInURLBar", () => {
let notificationContainerEl;
let browser;
let getContainerStub;
beforeEach(() => {
notificationContainerEl = { style: {} };
browser = {
ownerDocument: {
getElementById() {
return {};
},
},
};
getContainerStub = sandbox.stub(browser.ownerDocument, "getElementById");
});
it("should return for empty args", () => {
MessageLoaderUtils._loadAddonIconInURLBar();
assert.notCalled(getContainerStub);
});
it("should return if notification popup box not found", () => {
getContainerStub.returns(null);
MessageLoaderUtils._loadAddonIconInURLBar(browser);
assert.calledOnce(getContainerStub);
});
it("should unhide notification popup box with display style as none", () => {
getContainerStub.returns(notificationContainerEl);
notificationContainerEl.style.display = "none";
MessageLoaderUtils._loadAddonIconInURLBar(browser);
assert.calledWith(
browser.ownerDocument.getElementById,
"notification-popup-box"
);
assert.equal(notificationContainerEl.style.display, "block");
});
it("should unhide notification popup box with display style empty", () => {
getContainerStub.returns(notificationContainerEl);
notificationContainerEl.style.display = "";
MessageLoaderUtils._loadAddonIconInURLBar(browser);
assert.calledWith(
browser.ownerDocument.getElementById,
"notification-popup-box"
);
assert.equal(notificationContainerEl.style.display, "block");
});
});
describe("#installAddonFromURL", () => {
let globals;
let getInstallStub;
let installAddonStub;
beforeEach(() => {
globals = new GlobalOverrider();
getInstallStub = sandbox.stub();
installAddonStub = sandbox.stub();
sandbox.stub(MessageLoaderUtils, "_loadAddonIconInURLBar").returns(null);
globals.set("AddonManager", {
getInstallForURL: getInstallStub,
installAddonFromWebpage: installAddonStub,
});
});
afterEach(() => {
globals.restore();
});
it("should call the Addons API when passed a valid URL", async () => {
getInstallStub.resolves(null);
installAddonStub.resolves(null);
await MessageLoaderUtils.installAddonFromURL({}, "foo.com");
assert.calledOnce(getInstallStub);
assert.calledOnce(installAddonStub);
// Verify that the expected installation source has been passed to the getInstallForURL
// method (See Bug 1496167 for a rationale).
assert.calledWithExactly(getInstallStub, "foo.com", {
telemetryInfo: { source: "amo" },
});
});
it("should optionally pass a custom telemetrySource to the Addons API if specified", async () => {
getInstallStub.resolves(null);
installAddonStub.resolves(null);
await MessageLoaderUtils.installAddonFromURL({}, "foo.com", "foo");
assert.calledOnce(getInstallStub);
assert.calledOnce(installAddonStub);
// Verify that a custom installation source can be passed to the getInstallForURL
// method (See Bug 1549770 for a rationale).
assert.calledWithExactly(getInstallStub, "foo.com", {
telemetryInfo: { source: "foo" },
});
});
it("should not call the Addons API on invalid URLs", async () => {
sandbox
.stub(global.Services.scriptSecurityManager, "getSystemPrincipal")
.throws();
await MessageLoaderUtils.installAddonFromURL({}, "https://foo.com");
assert.notCalled(getInstallStub);
assert.notCalled(installAddonStub);
});
});
describe("#cleanupCache", () => {
it("should remove data for providers no longer active", async () => {
const fakeStorage = {
get: sinon.stub().returns(
Promise.resolve({
"id-1": {},
"id-2": {},
"id-3": {},
})
),
set: sinon.stub().returns(Promise.resolve()),
};
const fakeProviders = [
{ id: "id-1", type: "remote" },
{ id: "id-3", type: "remote" },
];
await MessageLoaderUtils.cleanupCache(fakeProviders, fakeStorage);
assert.calledOnce(fakeStorage.set);
assert.calledWith(
fakeStorage.set,
MessageLoaderUtils.REMOTE_LOADER_CACHE_KEY,
{ "id-1": {}, "id-3": {} }
);
});
});
});
================================================
FILE: test/unit/asrouter/ModalOverlay.test.jsx
================================================
import { ModalOverlayWrapper } from "content-src/asrouter/components/ModalOverlay/ModalOverlay";
import { mount } from "enzyme";
import React from "react";
describe("ModalOverlayWrapper", () => {
let fakeDoc;
let sandbox;
let header;
beforeEach(() => {
sandbox = sinon.createSandbox();
header = document.createElement("div");
fakeDoc = {
addEventListener: sandbox.stub(),
removeEventListener: sandbox.stub(),
body: { classList: { add: sandbox.stub(), remove: sandbox.stub() } },
getElementById() {
return header;
},
};
});
afterEach(() => {
sandbox.restore();
});
it("should add eventListener and a class on mount", async () => {
mount( );
assert.calledOnce(fakeDoc.addEventListener);
assert.calledWith(fakeDoc.body.classList.add, "modal-open");
});
it("should remove eventListener on unmount", async () => {
const wrapper = mount( );
wrapper.unmount();
assert.calledOnce(fakeDoc.addEventListener);
assert.calledOnce(fakeDoc.removeEventListener);
assert.calledWith(fakeDoc.body.classList.remove, "modal-open");
});
it("should call props.onClose on an Escape key", async () => {
const onClose = sandbox.stub();
mount( );
// Simulate onkeydown being called
const [, callback] = fakeDoc.addEventListener.firstCall.args;
callback({ key: "Escape" });
assert.calledOnce(onClose);
});
it("should not call props.onClose on other keys than Escape", async () => {
const onClose = sandbox.stub();
mount( );
// Simulate onkeydown being called
const [, callback] = fakeDoc.addEventListener.firstCall.args;
callback({ key: "Ctrl" });
assert.notCalled(onClose);
});
it("should not call props.onClose when clicked outside dialog", async () => {
const onClose = sandbox.stub();
const wrapper = mount(
);
wrapper.find("div.modalOverlayOuter.active").simulate("click");
assert.notCalled(onClose);
});
});
================================================
FILE: test/unit/asrouter/PanelTestProvider.test.js
================================================
import { PanelTestProvider } from "lib/PanelTestProvider.jsm";
import schema from "content-src/asrouter/schemas/panel/cfr-fxa-bookmark.schema.json";
import update_schema from "content-src/asrouter/templates/OnboardingMessage/UpdateAction.schema.json";
import whats_new_schema from "content-src/asrouter/templates/OnboardingMessage/WhatsNewMessage.schema.json";
const messages = PanelTestProvider.getMessages();
describe("PanelTestProvider", () => {
it("should have a message", () => {
// Careful: when changing this number make sure that new messages also go
// through schema verifications.
assert.lengthOf(messages, 16);
});
it("should be a valid message", () => {
const fxaMessages = messages.filter(
({ template }) => template === "fxa_bookmark_panel"
);
for (let message of fxaMessages) {
assert.jsonSchema(message.content, schema);
}
});
it("should be a valid message", () => {
const updateMessages = messages.filter(
({ template }) => template === "update_action"
);
for (let message of updateMessages) {
assert.jsonSchema(message.content, update_schema);
}
});
it("should be a valid message", () => {
const whatsNewMessages = messages.filter(
({ template }) => template === "whatsnew_panel_message"
);
for (let message of whatsNewMessages) {
assert.jsonSchema(message.content, whats_new_schema);
// Not part of `message.content` so it can't be enforced through schema
assert.property(message, "order");
}
});
});
================================================
FILE: test/unit/asrouter/RemoteL10n.test.js
================================================
import { RemoteL10n, _RemoteL10n } from "lib/RemoteL10n.jsm";
import { GlobalOverrider } from "test/unit/utils";
describe("RemoteL10n", () => {
let sandbox;
let globals;
let domL10nStub;
beforeEach(() => {
sandbox = sinon.createSandbox();
globals = new GlobalOverrider();
domL10nStub = sandbox.stub();
globals.set("DOMLocalization", domL10nStub);
});
afterEach(() => {
sandbox.restore();
globals.restore();
});
describe("#RemoteL10n", () => {
it("should create a new instance", () => {
assert.ok(new _RemoteL10n());
});
it("should create a DOMLocalization instance", () => {
domL10nStub.returns({ instance: true });
const instance = new _RemoteL10n();
assert.propertyVal(instance._createDOML10n(), "instance", true);
assert.calledOnce(domL10nStub);
});
it("should create a new instance", () => {
domL10nStub.returns({ instance: true });
const instance = new _RemoteL10n();
assert.ok(instance.l10n);
instance.reloadL10n();
assert.ok(instance.l10n);
assert.calledTwice(domL10nStub);
});
it("should reuse the instance", () => {
domL10nStub.returns({ instance: true });
const instance = new _RemoteL10n();
assert.ok(instance.l10n);
assert.ok(instance.l10n);
assert.calledOnce(domL10nStub);
});
});
describe("#_createDOML10n", () => {
it("should load the remote Fluent file if USE_REMOTE_L10N_PREF is true", async () => {
sandbox.stub(global.Services.prefs, "getBoolPref").returns(true);
RemoteL10n._createDOML10n();
assert.calledOnce(domL10nStub);
const { args } = domL10nStub.firstCall;
// The first arg is the resource array, and the second one is the bundle generator.
assert.equal(args.length, 2);
assert.deepEqual(args[0], [
"browser/newtab/asrouter.ftl",
"browser/branding/brandings.ftl",
"browser/branding/sync-brand.ftl",
"branding/brand.ftl",
]);
assert.isFunction(args[1]);
});
it("should load the local Fluent file if USE_REMOTE_L10N_PREF is false", () => {
sandbox.stub(global.Services.prefs, "getBoolPref").returns(false);
RemoteL10n._createDOML10n();
const { args } = domL10nStub.firstCall;
// The first arg is the resource array, and the second one should be null.
assert.equal(args.length, 2);
assert.deepEqual(args[0], [
"browser/newtab/asrouter.ftl",
"browser/branding/brandings.ftl",
"browser/branding/sync-brand.ftl",
"branding/brand.ftl",
]);
assert.isUndefined(args[1]);
});
});
});
================================================
FILE: test/unit/asrouter/RichText.test.jsx
================================================
import {
convertLinks,
RichText,
} from "content-src/asrouter/components/RichText/RichText";
import { Localized } from "fluent-react";
import { mount } from "enzyme";
import React from "react";
describe("convertLinks", () => {
let sandbox;
beforeEach(() => {
sandbox = sinon.createSandbox();
});
afterEach(() => {
sandbox.restore();
});
it("should return an object with anchor elements", () => {
const cta = {
url: "https://foo.com",
metric: "foo",
};
const stub = sandbox.stub();
const result = convertLinks({ cta }, stub);
assert.property(result, "cta");
assert.propertyVal(result.cta, "type", "a");
assert.propertyVal(result.cta.props, "href", cta.url);
assert.propertyVal(result.cta.props, "data-metric", cta.metric);
assert.propertyVal(result.cta.props, "onClick", stub);
});
it("should return an anchor element without href", () => {
const cta = {
url: "https://foo.com",
metric: "foo",
action: "OPEN_MENU",
args: "appMenu",
};
const stub = sandbox.stub();
const result = convertLinks({ cta }, stub);
assert.property(result, "cta");
assert.propertyVal(result.cta, "type", "a");
assert.propertyVal(result.cta.props, "href", false);
assert.propertyVal(result.cta.props, "data-metric", cta.metric);
assert.propertyVal(result.cta.props, "data-action", cta.action);
assert.propertyVal(result.cta.props, "data-args", cta.args);
assert.propertyVal(result.cta.props, "onClick", stub);
});
it("should follow openNewWindow prop", () => {
const cta = { url: "https://foo.com" };
const newWindow = convertLinks({ cta }, sandbox.stub(), false, true);
const sameWindow = convertLinks({ cta }, sandbox.stub(), false);
assert.propertyVal(newWindow.cta.props, "target", "_blank");
assert.propertyVal(sameWindow.cta.props, "target", "");
});
it("should allow for custom elements & styles", () => {
const wrapper = mount(
}}
text="foo "
localization_id="text"
/>
);
const localized = wrapper.find(Localized);
assert.propertyVal(localized.props().em.props.style, "color", "#f05");
});
});
================================================
FILE: test/unit/asrouter/SnippetsTestMessageProvider.test.js
================================================
import EOYSnippetSchema from "../../../content-src/asrouter/templates/EOYSnippet/EOYSnippet.schema.json";
import SimpleBelowSearchSnippetSchema from "../../../content-src/asrouter/templates/SimpleBelowSearchSnippet/SimpleBelowSearchSnippet.schema.json";
import SimpleSnippetSchema from "../../../content-src/asrouter/templates/SimpleSnippet/SimpleSnippet.schema.json";
import { SnippetsTestMessageProvider } from "../../../lib/SnippetsTestMessageProvider.jsm";
import SubmitFormSnippetSchema from "../../../content-src/asrouter/templates/SubmitFormSnippet/SubmitFormSnippet.schema.json";
const schemas = {
simple_snippet: SimpleSnippetSchema,
newsletter_snippet: SubmitFormSnippetSchema,
fxa_signup_snippet: SubmitFormSnippetSchema,
send_to_device_snippet: SubmitFormSnippetSchema,
eoy_snippet: EOYSnippetSchema,
simple_below_search_snippet: SimpleBelowSearchSnippetSchema,
};
describe("SnippetsTestMessageProvider", () => {
let messages = SnippetsTestMessageProvider.getMessages();
it("should return an array of messages", () => {
assert.isArray(messages);
});
it("should have a valid example of each schema", () => {
Object.keys(schemas).forEach(templateName => {
const example = messages.find(
message => message.template === templateName
);
assert.ok(example, `has a ${templateName} example`);
});
});
it("should have examples that are valid", () => {
messages.forEach(example => {
assert.jsonSchema(
example.content,
schemas[example.template],
`${example.id} should be valid`
);
});
});
});
================================================
FILE: test/unit/asrouter/TargetingDocs.test.js
================================================
import { ASRouterTargeting } from "lib/ASRouterTargeting.jsm";
import docs from "content-src/asrouter/docs/targeting-attributes.md";
// The following targeting parameters are either deprecated or should not be included in the docs for some reason.
const SKIP_DOCS = [];
// These are extra message context attributes via ASRouter.jsm
const MESSAGE_CONTEXT_ATTRIBUTES = [
"previousSessionEnd",
"trailheadInterrupt",
"trailheadTriplet",
];
function getHeadingsFromDocs() {
const re = /### `(\w+)`/g;
const found = [];
let match = 1;
while (match) {
match = re.exec(docs);
if (match) {
found.push(match[1]);
}
}
return found;
}
function getTOCFromDocs() {
const re = /## Available attributes\n+([^]+)\n+## Detailed usage/;
const sectionMatch = docs.match(re);
if (!sectionMatch) {
return [];
}
const [, listText] = sectionMatch;
const re2 = /\[(\w+)\]/g;
const found = [];
let match = 1;
while (match) {
match = re2.exec(listText);
if (match) {
found.push(match[1]);
}
}
return found;
}
describe("ASRTargeting docs", () => {
const DOCS_TARGETING_HEADINGS = getHeadingsFromDocs();
const DOCS_TOC = getTOCFromDocs();
const ASRTargetingAttributes = [
...Object.keys(ASRouterTargeting.Environment).filter(
attribute => !SKIP_DOCS.includes(attribute)
),
...MESSAGE_CONTEXT_ATTRIBUTES,
];
describe("All targeting params documented in targeting-attributes.md", () => {
for (const targetingParam of ASRTargetingAttributes) {
// If this test is failing, you probably forgot to add docs to content-src/asrouter/targeting-attributes.md
// for a new targeting attribute, or you forgot to put it in the table of contents up top.
it(`should have docs and table of contents entry for ${targetingParam}`, () => {
assert.include(
DOCS_TARGETING_HEADINGS,
targetingParam,
`Didn't find the heading: ### \`${targetingParam}\``
);
assert.include(
DOCS_TOC,
targetingParam,
`Didn't find a table of contents entry for ${targetingParam}`
);
});
}
});
describe("No extra attributes in targeting-attributes.md", () => {
// whitelist includes targeting attributes that are not implemented by
// ASRTargetingAttributes. For example trigger context passed to the evaluation
// context in when a trigger runs or ASRouter state used in the evaluation.
const whitelist = [
"personalizedCfrThreshold",
"personalizedCfrScores",
"messageImpressions",
];
for (const targetingParam of DOCS_TARGETING_HEADINGS.filter(
doc => !whitelist.includes(doc)
)) {
// If this test is failing, you might has spelled something wrong or removed a targeting param without
// removing its docs.
it(`should have an implementation for ${targetingParam} in ASRouterTargeting.Environment`, () => {
assert.include(
ASRTargetingAttributes,
targetingParam,
`Didn't find an implementation for ${targetingParam}`
);
});
}
});
});
================================================
FILE: test/unit/asrouter/asrouter-content.test.jsx
================================================
import {
ASRouterUISurface,
ASRouterUtils,
} from "content-src/asrouter/asrouter-content";
import { GlobalOverrider } from "test/unit/utils";
import { OUTGOING_MESSAGE_NAME as AS_GENERAL_OUTGOING_MESSAGE_NAME } from "content-src/lib/init-store";
import { FAKE_LOCAL_MESSAGES } from "./constants";
import { OnboardingMessageProvider } from "lib/OnboardingMessageProvider.jsm";
import React from "react";
import { mount } from "enzyme";
import { Trailhead } from "../../../content-src/asrouter/templates/Trailhead/Trailhead";
import { Triplets } from "../../../content-src/asrouter/templates/FirstRun/Triplets";
import { actionCreators as ac } from "common/Actions.jsm";
let [FAKE_MESSAGE] = FAKE_LOCAL_MESSAGES;
const FAKE_NEWSLETTER_SNIPPET = FAKE_LOCAL_MESSAGES.find(
msg => msg.id === "newsletter"
);
const FAKE_FXA_SNIPPET = FAKE_LOCAL_MESSAGES.find(msg => msg.id === "fxa");
const FAKE_BELOW_SEARCH_SNIPPET = FAKE_LOCAL_MESSAGES.find(
msg => msg.id === "belowsearch"
);
FAKE_MESSAGE = Object.assign({}, FAKE_MESSAGE, { provider: "fakeprovider" });
describe("ASRouterUtils", () => {
let global;
let sandbox;
let fakeSendAsyncMessage;
beforeEach(() => {
global = new GlobalOverrider();
sandbox = sinon.createSandbox();
fakeSendAsyncMessage = sandbox.stub();
global.set({ RPMSendAsyncMessage: fakeSendAsyncMessage });
});
afterEach(() => {
sandbox.restore();
global.restore();
});
it("should send a message with the right payload data", () => {
ASRouterUtils.sendTelemetry({ id: 1, event: "CLICK" });
assert.calledOnce(fakeSendAsyncMessage);
assert.calledWith(fakeSendAsyncMessage, AS_GENERAL_OUTGOING_MESSAGE_NAME);
const [, payload] = fakeSendAsyncMessage.firstCall.args;
assert.propertyVal(payload.data, "id", 1);
assert.propertyVal(payload.data, "event", "CLICK");
});
});
describe("ASRouterUISurface", () => {
let wrapper;
let globalO;
let sandbox;
let headerPortal;
let footerPortal;
let fakeDocument;
let fetchStub;
beforeEach(() => {
sandbox = sinon.createSandbox();
headerPortal = document.createElement("div");
footerPortal = document.createElement("div");
sandbox.stub(footerPortal, "querySelector").returns(footerPortal);
fetchStub = sandbox.stub(global, "fetch").resolves({
ok: true,
status: 200,
json: () => Promise.resolve({}),
});
fakeDocument = {
location: { href: "" },
_listeners: new Set(),
_visibilityState: "hidden",
head: {
appendChild(el) {
return el;
},
},
get visibilityState() {
return this._visibilityState;
},
set visibilityState(value) {
if (this._visibilityState === value) {
return;
}
this._visibilityState = value;
this._listeners.forEach(l => l());
},
addEventListener(event, listener) {
this._listeners.add(listener);
},
removeEventListener(event, listener) {
this._listeners.delete(listener);
},
get body() {
return document.createElement("body");
},
getElementById(id) {
switch (id) {
case "header-asrouter-container":
return headerPortal;
default:
return footerPortal;
}
},
createElement(tag) {
return document.createElement(tag);
},
};
globalO = new GlobalOverrider();
globalO.set({
RPMAddMessageListener: sandbox.stub(),
RPMRemoveMessageListener: sandbox.stub(),
RPMSendAsyncMessage: sandbox.stub(),
});
sandbox.stub(ASRouterUtils, "sendTelemetry");
wrapper = mount( );
});
afterEach(() => {
sandbox.restore();
globalO.restore();
});
it("should render the component if a message id is defined", () => {
wrapper.setState({ message: FAKE_MESSAGE });
assert.isTrue(wrapper.exists());
});
it("should pass in the correct form_method for newsletter snippets", () => {
wrapper.setState({ message: FAKE_NEWSLETTER_SNIPPET });
assert.isTrue(wrapper.find("SubmitFormSnippet").exists());
assert.propertyVal(
wrapper.find("SubmitFormSnippet").props(),
"form_method",
"POST"
);
});
it("should pass in the correct form_method for fxa snippets", () => {
wrapper.setState({ message: FAKE_FXA_SNIPPET });
assert.isTrue(wrapper.find("SubmitFormSnippet").exists());
assert.propertyVal(
wrapper.find("SubmitFormSnippet").props(),
"form_method",
"GET"
);
});
it("should render a preview banner if message provider is preview", () => {
wrapper.setState({ message: { ...FAKE_MESSAGE, provider: "preview" } });
assert.isTrue(wrapper.find(".snippets-preview-banner").exists());
});
it("should not render a preview banner if message provider is not preview", () => {
wrapper.setState({ message: FAKE_MESSAGE });
assert.isFalse(wrapper.find(".snippets-preview-banner").exists());
});
it("should render a SimpleSnippet in the footer portal", () => {
wrapper.setState({ message: FAKE_MESSAGE });
assert.isTrue(footerPortal.childElementCount > 0);
assert.equal(headerPortal.childElementCount, 0);
});
it("should not render a SimpleBelowSearchSnippet in a portal", () => {
wrapper.setState({ message: FAKE_BELOW_SEARCH_SNIPPET });
assert.equal(headerPortal.childElementCount, 0);
assert.equal(footerPortal.childElementCount, 0);
});
it("should render a trailhead message in the header portal", async () => {
// wrapper = shallow( );
const message = (await OnboardingMessageProvider.getUntranslatedMessages()).find(
msg => msg.template === "trailhead"
);
wrapper.setState({ message });
assert.isTrue(headerPortal.childElementCount > 0);
assert.equal(footerPortal.childElementCount, 0);
});
it("should dispatch an event to select the correct theme", () => {
const stub = sandbox.stub(window, "dispatchEvent");
sandbox
.stub(ASRouterUtils, "getPreviewEndpoint")
.returns({ theme: "dark" });
wrapper = mount( );
assert.calledOnce(stub);
assert.property(stub.firstCall.args[0].detail.data, "ntp_background");
assert.property(stub.firstCall.args[0].detail.data, "ntp_text");
assert.property(stub.firstCall.args[0].detail.data, "sidebar");
assert.property(stub.firstCall.args[0].detail.data, "sidebar_text");
});
describe("snippets", () => {
it("should send correct event and source when snippet is blocked", () => {
wrapper.setState({ message: FAKE_MESSAGE });
wrapper.find(".blockButton").simulate("click");
assert.propertyVal(
ASRouterUtils.sendTelemetry.firstCall.args[0],
"event",
"BLOCK"
);
assert.propertyVal(
ASRouterUtils.sendTelemetry.firstCall.args[0],
"source",
"NEWTAB_FOOTER_BAR"
);
});
it("should not send telemetry when a preview snippet is blocked", () => {
wrapper.setState({ message: { ...FAKE_MESSAGE, provider: "preview" } });
wrapper.find(".blockButton").simulate("click");
assert.notCalled(ASRouterUtils.sendTelemetry);
});
});
describe("trailhead", () => {
it("should render trailhead if a trailhead message is received", async () => {
const message = (await OnboardingMessageProvider.getUntranslatedMessages()).find(
msg => msg.template === "trailhead"
);
wrapper.setState({ message });
assert.lengthOf(wrapper.find(Trailhead), 1);
});
it("should render Triplets if a trailhead message with bundle is received", async () => {
const FAKE_TRIPLETS_BUNDLE = [
{
id: "test",
content: {
title: { string_id: "foo" },
text: { string_id: "text1" },
icon: "icon",
primary_button: {
label: { string_id: "button1" },
action: {
type: "OPEN_URL",
data: { args: "https://example.com/" },
},
},
},
},
];
const message = (await OnboardingMessageProvider.getUntranslatedMessages()).find(
msg => msg.template === "trailhead"
);
wrapper.setState({
message: { ...message, bundle: FAKE_TRIPLETS_BUNDLE },
});
assert.lengthOf(wrapper.find(Triplets), 1);
});
it("should send NEW_TAB_MESSAGE_REQUEST if a bundle card id is blocked or cleared", async () => {
sandbox.stub(ASRouterUtils, "sendMessage");
const FAKE_TRIPLETS_BUNDLE_1 = [
{
id: "CARD_1",
content: {
title: { string_id: "onboarding-private-browsing-title" },
text: { string_id: "onboarding-private-browsing-text" },
icon: "icon",
primary_button: {
label: { string_id: "onboarding-button-label-get-started" },
action: {
type: "OPEN_URL",
data: { args: "https://example.com/" },
},
},
},
},
];
const message = (await OnboardingMessageProvider.getUntranslatedMessages()).find(
msg => msg.id === "TRAILHEAD_1"
);
wrapper.setState({
message: { ...message, bundle: FAKE_TRIPLETS_BUNDLE_1 },
});
wrapper.instance().clearMessage("CARD_1");
assert.calledOnce(ASRouterUtils.sendMessage);
assert.calledWithExactly(ASRouterUtils.sendMessage, {
type: "NEWTAB_MESSAGE_REQUEST",
data: { endpoint: undefined },
});
});
});
describe("impressions", () => {
function simulateVisibilityChange(value) {
fakeDocument.visibilityState = value;
}
it("should call blockById after CTA link is clicked", () => {
wrapper.setState({ message: FAKE_MESSAGE });
sandbox.stub(ASRouterUtils, "blockById");
wrapper.instance().sendClick({ target: { dataset: { metric: "" } } });
assert.calledOnce(ASRouterUtils.blockById);
assert.calledWithExactly(ASRouterUtils.blockById, FAKE_MESSAGE.id);
});
it("should executeAction if defined on the anchor", () => {
wrapper.setState({ message: FAKE_MESSAGE });
sandbox.spy(ASRouterUtils, "executeAction");
wrapper.instance().sendClick({
target: { dataset: { action: "OPEN_MENU", args: "appMenu" } },
});
assert.calledOnce(ASRouterUtils.executeAction);
assert.calledWithExactly(ASRouterUtils.executeAction, {
type: "OPEN_MENU",
data: { args: "appMenu" },
});
});
it("should not call blockById if do_not_autoblock is true", () => {
wrapper.setState({
message: {
...FAKE_MESSAGE,
...{ content: { ...FAKE_MESSAGE.content, do_not_autoblock: true } },
},
});
sandbox.stub(ASRouterUtils, "blockById");
wrapper.instance().sendClick({ target: { dataset: { metric: "" } } });
assert.notCalled(ASRouterUtils.blockById);
});
it("should not send an impression if no message exists", () => {
simulateVisibilityChange("visible");
assert.notCalled(ASRouterUtils.sendTelemetry);
});
it("should not send an impression if the page is not visible", () => {
simulateVisibilityChange("hidden");
wrapper.setState({ message: FAKE_MESSAGE });
assert.notCalled(ASRouterUtils.sendTelemetry);
});
it("should not send an impression for a preview message", () => {
wrapper.setState({ message: { ...FAKE_MESSAGE, provider: "preview" } });
assert.notCalled(ASRouterUtils.sendTelemetry);
simulateVisibilityChange("visible");
assert.notCalled(ASRouterUtils.sendTelemetry);
});
it("should send an impression ping when there is a message and the page becomes visible", () => {
wrapper.setState({ message: FAKE_MESSAGE });
assert.notCalled(ASRouterUtils.sendTelemetry);
simulateVisibilityChange("visible");
assert.calledOnce(ASRouterUtils.sendTelemetry);
});
it("should send the correct impression source", () => {
wrapper.setState({ message: FAKE_MESSAGE });
simulateVisibilityChange("visible");
assert.calledOnce(ASRouterUtils.sendTelemetry);
assert.propertyVal(
ASRouterUtils.sendTelemetry.firstCall.args[0],
"event",
"IMPRESSION"
);
assert.propertyVal(
ASRouterUtils.sendTelemetry.firstCall.args[0],
"source",
"NEWTAB_FOOTER_BAR"
);
});
it("should send an impression ping when the page is visible and a message gets loaded", () => {
simulateVisibilityChange("visible");
wrapper.setState({ message: {} });
assert.notCalled(ASRouterUtils.sendTelemetry);
wrapper.setState({ message: FAKE_MESSAGE });
assert.calledOnce(ASRouterUtils.sendTelemetry);
});
it("should send another impression ping if the message id changes", () => {
simulateVisibilityChange("visible");
wrapper.setState({ message: FAKE_MESSAGE });
assert.calledOnce(ASRouterUtils.sendTelemetry);
wrapper.setState({ message: FAKE_LOCAL_MESSAGES[1] });
assert.calledTwice(ASRouterUtils.sendTelemetry);
});
it("should not send another impression ping if the message id has not changed", () => {
simulateVisibilityChange("visible");
wrapper.setState({ message: FAKE_MESSAGE });
assert.calledOnce(ASRouterUtils.sendTelemetry);
wrapper.setState({ somethingElse: 123 });
assert.calledOnce(ASRouterUtils.sendTelemetry);
});
it("should not send another impression ping if the message is cleared", () => {
simulateVisibilityChange("visible");
wrapper.setState({ message: FAKE_MESSAGE });
assert.calledOnce(ASRouterUtils.sendTelemetry);
wrapper.setState({ message: {} });
assert.calledOnce(ASRouterUtils.sendTelemetry);
});
it("should call .sendTelemetry with the right message data", () => {
simulateVisibilityChange("visible");
wrapper.setState({ message: FAKE_MESSAGE });
assert.calledOnce(ASRouterUtils.sendTelemetry);
const [payload] = ASRouterUtils.sendTelemetry.firstCall.args;
assert.propertyVal(payload, "message_id", FAKE_MESSAGE.id);
assert.propertyVal(payload, "event", "IMPRESSION");
assert.propertyVal(
payload,
"action",
`${FAKE_MESSAGE.provider}_user_event`
);
assert.propertyVal(payload, "source", "NEWTAB_FOOTER_BAR");
});
});
describe(".fetchFlowParams", () => {
let dispatchStub;
const assertCalledWithURL = url =>
assert.calledWith(fetchStub, new URL(url).toString(), {
credentials: "omit",
});
beforeEach(() => {
dispatchStub = sandbox.stub();
wrapper = mount(
);
});
it("should use the base url returned from the endpoint pref", async () => {
wrapper = mount(
);
await wrapper.instance().fetchFlowParams();
assertCalledWithURL("https://foo.com/metrics-flow");
});
it("should add given search params to the URL", async () => {
const params = { foo: "1", bar: "2" };
await wrapper.instance().fetchFlowParams(params);
assertCalledWithURL(
"https://accounts.firefox.com/metrics-flow?foo=1&bar=2"
);
});
it("should return flowId, flowBeginTime, deviceId on a 200 response", async () => {
const flowInfo = { flowId: "foo", flowBeginTime: 123, deviceId: "bar" };
fetchStub.withArgs("https://accounts.firefox.com/metrics-flow").resolves({
ok: true,
status: 200,
json: () => Promise.resolve(flowInfo),
});
const result = await wrapper.instance().fetchFlowParams();
assert.deepEqual(result, flowInfo);
});
it("should return {} and dispatch a TELEMETRY_UNDESIRED_EVENT on a non-200 response", async () => {
fetchStub.withArgs("https://accounts.firefox.com/metrics-flow").resolves({
ok: false,
status: 400,
statusText: "Client error",
url: "https://accounts.firefox.com/metrics-flow",
});
const result = await wrapper.instance().fetchFlowParams();
assert.deepEqual(result, {});
assert.calledWith(
dispatchStub,
ac.OnlyToMain({
type: "TELEMETRY_UNDESIRED_EVENT",
data: {
event: "FXA_METRICS_FETCH_ERROR",
value: 400,
},
})
);
});
it("should return {} and dispatch a TELEMETRY_UNDESIRED_EVENT on a parsing erorr", async () => {
fetchStub.withArgs("https://accounts.firefox.com/metrics-flow").resolves({
ok: false,
status: 200,
// No json to parse, throws an error
});
const result = await wrapper.instance().fetchFlowParams();
assert.deepEqual(result, {});
assert.calledWith(
dispatchStub,
ac.OnlyToMain({
type: "TELEMETRY_UNDESIRED_EVENT",
data: { event: "FXA_METRICS_ERROR" },
})
);
});
describe(".onUserAction", () => {
it("if the action.type is ENABLE_FIREFOX_MONITOR, it should generate the right monitor URL given some flowParams", async () => {
const flowInfo = { flowId: "foo", flowBeginTime: 123, deviceId: "bar" };
fetchStub
.withArgs(
"https://accounts.firefox.com/metrics-flow?utm_term=avocado"
)
.resolves({
ok: true,
status: 200,
json: () => Promise.resolve(flowInfo),
});
sandbox.spy(ASRouterUtils, "executeAction");
const msg = {
type: "ENABLE_FIREFOX_MONITOR",
data: {
args: {
url: "https://monitor.firefox.com?foo=bar",
flowRequestParams: {
utm_term: "avocado",
},
},
},
};
await wrapper.instance().onUserAction(msg);
assertCalledWithURL(
"https://accounts.firefox.com/metrics-flow?utm_term=avocado"
);
assert.calledWith(ASRouterUtils.executeAction, {
type: "OPEN_URL",
data: {
args: new URL(
"https://monitor.firefox.com?foo=bar&deviceId=bar&flowId=foo&flowBeginTime=123"
).toString(),
},
});
});
it("if the action.type is not ENABLE_FIREFOX_MONITOR, it should just call ASRouterUtils.executeAction", async () => {
const msg = {
type: "FOO",
data: {
args: "bar",
},
};
sandbox.spy(ASRouterUtils, "executeAction");
await wrapper.instance().onUserAction(msg);
assert.calledWith(ASRouterUtils.executeAction, msg);
});
});
});
});
================================================
FILE: test/unit/asrouter/compatibility-reference/fx57-compat.test.js
================================================
import EOYSnippetSchema from "content-src/asrouter/templates/EOYSnippet/EOYSnippet.schema.json";
import { expectedValues } from "./snippets-fx57";
import SimpleSnippetSchema from "content-src/asrouter/templates/SimpleSnippet/SimpleSnippet.schema.json";
import SubmitFormSchema from "content-src/asrouter/templates/SubmitFormSnippet/SubmitFormSnippet.schema.json";
export const SnippetsSchemas = {
eoy_snippet: EOYSnippetSchema,
simple_snippet: SimpleSnippetSchema,
newsletter_snippet: SubmitFormSchema,
fxa_signup_snippet: SubmitFormSchema,
send_to_device_snippet: SubmitFormSchema,
};
describe("Firefox 57 compatibility test", () => {
Object.keys(expectedValues).forEach(template => {
describe(template, () => {
const schema = SnippetsSchemas[template];
it(`should have a schema for ${template}`, () => {
assert.ok(schema);
});
it(`should validate with the schema for ${template}`, () => {
assert.jsonSchema(expectedValues[template], schema);
});
});
});
});
================================================
FILE: test/unit/asrouter/compatibility-reference/snippets-fx57.js
================================================
/**
* IMPORTANT NOTE!!!
*
* Please DO NOT introduce breaking changes file without contacting snippets endpoint engineers
* and changing the schema version to reflect a breaking change.
*
*/
const DATA_URI_IMAGE =
"data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
export const expectedValues = {
// Simple Snippet (https://github.com/mozmeao/snippets/blob/master/activity-stream/simple-snippet.html)
simple_snippet: {
icon: DATA_URI_IMAGE,
button_label: "Click me",
button_url: "https://mozilla.org",
button_background_color: "#FF0000",
button_color: "#FFFFFF",
text: "Hello world",
title: "Hi!",
title_icon: DATA_URI_IMAGE,
tall: true,
},
// FXA Snippet (https://github.com/mozmeao/snippets/blob/master/activity-stream/fxa.html)
fxa_signup_snippet: {
scene1_icon: DATA_URI_IMAGE,
scene1_button_label: "Click me",
scene1_button_background_color: "#FF0000",
scene1_button_color: "#FFFFFF",
scene1_text: "Hello world ",
scene1_title: "Hi!",
scene1_title_icon: DATA_URI_IMAGE,
scene2_text: "Second scene",
scene2_title: "Second scene title",
scene2_email_placeholder_text: "Email here",
scene2_button_label: "Sign Me Up",
scene2_dismiss_button_text: "Dismiss",
utm_campaign: "snippets123",
utm_term: "123term",
},
// Send To Device Snippet (https://github.com/mozmeao/snippets/blob/master/activity-stream/send-to-device.html)
send_to_device_snippet: {
include_sms: true,
locale: "de",
country: "DE",
message_id_sms: "foo",
message_id_email: "foo",
scene1_button_background_color: "#FF0000",
scene1_button_color: "#FFFFFF",
scene1_button_label: "Click me",
scene1_icon: DATA_URI_IMAGE,
scene1_text: "Hello world",
scene1_title: "Hi!",
scene1_title_icon: DATA_URI_IMAGE,
scene2_button_label: "Sign Me Up",
scene2_disclaimer_html: "Hello world ",
scene2_dismiss_button_text: "Dismiss",
scene2_icon: DATA_URI_IMAGE,
scene2_input_placeholder: "Email here",
scene2_text: "Second scene",
scene2_title: "Second scene title",
error_text: "error",
success_text: "all good",
success_title: "Ok!",
},
// Newsletter Snippet (https://github.com/mozmeao/snippets/blob/master/activity-stream/newsletter-subscribe.html)
newsletter_snippet: {
scene1_icon: DATA_URI_IMAGE,
scene1_button_label: "Click me",
scene1_button_background_color: "#FF0000",
scene1_button_color: "#FFFFFF",
scene1_text: "Hello world",
scene1_title: "Hi!",
scene1_title_icon: DATA_URI_IMAGE,
scene2_text: "Second scene",
scene2_title: "Second scene title",
scene2_newsletter: "foo",
scene2_email_placeholder_text: "Email here",
scene2_button_label: "Sign Me Up",
scene2_privacy_html: "Hello world ",
scene2_dismiss_button_text: "Dismiss",
locale: "de",
error_text: "error",
success_text: "all good",
},
// EOY Snippet (https://github.com/mozmeao/snippets/blob/master/activity-stream/mofo-eoy-2017.html)
eoy_snippet: {
block_button_text: "Block",
donation_form_url: "https://donate.mozilla.org/",
text:
"Big corporations want to restrict how we access the web. Fake news is making it harder for us to find the truth. Online bullies are silencing inspired voices. The not-for-profit Mozilla Foundation fights for a healthy internet with programs like our Tech Policy Fellowships and Internet Health Report; will you donate today?",
icon: DATA_URI_IMAGE,
button_label: "Donate",
monthly_checkbox_label_text: "Make my donation monthly",
button_background_color: "#0060DF",
button_color: "#FFFFFF",
background_color: "#FFFFFF",
text_color: "#000000",
highlight_color: "#FFE900",
locale: "en-US",
currency_code: "usd",
donation_amount_first: 50,
donation_amount_second: 25,
donation_amount_third: 10,
donation_amount_fourth: 3,
selected_button: "donation_amount_second",
test: "bold",
},
};
================================================
FILE: test/unit/asrouter/constants.js
================================================
export const CHILD_TO_PARENT_MESSAGE_NAME = "ASRouter:child-to-parent";
export const PARENT_TO_CHILD_MESSAGE_NAME = "ASRouter:parent-to-child";
export const FAKE_LOCAL_MESSAGES = [
{
id: "foo",
provider: "snippets",
template: "simple_snippet",
content: { title: "Foo", body: "Foo123" },
},
{
id: "foo1",
template: "simple_snippet",
provider: "snippets",
bundled: 2,
order: 1,
content: { title: "Foo1", body: "Foo123-1" },
},
{
id: "foo2",
template: "simple_snippet",
provider: "snippets",
bundled: 2,
order: 2,
content: { title: "Foo2", body: "Foo123-2" },
},
{
id: "bar",
template: "fancy_template",
content: { title: "Foo", body: "Foo123" },
},
{ id: "baz", content: { title: "Foo", body: "Foo123" } },
{
id: "newsletter",
provider: "snippets",
template: "newsletter_snippet",
content: { title: "Foo", body: "Foo123" },
},
{
id: "fxa",
provider: "snippets",
template: "fxa_signup_snippet",
content: { title: "Foo", body: "Foo123" },
},
{
id: "belowsearch",
provider: "snippets",
template: "simple_below_search_snippet",
content: { text: "Foo" },
},
];
export const FAKE_LOCAL_PROVIDER = {
id: "onboarding",
type: "local",
localProvider: "FAKE_LOCAL_PROVIDER",
enabled: true,
cohort: 0,
};
export const FAKE_LOCAL_PROVIDERS = {
FAKE_LOCAL_PROVIDER: { getMessages: () => FAKE_LOCAL_MESSAGES },
};
export const FAKE_REMOTE_MESSAGES = [
{
id: "qux",
template: "simple_snippet",
content: { title: "Qux", body: "hello world" },
},
];
export const FAKE_REMOTE_PROVIDER = {
id: "remotey",
type: "remote",
url: "http://fake.com/endpoint",
enabled: true,
};
export const FAKE_REMOTE_SETTINGS_PROVIDER = {
id: "remotey-settingsy",
type: "remote-settings",
bucket: "bucketname",
enabled: true,
};
const notificationText = new String("Fake notification text"); // eslint-disable-line
notificationText.attributes = { tooltiptext: "Fake tooltip text" };
export const FAKE_RECOMMENDATION = {
id: "fake_id",
template: "cfr_doorhanger",
content: {
category: "cfrDummy",
bucket_id: "fake_bucket_id",
notification_text: notificationText,
info_icon: {
label: "Fake Info Icon Label",
sumo_path: "a_help_path_fragment",
},
heading_text: "Fake Heading Text",
addon: {
title: "Fake Addon Title",
author: "Fake Addon Author",
icon: "a_path_to_some_icon",
rating: 4.2,
users: 1234,
amo_url: "a_path_to_amo",
},
descriptionDetails: {
steps: [{ string_id: "cfr-features-step1" }],
},
text: "Here is the recommendation text body",
buttons: {
primary: {
label: { string_id: "primary_button_id" },
action: {
id: "primary_action",
data: {},
},
},
secondary: [
{
label: { string_id: "secondary_button_id" },
action: { id: "secondary_action" },
},
{
label: { string_id: "secondary_button_id_2" },
},
{
label: { string_id: "secondary_button_id_3" },
action: { id: "secondary_action" },
},
],
},
},
};
// Stubs methods on RemotePageManager
export class FakeRemotePageManager {
constructor() {
this.messagePorts = [];
this.addMessageListener = sinon.stub();
this.sendAsyncMessage = sinon.stub();
this.removeMessageListener = sinon.stub();
this.browser = {
ownerGlobal: {
openTrustedLinkIn: sinon.stub(),
openLinkIn: sinon.stub(),
OpenBrowserWindow: sinon.stub(),
openPreferences: sinon.stub(),
gBrowser: {
pinTab: sinon.stub(),
selectedTab: {},
},
ConfirmationHint: {
show: sinon.stub(),
},
gProtectionsHandler: {
showProtectionsPopup: sinon.stub(),
openProtections: sinon.stub(),
},
},
};
this.portID = "6000:2";
}
}
================================================
FILE: test/unit/asrouter/schemas/panel/cfr-fxa-bookmark.schema.test.js
================================================
import schema from "content-src/asrouter/schemas/panel/cfr-fxa-bookmark.schema.json";
const DEFAULT_CONTENT = {
title: "Sync your bookmarks everywhere",
text: "Great find! Now don't be left without this bookmark.",
cta: "Sync bookmarks now",
info_icon: {
tooltiptext: "Learn more",
},
};
const L10N_CONTENT = {
title: { string_id: "cfr-bookmark-title" },
text: { string_id: "cfr-bookmark-body" },
cta: { string_id: "cfr-bookmark-link-text" },
info_icon: {
tooltiptext: { string_id: "cfr-bookmark-tooltip-text" },
},
};
describe("CFR FxA Message Schema", () => {
it("should validate DEFAULT_CONTENT", () => {
assert.jsonSchema(DEFAULT_CONTENT, schema);
});
it("should validate L10N_CONTENT", () => {
assert.jsonSchema(L10N_CONTENT, schema);
});
});
================================================
FILE: test/unit/asrouter/template-utils.test.js
================================================
import { safeURI } from "content-src/asrouter/template-utils";
describe("safeURI", () => {
let warnStub;
beforeEach(() => {
warnStub = sinon.stub(console, "warn");
});
afterEach(() => {
warnStub.restore();
});
it("should allow http: URIs", () => {
assert.equal(safeURI("http://foo.com"), "http://foo.com");
});
it("should allow https: URIs", () => {
assert.equal(safeURI("https://foo.com"), "https://foo.com");
});
it("should allow data URIs", () => {
assert.equal(
safeURI("data:image/png;base64,iVBO"),
"data:image/png;base64,iVBO"
);
});
it("should not allow javascript: URIs", () => {
assert.equal(safeURI("javascript:foo()"), ""); // eslint-disable-line no-script-url
assert.calledOnce(warnStub);
});
it("should not warn if the URL is falsey ", () => {
assert.equal(safeURI(), "");
assert.notCalled(warnStub);
});
});
================================================
FILE: test/unit/asrouter/templates/EOYSnippet.test.jsx
================================================
import { EOYSnippet } from "content-src/asrouter/templates/EOYSnippet/EOYSnippet";
import { GlobalOverrider } from "test/unit/utils";
import { mount } from "enzyme";
import React from "react";
import schema from "content-src/asrouter/templates/EOYSnippet/EOYSnippet.schema.json";
const DEFAULT_CONTENT = {
text: "foo",
donation_amount_first: 50,
donation_amount_second: 25,
donation_amount_third: 10,
donation_amount_fourth: 5,
donation_form_url: "https://submit.form",
button_label: "Donate",
currency_code: "usd",
};
describe("EOYSnippet", () => {
let sandbox;
let wrapper;
/**
* mountAndCheckProps - Mounts a EOYSnippet with DEFAULT_CONTENT extended with any props
* passed in the content param and validates props against the schema.
* @param {obj} content Object containing custom message content (e.g. {text, icon, title})
* @returns enzyme wrapper for EOYSnippet
*/
function mountAndCheckProps(content = {}, provider = "test-provider") {
const props = {
content: Object.assign({}, DEFAULT_CONTENT, content),
provider,
onAction: sandbox.stub(),
onBlock: sandbox.stub(),
};
const comp = mount( );
// Check schema with the final props the component receives (including defaults)
assert.jsonSchema(comp.children().get(0).props.content, schema);
return comp;
}
beforeEach(() => {
sandbox = sinon.createSandbox();
wrapper = mountAndCheckProps();
});
afterEach(() => {
sandbox.restore();
});
it("should have the correct defaults", () => {
wrapper = mountAndCheckProps();
// SendToDeviceSnippet is a wrapper around SubmitFormSnippet
const { props } = wrapper.children().get(0);
const defaultProperties = Object.keys(schema.properties).filter(
prop => schema.properties[prop].default
);
assert.lengthOf(defaultProperties, 4);
defaultProperties.forEach(prop =>
assert.propertyVal(props.content, prop, schema.properties[prop].default)
);
});
it("should render 4 donation options", () => {
assert.lengthOf(wrapper.find("input[type='radio']"), 4);
});
it("should select the second donation option", () => {
wrapper = mountAndCheckProps({ selected_button: "donation_amount_second" });
assert.propertyVal(
wrapper.find("input[type='radio']").get(1).props,
"defaultChecked",
true
);
});
it("should set frequency value to monthly", () => {
const form = wrapper.find("form").instance();
assert.equal(form.querySelector("[name='frequency']").value, "single");
form.querySelector("#monthly-checkbox").checked = true;
wrapper.find("form").simulate("submit");
assert.equal(form.querySelector("[name='frequency']").value, "monthly");
});
it("should block after submitting the form", () => {
const onBlockStub = sandbox.stub();
wrapper.setProps({ onBlock: onBlockStub });
wrapper.find("form").simulate("submit");
assert.calledOnce(onBlockStub);
});
it("should not block if do_not_autoblock is true", () => {
const onBlockStub = sandbox.stub();
wrapper = mountAndCheckProps({ do_not_autoblock: true });
wrapper.setProps({ onBlock: onBlockStub });
wrapper.find("form").simulate("submit");
assert.notCalled(onBlockStub);
});
it("it should preserve URL GET params as hidden inputs", () => {
wrapper = mountAndCheckProps({
donation_form_url:
"https://donate.mozilla.org/pl/?utm_source=desktop-snippet&utm_medium=snippet&utm_campaign=donate&utm_term=7556",
});
const hiddenInputs = wrapper.find("input[type='hidden']");
assert.propertyVal(
hiddenInputs.find("[name='utm_source']").props(),
"value",
"desktop-snippet"
);
assert.propertyVal(
hiddenInputs.find("[name='amp;utm_medium']").props(),
"value",
"snippet"
);
assert.propertyVal(
hiddenInputs.find("[name='amp;utm_campaign']").props(),
"value",
"donate"
);
assert.propertyVal(
hiddenInputs.find("[name='amp;utm_term']").props(),
"value",
"7556"
);
});
describe("locale", () => {
let stub;
let globals;
beforeEach(() => {
globals = new GlobalOverrider();
stub = sandbox.stub().returns({ format: () => {} });
globals = new GlobalOverrider();
globals.set({ Intl: { NumberFormat: stub } });
});
afterEach(() => {
globals.restore();
});
it("should use content.locale for Intl", () => {
// triggers component rendering and calls the function we're testing
wrapper.setProps({
content: {
locale: "locale-foo",
donation_form_url: DEFAULT_CONTENT.donation_form_url,
},
});
assert.calledOnce(stub);
assert.calledWithExactly(stub, "locale-foo", sinon.match.object);
});
it("should use navigator.language as locale fallback", () => {
// triggers component rendering and calls the function we're testing
wrapper.setProps({
content: {
locale: null,
donation_form_url: DEFAULT_CONTENT.donation_form_url,
},
});
assert.calledOnce(stub);
assert.calledWithExactly(stub, navigator.language, sinon.match.object);
});
});
});
================================================
FILE: test/unit/asrouter/templates/ExtensionDoorhanger.test.jsx
================================================
import { CFRMessageProvider } from "lib/CFRMessageProvider.jsm";
import schema from "content-src/asrouter/templates/CFR/templates/ExtensionDoorhanger.schema.json";
const DEFAULT_CONTENT = {
layout: "addon_recommendation",
category: "dummyCategory",
bucket_id: "some_bucket_id",
notification_text: "Recommendation",
heading_text: "Recommended Extension",
info_icon: {
label: { attributes: { tooltiptext: "Why am I seeing this" } },
sumo_path: "extensionrecommendations",
},
addon: {
id: "1234",
title: "Addon name",
icon: "https://mozilla.org/icon",
author: "Author name",
amo_url: "https://example.com",
},
text: "Description of addon",
buttons: {
primary: {
label: {
value: "Add Now",
attributes: { accesskey: "A" },
},
action: {
type: "INSTALL_ADDON_FROM_URL",
data: { url: null },
},
},
secondary: {
label: {
value: "Not Now",
attributes: { accesskey: "N" },
},
action: { type: "CANCEL" },
},
},
};
const L10N_CONTENT = {
layout: "addon_recommendation",
category: "dummyL10NCategory",
bucket_id: "some_bucket_id",
notification_text: { string_id: "notification_text_id" },
heading_text: { string_id: "heading_text_id" },
info_icon: {
label: { string_id: "why_seeing_this" },
sumo_path: "extensionrecommendations",
},
addon: {
id: "1234",
title: "Addon name",
icon: "https://mozilla.org/icon",
author: "Author name",
amo_url: "https://example.com",
},
text: { string_id: "text_id" },
buttons: {
primary: {
label: { string_id: "btn_ok_id" },
action: {
type: "INSTALL_ADDON_FROM_URL",
data: { url: null },
},
},
secondary: {
label: { string_id: "btn_cancel_id" },
action: { type: "CANCEL" },
},
},
};
describe("ExtensionDoorhanger", () => {
it("should validate DEFAULT_CONTENT", () => {
assert.jsonSchema(DEFAULT_CONTENT, schema);
});
it("should validate L10N_CONTENT", () => {
assert.jsonSchema(L10N_CONTENT, schema);
});
it("should validate all messages from CFRMessageProvider", () => {
const messages = CFRMessageProvider.getMessages();
messages.forEach(msg => assert.jsonSchema(msg.content, schema));
});
});
================================================
FILE: test/unit/asrouter/templates/FXASignupSnippet.test.jsx
================================================
import { FXASignupSnippet } from "content-src/asrouter/templates/FXASignupSnippet/FXASignupSnippet";
import { mount } from "enzyme";
import React from "react";
import schema from "content-src/asrouter/templates/FXASignupSnippet/FXASignupSnippet.schema.json";
import { SnippetsTestMessageProvider } from "lib/SnippetsTestMessageProvider.jsm";
const DEFAULT_CONTENT = SnippetsTestMessageProvider.getMessages().find(
msg => msg.template === "fxa_signup_snippet"
).content;
describe("FXASignupSnippet", () => {
let sandbox;
function mountAndCheckProps(content = {}) {
const props = {
id: "foo123",
content: Object.assign(
{ utm_campaign: "foo", utm_term: "bar" },
DEFAULT_CONTENT,
content
),
onBlock() {},
onDismiss: sandbox.stub(),
sendUserActionTelemetry: sandbox.stub(),
onAction: sandbox.stub(),
};
const comp = mount( );
// Check schema with the final props the component receives (including defaults)
assert.jsonSchema(comp.children().get(0).props.content, schema);
return comp;
}
beforeEach(() => {
sandbox = sinon.createSandbox();
});
afterEach(() => {
sandbox.restore();
});
it("should have the correct defaults", () => {
const defaults = {
id: "foo123",
onBlock() {},
content: {},
onDismiss: sandbox.stub(),
sendUserActionTelemetry: sandbox.stub(),
onAction: sandbox.stub(),
};
const wrapper = mount( );
// FXASignupSnippet is a wrapper around SubmitFormSnippet
const { props } = wrapper.children().get(0);
const defaultProperties = Object.keys(schema.properties).filter(
prop => schema.properties[prop].default
);
assert.lengthOf(defaultProperties, 5);
defaultProperties.forEach(prop =>
assert.propertyVal(props.content, prop, schema.properties[prop].default)
);
const defaultHiddenProperties = Object.keys(
schema.properties.hidden_inputs.properties
).filter(prop => schema.properties.hidden_inputs.properties[prop].default);
assert.lengthOf(defaultHiddenProperties, 0);
});
it("should have a form_action", () => {
const wrapper = mountAndCheckProps();
assert.propertyVal(
wrapper.children().get(0).props,
"form_action",
"https://accounts.firefox.com/"
);
});
it("should navigate to scene2", () => {
const wrapper = mountAndCheckProps({});
wrapper.find(".ASRouterButton").simulate("click");
assert.lengthOf(wrapper.find(".mainInput"), 1);
});
});
================================================
FILE: test/unit/asrouter/templates/FirstRun.test.jsx
================================================
import {
FirstRun,
FLUENT_FILES,
} from "content-src/asrouter/templates/FirstRun/FirstRun";
import { Interrupt } from "content-src/asrouter/templates/FirstRun/Interrupt";
import { Triplets } from "content-src/asrouter/templates/FirstRun/Triplets";
import { OnboardingMessageProvider } from "lib/OnboardingMessageProvider.jsm";
import { mount } from "enzyme";
import React from "react";
const FAKE_TRIPLETS_BUNDLE_1 = [
{
id: "CARD_1",
content: {
title: { string_id: "onboarding-private-browsing-title" },
text: { string_id: "onboarding-private-browsing-text" },
icon: "icon",
primary_button: {
label: { string_id: "onboarding-button-label-get-started" },
action: {
type: "OPEN_URL",
data: { args: "https://example.com/" },
},
},
},
},
];
const FAKE_TRIPLETS_BUNDLE_2 = [
{
id: "CARD_2",
content: {
title: { string_id: "onboarding-data-sync-title" },
text: { string_id: "onboarding-data-sync-text2" },
icon: "icon",
primary_button: {
label: { string_id: "onboarding-data-sync-button2" },
action: {
type: "OPEN_URL",
data: { args: "https://foo.com/" },
},
},
},
},
];
const FAKE_FLOW_PARAMS = {
deviceId: "foo",
flowId: "abc1",
flowBeginTime: 1234,
};
async function getTestMessage(id, requestNewBundle) {
const message = (await OnboardingMessageProvider.getUntranslatedMessages()).find(
msg => msg.id === id
);
// Simulate dynamic triplets by returning a different bundle
if (requestNewBundle) {
return { ...message, bundle: FAKE_TRIPLETS_BUNDLE_2 };
}
return { ...message, bundle: FAKE_TRIPLETS_BUNDLE_1 };
}
describe("", () => {
let wrapper;
let message;
let fakeDoc;
let sandbox;
let clock;
let onBlockByIdStub;
async function setup() {
sandbox = sinon.createSandbox();
clock = sandbox.useFakeTimers();
message = await getTestMessage("TRAILHEAD_1");
fakeDoc = {
body: document.createElement("body"),
head: document.createElement("head"),
createElement: type => document.createElement(type),
getElementById: () => document.createElement("div"),
activeElement: document.createElement("div"),
};
onBlockByIdStub = sandbox.stub();
sandbox
.stub(global, "fetch")
.withArgs("http://fake.com/endpoint")
.resolves({
ok: true,
status: 200,
json: () => Promise.resolve(FAKE_FLOW_PARAMS),
});
wrapper = mount(
{}}
sendUserActionTelemetry={() => {}}
onBlockById={onBlockByIdStub}
/>
);
}
beforeEach(setup);
afterEach(() => {
sandbox.restore();
});
it("should render", () => {
assert.ok(wrapper);
});
describe("with both interrupt and triplets", () => {
it("should render interrupt and triplets", () => {
assert.lengthOf(wrapper.find(Interrupt), 1, "");
assert.lengthOf(wrapper.find(Triplets), 1, "");
});
it("should show the card panel and hide the content on the Triplets", () => {
// This is so the container shows up in the background but we can fade in the content when intterupt is closed.
const tripletsProps = wrapper.find(Triplets).props();
assert.propertyVal(tripletsProps, "showCardPanel", true);
assert.propertyVal(tripletsProps, "showContent", false);
});
it("should set the UTM term to trailhead-join (for the traihead-join message)", () => {
const iProps = wrapper.find(Interrupt).props();
const tProps = wrapper.find(Triplets).props();
assert.propertyVal(iProps, "UTMTerm", "trailhead-join");
assert.propertyVal(tProps, "UTMTerm", "trailhead-join-card");
});
});
describe("with an interrupt but no triplets", () => {
beforeEach(() => {
message.bundle = []; // Empty triplets
wrapper = mount( );
});
it("should render interrupt but no triplets", () => {
assert.lengthOf(wrapper.find(Interrupt), 1, "");
assert.lengthOf(wrapper.find(Triplets), 0, "");
});
});
describe("with triplets but no interrupt", () => {
it("should render interrupt but no triplets", () => {
delete message.content; // Empty interrupt
wrapper = mount( );
assert.lengthOf(wrapper.find(Interrupt), 0, "");
assert.lengthOf(wrapper.find(Triplets), 1, "");
});
});
describe("with no triplets or interrupt", () => {
it("should render empty", () => {
message = { type: "FOO_123" };
wrapper = mount( );
assert.isTrue(wrapper.isEmptyRender());
});
});
it("should pass along executeAction appropriately", () => {
const stub = sandbox.stub();
wrapper = mount(
);
assert.propertyVal(wrapper.find(Interrupt).props(), "executeAction", stub);
assert.propertyVal(wrapper.find(Triplets).props(), "onAction", stub);
});
it("should load flow params on mount if fxaEndpoint is defined", () => {
const stub = sandbox.stub();
wrapper = mount(
{}}
fetchFlowParams={stub}
fxaEndpoint="https://foo.com"
/>
);
assert.calledOnce(stub);
});
it("should load flow params onUpdate if fxaEndpoint is not defined on mount and then later defined", () => {
const stub = sandbox.stub();
wrapper = mount(
{}}
/>
);
assert.notCalled(stub);
wrapper.setProps({ fxaEndpoint: "https://foo.com" });
assert.calledOnce(stub);
});
it("should not load flow params again onUpdate if they were already set", () => {
const stub = sandbox.stub();
wrapper = mount(
{}}
fetchFlowParams={stub}
fxaEndpoint="https://foo.com"
/>
);
wrapper.setProps({ foo: "bar" });
wrapper.setProps({ foo: "baz" });
assert.calledOnce(stub);
});
it("should load fluent files on mount", () => {
assert.lengthOf(fakeDoc.head.querySelectorAll("link"), FLUENT_FILES.length);
});
it("should hide the interrupt and show the triplets when onNextScene is called", () => {
// Simulate calling next scene
wrapper
.find(Interrupt)
.find(".trailheadStart")
.simulate("click");
assert.lengthOf(wrapper.find(Interrupt), 0, "Interrupt hidden");
assert.isTrue(
wrapper
.find(Triplets)
.find(".trailheadCardGrid")
.hasClass("show"),
"Show triplet content"
);
});
it("should hide the interrupt when props.interruptCleared changes to true", () => {
assert.lengthOf(wrapper.find(Interrupt), 1, "Interrupt shown");
wrapper.setProps({ interruptCleared: true });
assert.lengthOf(wrapper.find(Interrupt), 0, "Interrupt hidden");
});
it("should hide triplets when closeTriplets is called and block extended triplets after 500ms", () => {
// Simulate calling next scene
wrapper
.find(Triplets)
.find(".icon-dismiss")
.simulate("click");
assert.isFalse(
wrapper
.find(Triplets)
.find(".trailheadCardGrid")
.hasClass("show"),
"Show triplet content"
);
assert.notCalled(onBlockByIdStub);
clock.tick(500);
assert.calledWith(onBlockByIdStub, "EXTENDED_TRIPLETS_1");
});
it("should update triplets card when cards in message bundle changes", async () => {
let tripletsProps = wrapper.find(Triplets).props();
assert.propertyVal(tripletsProps, "cards", FAKE_TRIPLETS_BUNDLE_1);
const messageWithNewBundle = await getTestMessage("TRAILHEAD_1", true);
wrapper.setProps({ message: messageWithNewBundle });
tripletsProps = wrapper.find(Triplets).props();
assert.propertyVal(tripletsProps, "cards", FAKE_TRIPLETS_BUNDLE_2);
});
});
================================================
FILE: test/unit/asrouter/templates/FullPageInterrupt.test.jsx
================================================
import { mount } from "enzyme";
import { OnboardingMessageProvider } from "lib/OnboardingMessageProvider.jsm";
import React from "react";
import {
FullPageInterrupt,
FxAccounts,
FxCards,
} from "content-src/asrouter/templates/FullPageInterrupt/FullPageInterrupt";
import { FxASignupForm } from "content-src/asrouter/components/FxASignupForm/FxASignupForm";
import { OnboardingCard } from "content-src/asrouter/templates/OnboardingMessage/OnboardingMessage";
const CARDS = [
{
id: "CARD_1",
content: {
title: { string_id: "onboarding-private-browsing-title" },
text: { string_id: "onboarding-private-browsing-text" },
icon: "icon",
primary_button: {
label: { string_id: "onboarding-button-label-get-started" },
action: {
type: "OPEN_URL",
data: { args: "https://example.com/" },
},
},
},
},
];
const FAKE_FLOW_PARAMS = {
deviceId: "foo",
flowId: "abc1",
flowBeginTime: 1234,
};
describe("", () => {
let wrapper;
let dummyNode;
let dispatch;
let onBlock;
let sandbox;
let onAction;
let onBlockById;
let sendTelemetryStub;
beforeEach(async () => {
sandbox = sinon.createSandbox();
dispatch = sandbox.stub();
onBlock = sandbox.stub();
onAction = sandbox.stub();
onBlockById = sandbox.stub();
sendTelemetryStub = sandbox.stub();
dummyNode = document.createElement("body");
sandbox.stub(dummyNode, "querySelector").returns(dummyNode);
const fakeDocument = {
getElementById() {
return dummyNode;
},
};
const message = (await OnboardingMessageProvider.getUntranslatedMessages()).find(
msg => msg.id === "FULL_PAGE_1"
);
wrapper = mount(
);
});
afterEach(() => {
sandbox.restore();
});
it("should trigger onBlock on removeOverlay", () => {
wrapper.instance().removeOverlay();
assert.calledOnce(onBlock);
});
it("should render Full Page interrupt with accounts and triplet cards section", () => {
assert.lengthOf(wrapper.find(FxAccounts), 1);
assert.lengthOf(wrapper.find(FxCards), 1);
});
it("should render FxASignupForm inside FxAccounts", () => {
assert.lengthOf(wrapper.find(FxASignupForm), 1);
});
it("should display learn more link on full page", () => {
assert.ok(wrapper.find("a.fullpage-left-link").exists());
});
it("should add utm_* query params to card actions and send the right ping when a card button is clicked", () => {
wrapper
.find(OnboardingCard)
.find("button.onboardingButton")
.simulate("click");
assert.calledOnce(onAction);
const url = onAction.firstCall.args[0].data.args;
assert.equal(
url,
"https://example.com/?utm_source=activity-stream&utm_campaign=firstrun&utm_medium=referral&utm_term=trailhead-full_page_d"
);
assert.calledWith(sendTelemetryStub, {
event: "CLICK_BUTTON",
message_id: CARDS[0].id,
id: "TRAILHEAD",
});
});
it("should not call blockById by default when a card button is clicked", () => {
wrapper
.find(OnboardingCard)
.find("button.onboardingButton")
.simulate("click");
assert.notCalled(onBlockById);
});
it("should call blockById when blockOnClick on message is true", () => {
CARDS[0].blockOnClick = true;
wrapper
.find(OnboardingCard)
.find("button.onboardingButton")
.simulate("click");
assert.calledOnce(onBlockById);
assert.calledWith(onBlockById, CARDS[0].id);
});
});
================================================
FILE: test/unit/asrouter/templates/FxASignupForm.test.jsx
================================================
import { actionCreators as ac, actionTypes as at } from "common/Actions.jsm";
import { FxASignupForm } from "content-src/asrouter/components/FxASignupForm/FxASignupForm";
import { mount } from "enzyme";
import React from "react";
describe("", () => {
let wrapper;
let dummyNode;
let dispatch;
let onClose;
let sandbox;
const FAKE_FLOW_PARAMS = {
deviceId: "foo",
flowId: "abc1",
flowBeginTime: 1234,
};
const FAKE_MESSAGE_CONTENT = {
title: { string_id: "onboarding-welcome-body" },
learn: {
text: { string_id: "onboarding-welcome-learn-more" },
url: "https://www.mozilla.org/firefox/accounts/",
},
form: {
title: { string_id: "onboarding-welcome-form-header" },
text: { string_id: "onboarding-join-form-body" },
email: { string_id: "onboarding-fullpage-form-email" },
button: { string_id: "onboarding-join-form-continue" },
},
};
beforeEach(async () => {
sandbox = sinon.sandbox.create();
dispatch = sandbox.stub();
onClose = sandbox.stub();
dummyNode = document.createElement("body");
sandbox.stub(dummyNode, "querySelector").returns(dummyNode);
const fakeDocument = {
getElementById() {
return dummyNode;
},
};
wrapper = mount(
);
});
afterEach(() => {
sandbox.restore();
});
it("should prevent submissions with no email", () => {
const form = wrapper.find("form");
const preventDefault = sandbox.stub();
form.simulate("submit", { preventDefault });
assert.calledOnce(preventDefault);
assert.notCalled(dispatch);
});
it("should not display signin link by default", () => {
assert.notOk(
wrapper
.find("button[data-l10n-id='onboarding-join-form-signin']")
.exists()
);
});
it("should display signin when showSignInLink is true", () => {
wrapper.setProps({ showSignInLink: true });
let signIn = wrapper.find(
"button[data-l10n-id='onboarding-join-form-signin']"
);
assert.exists(signIn);
});
it("should emit UserEvent SUBMIT_EMAIL when you submit a valid email", () => {
let form = wrapper.find("form");
assert.ok(form.exists());
form.getDOMNode().elements.email.value = "a@b.c";
form.simulate("submit");
assert.calledOnce(dispatch);
assert.isUserEventAction(dispatch.firstCall.args[0]);
assert.calledWith(
dispatch,
ac.UserEvent({
event: at.SUBMIT_EMAIL,
value: { has_flow_params: true },
})
);
});
it("should emit UserEvent SUBMIT_SIGNIN when submit with email disabled", () => {
let form = wrapper.find("form");
form.getDOMNode().elements.email.disabled = true;
form.simulate("submit");
assert.calledOnce(dispatch);
assert.isUserEventAction(dispatch.firstCall.args[0]);
assert.calledWith(
dispatch,
ac.UserEvent({
event: at.SUBMIT_SIGNIN,
value: { has_flow_params: true },
})
);
});
});
================================================
FILE: test/unit/asrouter/templates/Interrupt.test.jsx
================================================
import { FullPageInterrupt } from "content-src/asrouter/templates/FullPageInterrupt/FullPageInterrupt";
import { Interrupt } from "content-src/asrouter/templates/FirstRun/Interrupt";
import { ReturnToAMO } from "content-src/asrouter/templates/ReturnToAMO/ReturnToAMO";
import { Trailhead } from "content-src/asrouter/templates//Trailhead/Trailhead";
import { shallow } from "enzyme";
import React from "react";
describe("", () => {
let wrapper;
it("should render Return TO AMO when the message has a template of return_to_amo_overlay", () => {
wrapper = shallow(
);
assert.lengthOf(wrapper.find(ReturnToAMO), 1);
});
it("should render Trailhead when the message has a template of trailhead", () => {
wrapper = shallow(
);
assert.lengthOf(wrapper.find(Trailhead), 1);
});
it("should render Full Page interrupt when the message has a template of full_page_interrupt", () => {
wrapper = shallow(
);
assert.lengthOf(wrapper.find(FullPageInterrupt), 1);
});
it("should throw an error if another type of message is dispatched", () => {
assert.throws(() => {
wrapper = shallow(
);
});
});
});
================================================
FILE: test/unit/asrouter/templates/NewsletterSnippet.test.jsx
================================================
import { mount } from "enzyme";
import { NewsletterSnippet } from "content-src/asrouter/templates/NewsletterSnippet/NewsletterSnippet";
import React from "react";
import schema from "content-src/asrouter/templates/NewsletterSnippet/NewsletterSnippet.schema.json";
import { SnippetsTestMessageProvider } from "lib/SnippetsTestMessageProvider.jsm";
const DEFAULT_CONTENT = SnippetsTestMessageProvider.getMessages().find(
msg => msg.template === "newsletter_snippet"
).content;
describe("NewsletterSnippet", () => {
let sandbox;
function mountAndCheckProps(content = {}) {
const props = {
id: "foo123",
content: Object.assign({}, DEFAULT_CONTENT, content),
onBlock() {},
onDismiss: sandbox.stub(),
sendUserActionTelemetry: sandbox.stub(),
onAction: sandbox.stub(),
};
const comp = mount( );
// Check schema with the final props the component receives (including defaults)
assert.jsonSchema(comp.children().get(0).props.content, schema);
return comp;
}
beforeEach(() => {
sandbox = sinon.createSandbox();
});
afterEach(() => {
sandbox.restore();
});
describe("schema test", () => {
it("should validate the schema and defaults", () => {
const wrapper = mountAndCheckProps();
wrapper.find(".ASRouterButton").simulate("click");
assert.equal(wrapper.find(".mainInput").instance().type, "email");
});
it("should have all of the default fields", () => {
const defaults = {
id: "foo123",
content: {},
onBlock() {},
onDismiss: sandbox.stub(),
sendUserActionTelemetry: sandbox.stub(),
onAction: sandbox.stub(),
};
const wrapper = mount( );
// NewsletterSnippet is a wrapper around SubmitFormSnippet
const { props } = wrapper.children().get(0);
// the `locale` properties gets used as part of hidden_fields so we
// check for it separately
const properties = { ...schema.properties };
const { locale } = properties;
delete properties.locale;
const defaultProperties = Object.keys(properties).filter(
prop => properties[prop].default
);
assert.lengthOf(defaultProperties, 6);
defaultProperties.forEach(prop =>
assert.propertyVal(props.content, prop, properties[prop].default)
);
const defaultHiddenProperties = Object.keys(
schema.properties.hidden_inputs.properties
).filter(
prop => schema.properties.hidden_inputs.properties[prop].default
);
assert.lengthOf(defaultHiddenProperties, 1);
defaultHiddenProperties.forEach(prop =>
assert.propertyVal(
props.content.hidden_inputs,
prop,
schema.properties.hidden_inputs.properties[prop].default
)
);
assert.propertyVal(props.content.hidden_inputs, "lang", locale.default);
});
});
});
================================================
FILE: test/unit/asrouter/templates/OnboardingMessage.test.jsx
================================================
import { GlobalOverrider } from "test/unit/utils";
import { OnboardingMessageProvider } from "lib/OnboardingMessageProvider.jsm";
import schema from "content-src/asrouter/templates/OnboardingMessage/OnboardingMessage.schema.json";
import badgeSchema from "content-src/asrouter/templates/OnboardingMessage/ToolbarBadgeMessage.schema.json";
import whatsNewSchema from "content-src/asrouter/templates/OnboardingMessage/WhatsNewMessage.schema.json";
const DEFAULT_CONTENT = {
title: "A title",
text: "A description",
icon: "icon",
primary_button: {
label: "some_button_label",
action: {
type: "SOME_TYPE",
data: { args: "example.com" },
},
},
};
const L10N_CONTENT = {
title: { string_id: "onboarding-private-browsing-title" },
text: { string_id: "onboarding-private-browsing-text" },
icon: "icon",
primary_button: {
label: { string_id: "onboarding-button-label-get-started" },
action: { type: "SOME_TYPE" },
},
};
describe("OnboardingMessage", () => {
let globals;
let sandbox;
beforeEach(() => {
globals = new GlobalOverrider();
sandbox = sinon.createSandbox();
globals.set("FxAccountsConfig", {
promiseConnectAccountURI: sandbox.stub().resolves("some/url"),
});
globals.set("AddonRepository", {
getAddonsByIDs: ([content]) => [
{
name: content,
sourceURI: { spec: "foo", scheme: "https" },
icons: { 64: "icon" },
},
],
});
});
afterEach(() => {
sandbox.restore();
globals.restore();
});
it("should validate DEFAULT_CONTENT", () => {
assert.jsonSchema(DEFAULT_CONTENT, schema);
});
it("should validate L10N_CONTENT", () => {
assert.jsonSchema(L10N_CONTENT, schema);
});
it("should validate all messages from OnboardingMessageProvider", async () => {
const messages = await OnboardingMessageProvider.getUntranslatedMessages();
// FXA_1 doesn't have content - so filter it out
messages
.filter(msg => msg.template in ["onboarding", "return_to_amo_overlay"])
.forEach(msg => assert.jsonSchema(msg.content, schema));
});
it("should validate all badge template messages", async () => {
const messages = await OnboardingMessageProvider.getUntranslatedMessages();
messages
.filter(msg => msg.template === "toolbar_badge")
.forEach(msg => assert.jsonSchema(msg.content, badgeSchema));
});
it("should validate all What's New template messages", async () => {
const messages = await OnboardingMessageProvider.getUntranslatedMessages();
messages
.filter(msg => msg.template === "whatsnew_panel_message")
.forEach(msg => assert.jsonSchema(msg.content, whatsNewSchema));
});
it("should decode the content field (double decoding)", async () => {
const fakeContent = "foo%2540bar.org";
globals.set("AttributionCode", {
getAttrDataAsync: sandbox
.stub()
.resolves({ content: fakeContent, source: "addons.mozilla.org" }),
});
const msgs = (await OnboardingMessageProvider.getUntranslatedMessages()).filter(
({ id }) => id === "RETURN_TO_AMO_1"
);
const [
translatedMessage,
] = await OnboardingMessageProvider.translateMessages(msgs);
assert.propertyVal(
translatedMessage.content.text.args,
"addon-name",
"foo@bar.org"
);
});
it("should catch any decoding exceptions", async () => {
const fakeContent = "foo%bar.org";
globals.set("AttributionCode", {
getAttrDataAsync: sandbox
.stub()
.resolves({ content: fakeContent, source: "addons.mozilla.org" }),
});
const msgs = (await OnboardingMessageProvider.getUntranslatedMessages()).filter(
({ id }) => id === "RETURN_TO_AMO_1"
);
const [
translatedMessage,
] = await OnboardingMessageProvider.translateMessages(msgs);
assert.propertyVal(
translatedMessage.content.text.args,
"addon-name",
fakeContent
);
});
it("should ignore attribution from sources other than mozilla.org", async () => {
const fakeContent = "foo%bar.org";
globals.set("AttributionCode", {
getAttrDataAsync: sandbox
.stub()
.resolves({ content: fakeContent, source: "addons.allizom.org" }),
});
const [
returnToAMOMsg,
] = (await OnboardingMessageProvider.getUntranslatedMessages()).filter(
({ id }) => id === "RETURN_TO_AMO_1"
);
assert.propertyVal(returnToAMOMsg.content.text.args, "addon-name", null);
});
it("should correctly add all addon information to the message after translation", async () => {
const fakeContent = "foo%2540bar.org";
globals.set("AttributionCode", {
getAttrDataAsync: sandbox
.stub()
.resolves({ content: fakeContent, source: "addons.mozilla.org" }),
});
const msgs = (await OnboardingMessageProvider.getUntranslatedMessages()).filter(
({ id }) => id === "RETURN_TO_AMO_1"
);
const [
translatedMessage,
] = await OnboardingMessageProvider.translateMessages(msgs);
assert.propertyVal(
translatedMessage.content.text.args,
"addon-name",
"foo@bar.org"
);
assert.propertyVal(translatedMessage.content, "addon_icon", "icon");
assert.propertyVal(
translatedMessage.content.primary_button.action.data,
"url",
"foo"
);
assert.propertyVal(
translatedMessage.content.primary_button.action.data,
"telemetrySource",
"rtamo"
);
});
it("should skip return_to_amo_overlay if any addon fields are missing", async () => {
const fakeContent = "foo%bar.org";
globals.set("AttributionCode", {
getAttrDataAsync: sandbox
.stub()
.resolves({ content: fakeContent, source: "addons.mozilla.org" }),
});
globals.set("AddonRepository", {
getAddonsByIDs: ([content]) => [
{
name: content,
sourceURI: { spec: "foo", scheme: "https" },
icons: { 64: null },
},
],
});
const msgs = (await OnboardingMessageProvider.getUntranslatedMessages()).filter(
({ id }) => id === "RETURN_TO_AMO_1"
);
const translatedMessages = await OnboardingMessageProvider.translateMessages(
msgs
);
assert.lengthOf(translatedMessages, 0);
});
it("should skip return_to_amo_overlay if any addon fields are missing", async () => {
const fakeContent = "foo%bar.org";
globals.set("AttributionCode", {
getAttrDataAsync: sandbox
.stub()
.resolves({ content: fakeContent, source: "addons.mozilla.org" }),
});
globals.set("AddonRepository", {
getAddonsByIDs: ([content]) => [
{
name: content,
sourceURI: { spec: null, scheme: "https" },
icons: { 64: "icon" },
},
],
});
const msgs = (await OnboardingMessageProvider.getUntranslatedMessages()).filter(
({ id }) => id === "RETURN_TO_AMO_1"
);
const translatedMessages = await OnboardingMessageProvider.translateMessages(
msgs
);
assert.lengthOf(translatedMessages, 0);
});
it("should skip return_to_amo_overlay if any addon fields are missing", async () => {
const fakeContent = "foo%bar.org";
globals.set("AttributionCode", {
getAttrDataAsync: sandbox
.stub()
.resolves({ content: fakeContent, source: "addons.mozilla.org" }),
});
globals.set("AddonRepository", {
getAddonsByIDs: ([content]) => [
{
name: null,
sourceURI: { spec: "foo", scheme: "https" },
icons: { 64: "icon" },
},
],
});
const msgs = (await OnboardingMessageProvider.getUntranslatedMessages()).filter(
({ id }) => id === "RETURN_TO_AMO_1"
);
const translatedMessages = await OnboardingMessageProvider.translateMessages(
msgs
);
assert.lengthOf(translatedMessages, 0);
});
it("should skip return_to_amo_overlay if addon scheme is not https", async () => {
const fakeContent = "foo%bar.org";
globals.set("AttributionCode", {
getAttrDataAsync: sandbox
.stub()
.resolves({ content: fakeContent, source: "addons.mozilla.org" }),
});
globals.set("AddonRepository", {
getAddonsByIDs: ([content]) => [
{
name: content,
sourceURI: { spec: "foo", scheme: "http" },
icons: { 64: "icon" },
},
],
});
const msgs = (await OnboardingMessageProvider.getUntranslatedMessages()).filter(
({ id }) => id === "RETURN_TO_AMO_1"
);
const translatedMessages = await OnboardingMessageProvider.translateMessages(
msgs
);
assert.lengthOf(translatedMessages, 0);
});
it("should skip return_to_amo_overlay if getAddonInfo fails", async () => {
globals.set("AttributionCode", {
getAttrDataAsync: sandbox.stub().rejects(),
});
const msgs = (await OnboardingMessageProvider.getUntranslatedMessages()).filter(
({ id }) => id === "RETURN_TO_AMO_1"
);
const translatedMessages = await OnboardingMessageProvider.translateMessages(
msgs
);
assert.lengthOf(translatedMessages, 0);
});
it("should catch any exceptions fetching the addon information", async () => {
const fakeContent = "foo%bar.org";
globals.set("AttributionCode", {
getAttrDataAsync: sandbox.stub().resolves({ content: fakeContent }),
});
globals.set("AddonRepository", {
getAddonsByIDs: sandbox.stub().rejects(),
});
const msgs = await OnboardingMessageProvider.getUntranslatedMessages();
const translatedMessages = await OnboardingMessageProvider.translateMessages(
msgs
);
const returnToAMOMsgs = translatedMessages.filter(
({ id }) => id === "RETURN_TO_AMO_1"
);
assert.lengthOf(translatedMessages, msgs.length - 1);
assert.lengthOf(returnToAMOMsgs, 0);
});
});
================================================
FILE: test/unit/asrouter/templates/SendToDeviceSnippet.test.jsx
================================================
import { mount } from "enzyme";
import React from "react";
import schema from "content-src/asrouter/templates/SendToDeviceSnippet/SendToDeviceSnippet.schema.json";
import { SendToDeviceSnippet } from "content-src/asrouter/templates/SendToDeviceSnippet/SendToDeviceSnippet";
import { SnippetsTestMessageProvider } from "lib/SnippetsTestMessageProvider.jsm";
const DEFAULT_CONTENT = SnippetsTestMessageProvider.getMessages().find(
msg => msg.template === "send_to_device_snippet"
).content;
async function testBodyContains(body, key, value) {
const regex = new RegExp(
`Content-Disposition: form-data; name="${key}"${value}`
);
const match = regex.exec(body);
return match;
}
/**
* Simulates opening the second panel (form view), filling in the input, and submitting
* @param {EnzymeWrapper} wrapper A SendToDevice wrapper
* @param {string} value Email or phone number
* @param {function?} setCustomValidity setCustomValidity stub
*/
function openFormAndSetValue(wrapper, value, setCustomValidity = () => {}) {
// expand
wrapper.find(".ASRouterButton").simulate("click");
// Fill in email
const input = wrapper.find(".mainInput");
input.instance().value = value;
input.simulate("change", { target: { value, setCustomValidity } });
wrapper.find("form").simulate("submit");
}
describe("SendToDeviceSnippet", () => {
let sandbox;
let fetchStub;
let jsonResponse;
function mountAndCheckProps(content = {}) {
const props = {
id: "foo123",
content: Object.assign({}, DEFAULT_CONTENT, content),
onBlock() {},
onDismiss: sandbox.stub(),
sendUserActionTelemetry: sandbox.stub(),
onAction: sandbox.stub(),
};
const comp = mount( );
// Check schema with the final props the component receives (including defaults)
assert.jsonSchema(comp.children().get(0).props.content, schema);
return comp;
}
beforeEach(() => {
sandbox = sinon.createSandbox();
jsonResponse = { status: "ok" };
fetchStub = sandbox
.stub(global, "fetch")
.returns(Promise.resolve({ json: () => Promise.resolve(jsonResponse) }));
});
afterEach(() => {
sandbox.restore();
});
it("should have the correct defaults", () => {
const defaults = {
id: "foo123",
onBlock() {},
content: {},
onDismiss: sandbox.stub(),
sendUserActionTelemetry: sandbox.stub(),
onAction: sandbox.stub(),
form_method: "POST",
};
const wrapper = mount( );
// SendToDeviceSnippet is a wrapper around SubmitFormSnippet
const { props } = wrapper.children().get(0);
const defaultProperties = Object.keys(schema.properties).filter(
prop => schema.properties[prop].default
);
assert.lengthOf(defaultProperties, 7);
defaultProperties.forEach(prop =>
assert.propertyVal(props.content, prop, schema.properties[prop].default)
);
const defaultHiddenProperties = Object.keys(
schema.properties.hidden_inputs.properties
).filter(prop => schema.properties.hidden_inputs.properties[prop].default);
assert.lengthOf(defaultHiddenProperties, 0);
});
describe("form input", () => {
it("should set the input type to text if content.include_sms is true", () => {
const wrapper = mountAndCheckProps({ include_sms: true });
wrapper.find(".ASRouterButton").simulate("click");
assert.equal(wrapper.find(".mainInput").instance().type, "text");
});
it("should set the input type to email if content.include_sms is false", () => {
const wrapper = mountAndCheckProps({ include_sms: false });
wrapper.find(".ASRouterButton").simulate("click");
assert.equal(wrapper.find(".mainInput").instance().type, "email");
});
it("should validate the input with isEmailOrPhoneNumber if include_sms is true", () => {
const wrapper = mountAndCheckProps({ include_sms: true });
const setCustomValidity = sandbox.stub();
openFormAndSetValue(wrapper, "foo", setCustomValidity);
assert.calledWith(
setCustomValidity,
"Must be an email or a phone number."
);
});
it("should not custom validate the input if include_sms is false", () => {
const wrapper = mountAndCheckProps({ include_sms: false });
const setCustomValidity = sandbox.stub();
openFormAndSetValue(wrapper, "foo", setCustomValidity);
assert.notCalled(setCustomValidity);
});
});
describe("submitting", () => {
it("should send the right information to basket.mozilla.org/news/subscribe for an email", async () => {
const wrapper = mountAndCheckProps({
locale: "fr-CA",
include_sms: true,
message_id_email: "foo",
});
openFormAndSetValue(wrapper, "foo@bar.com");
wrapper.find("form").simulate("submit");
assert.calledOnce(fetchStub);
const [request] = fetchStub.firstCall.args;
assert.equal(request.url, "https://basket.mozilla.org/news/subscribe/");
const body = await request.text();
assert.ok(testBodyContains(body, "email", "foo@bar.com"), "has email");
assert.ok(testBodyContains(body, "lang", "fr-CA"), "has lang");
assert.ok(
testBodyContains(body, "newsletters", "foo"),
"has newsletters"
);
assert.ok(
testBodyContains(body, "source_url", "foo"),
"https%3A%2F%2Fsnippets.mozilla.com%2Fshow%2Ffoo123"
);
});
it("should send the right information for an sms", async () => {
const wrapper = mountAndCheckProps({
locale: "fr-CA",
include_sms: true,
message_id_sms: "foo",
country: "CA",
});
openFormAndSetValue(wrapper, "5371283767");
wrapper.find("form").simulate("submit");
assert.calledOnce(fetchStub);
const [request] = fetchStub.firstCall.args;
assert.equal(
request.url,
"https://basket.mozilla.org/news/subscribe_sms/"
);
const body = await request.text();
assert.ok(
testBodyContains(body, "mobile_number", "5371283767"),
"has number"
);
assert.ok(testBodyContains(body, "lang", "fr-CA"), "has lang");
assert.ok(testBodyContains(body, "country", "CA"), "CA");
assert.ok(testBodyContains(body, "msg_name", "foo"), "has msg_name");
});
});
});
================================================
FILE: test/unit/asrouter/templates/SimpleBelowSearchSnippet.test.jsx
================================================
import { mount } from "enzyme";
import React from "react";
import schema from "content-src/asrouter/templates/SimpleBelowSearchSnippet/SimpleBelowSearchSnippet.schema.json";
import { SimpleBelowSearchSnippet } from "content-src/asrouter/templates/SimpleBelowSearchSnippet/SimpleBelowSearchSnippet.jsx";
const DEFAULT_CONTENT = { text: "foo" };
describe("SimpleBelowSearchSnippet", () => {
let sandbox;
let sendUserActionTelemetryStub;
/**
* mountAndCheckProps - Mounts a SimpleBelowSearchSnippet with DEFAULT_CONTENT extended with any props
* passed in the content param and validates props against the schema.
* @param {obj} content Object containing custom message content (e.g. {text, icon})
* @returns enzyme wrapper for SimpleSnippet
*/
function mountAndCheckProps(content = {}, provider = "test-provider") {
const props = {
content: { ...DEFAULT_CONTENT, ...content },
provider,
sendUserActionTelemetry: sendUserActionTelemetryStub,
onAction: sandbox.stub(),
};
assert.jsonSchema(props.content, schema);
return mount( );
}
beforeEach(() => {
sandbox = sinon.createSandbox();
sendUserActionTelemetryStub = sandbox.stub();
});
afterEach(() => {
sandbox.restore();
});
it("should render .text", () => {
const wrapper = mountAndCheckProps({ text: "bar" });
assert.equal(wrapper.find(".body").text(), "bar");
});
it("should render .icon (light theme)", () => {
const wrapper = mountAndCheckProps({
icon: "data:image/gif;base64,R0lGODl",
});
assert.equal(
wrapper.find(".icon-light-theme").prop("src"),
"data:image/gif;base64,R0lGODl"
);
});
it("should render .icon (dark theme)", () => {
const wrapper = mountAndCheckProps({
icon_dark_theme: "data:image/gif;base64,R0lGODl",
});
assert.equal(
wrapper.find(".icon-dark-theme").prop("src"),
"data:image/gif;base64,R0lGODl"
);
});
});
================================================
FILE: test/unit/asrouter/templates/SimpleSnippet.test.jsx
================================================
import { mount } from "enzyme";
import React from "react";
import schema from "content-src/asrouter/templates/SimpleSnippet/SimpleSnippet.schema.json";
import { SimpleSnippet } from "content-src/asrouter/templates/SimpleSnippet/SimpleSnippet.jsx";
const DEFAULT_CONTENT = { text: "foo" };
describe("SimpleSnippet", () => {
let sandbox;
let onBlockStub;
let sendUserActionTelemetryStub;
/**
* mountAndCheckProps - Mounts a SimpleSnippet with DEFAULT_CONTENT extended with any props
* passed in the content param and validates props against the schema.
* @param {obj} content Object containing custom message content (e.g. {text, icon, title})
* @returns enzyme wrapper for SimpleSnippet
*/
function mountAndCheckProps(content = {}, provider = "test-provider") {
const props = {
content: Object.assign({}, DEFAULT_CONTENT, content),
provider,
onBlock: onBlockStub,
sendUserActionTelemetry: sendUserActionTelemetryStub,
onAction: sandbox.stub(),
};
assert.jsonSchema(props.content, schema);
return mount( );
}
beforeEach(() => {
sandbox = sinon.createSandbox();
onBlockStub = sandbox.stub();
sendUserActionTelemetryStub = sandbox.stub();
});
afterEach(() => {
sandbox.restore();
});
it("should render .text", () => {
const wrapper = mountAndCheckProps({ text: "bar" });
assert.equal(wrapper.find(".body").text(), "bar");
});
it("should not render title element if no .title prop is supplied", () => {
const wrapper = mountAndCheckProps();
assert.lengthOf(wrapper.find(".title"), 0);
});
it("should render .title", () => {
const wrapper = mountAndCheckProps({ title: "Foo" });
assert.equal(
wrapper
.find(".title")
.text()
.trim(),
"Foo"
);
});
it("should render a light theme variant .icon", () => {
const wrapper = mountAndCheckProps({
icon: "data:image/gif;base64,R0lGODl",
});
assert.equal(
wrapper.find(".icon-light-theme").prop("src"),
"data:image/gif;base64,R0lGODl"
);
});
it("should render a dark theme variant .icon", () => {
const wrapper = mountAndCheckProps({
icon_dark_theme: "data:image/gif;base64,R0lGODl",
});
assert.equal(
wrapper.find(".icon-dark-theme").prop("src"),
"data:image/gif;base64,R0lGODl"
);
});
it("should render a light theme variant .icon as fallback", () => {
const wrapper = mountAndCheckProps({
icon_dark_theme: "",
icon: "data:image/gif;base64,R0lGODp",
});
assert.equal(
wrapper.find(".icon-dark-theme").prop("src"),
"data:image/gif;base64,R0lGODp"
);
});
it("should render .button_label and default className", () => {
const wrapper = mountAndCheckProps({
button_label: "Click here",
button_action: "OPEN_APPLICATIONS_MENU",
button_action_args: "appMenu",
});
const button = wrapper.find("button.ASRouterButton");
button.simulate("click");
assert.equal(button.text(), "Click here");
assert.equal(button.prop("className"), "ASRouterButton secondary");
assert.calledOnce(wrapper.props().onAction);
assert.calledWithExactly(wrapper.props().onAction, {
type: "OPEN_APPLICATIONS_MENU",
data: { args: "appMenu" },
});
});
it("should not wrap the main content if a section header is not present", () => {
const wrapper = mountAndCheckProps({ text: "bar" });
assert.lengthOf(wrapper.find(".innerContentWrapper"), 0);
});
it("should wrap the main content if a section header is present", () => {
const wrapper = mountAndCheckProps({
section_title_icon: "data:image/gif;base64,R0lGODl",
section_title_text: "Messages from Mozilla",
});
assert.lengthOf(wrapper.find(".innerContentWrapper"), 1);
});
it("should render a section header if text and icon (light-theme) are specified", () => {
const wrapper = mountAndCheckProps({
section_title_icon: "data:image/gif;base64,R0lGODl",
section_title_text: "Messages from Mozilla",
});
assert.equal(
wrapper.find(".section-title .icon-light-theme").prop("style")
.backgroundImage,
'url("data:image/gif;base64,R0lGODl")'
);
assert.equal(
wrapper
.find(".section-title-text")
.text()
.trim(),
"Messages from Mozilla"
);
// ensure there is no when a section_title_url is not specified
assert.lengthOf(wrapper.find(".section-title a"), 0);
});
it("should render a section header if text and icon (light-theme) are specified", () => {
const wrapper = mountAndCheckProps({
section_title_icon: "data:image/gif;base64,R0lGODl",
section_title_icon_dark_theme: "data:image/gif;base64,R0lGODl",
section_title_text: "Messages from Mozilla",
});
assert.equal(
wrapper.find(".section-title .icon-dark-theme").prop("style")
.backgroundImage,
'url("data:image/gif;base64,R0lGODl")'
);
assert.equal(
wrapper
.find(".section-title-text")
.text()
.trim(),
"Messages from Mozilla"
);
// ensure there is no when a section_title_url is not specified
assert.lengthOf(wrapper.find(".section-title a"), 0);
});
it("should render a section header wrapped in an tag if a url is provided", () => {
const wrapper = mountAndCheckProps({
section_title_icon: "data:image/gif;base64,R0lGODl",
section_title_text: "Messages from Mozilla",
section_title_url: "https://www.mozilla.org",
});
assert.equal(
wrapper.find(".section-title a").prop("href"),
"https://www.mozilla.org"
);
});
it("should send an OPEN_URL action when button_url is defined and button is clicked", () => {
const wrapper = mountAndCheckProps({
button_label: "Button",
button_url: "https://mozilla.org",
});
const button = wrapper.find("button.ASRouterButton");
button.simulate("click");
assert.calledOnce(wrapper.props().onAction);
assert.calledWithExactly(wrapper.props().onAction, {
type: "OPEN_URL",
data: { args: "https://mozilla.org" },
});
});
it("should call props.onBlock and sendUserActionTelemetry when CTA button is clicked", () => {
const wrapper = mountAndCheckProps({ text: "bar" });
wrapper.instance().onButtonClick();
assert.calledOnce(onBlockStub);
assert.calledOnce(sendUserActionTelemetryStub);
});
it("should not call props.onBlock if do_not_autoblock is true", () => {
const wrapper = mountAndCheckProps({ text: "bar", do_not_autoblock: true });
wrapper.instance().onButtonClick();
assert.notCalled(onBlockStub);
});
it("should not call sendUserActionTelemetry for preview message when CTA button is clicked", () => {
const wrapper = mountAndCheckProps({ text: "bar" }, "preview");
wrapper.instance().onButtonClick();
assert.calledOnce(onBlockStub);
assert.notCalled(sendUserActionTelemetryStub);
});
});
================================================
FILE: test/unit/asrouter/templates/SubmitFormSnippet.test.jsx
================================================
import { mount } from "enzyme";
import React from "react";
import { RichText } from "content-src/asrouter/components/RichText/RichText.jsx";
import schema from "content-src/asrouter/templates/SubmitFormSnippet/SubmitFormSnippet.schema.json";
import { SubmitFormSnippet } from "content-src/asrouter/templates/SubmitFormSnippet/SubmitFormSnippet.jsx";
const DEFAULT_CONTENT = {
scene1_text: "foo",
scene2_text: "bar",
scene1_button_label: "Sign Up",
retry_button_label: "Try again",
form_action: "foo.com",
hidden_inputs: { foo: "foo" },
error_text: "error",
success_text: "success",
};
describe("SubmitFormSnippet", () => {
let sandbox;
let onBlockStub;
/**
* mountAndCheckProps - Mounts a SubmitFormSnippet with DEFAULT_CONTENT extended with any props
* passed in the content param and validates props against the schema.
* @param {obj} content Object containing custom message content (e.g. {text, icon, title})
* @returns enzyme wrapper for SubmitFormSnippet
*/
function mountAndCheckProps(content = {}) {
const props = {
content: Object.assign({}, DEFAULT_CONTENT, content),
onBlock: onBlockStub,
onDismiss: sandbox.stub(),
sendUserActionTelemetry: sandbox.stub(),
onAction: sandbox.stub(),
form_method: "POST",
};
assert.jsonSchema(props.content, schema);
return mount( );
}
beforeEach(() => {
sandbox = sinon.createSandbox();
onBlockStub = sandbox.stub();
});
afterEach(() => {
sandbox.restore();
});
it("should render .text", () => {
const wrapper = mountAndCheckProps({ scene1_text: "bar" });
assert.equal(wrapper.find(".body").text(), "bar");
});
it("should not render title element if no .title prop is supplied", () => {
const wrapper = mountAndCheckProps();
assert.lengthOf(wrapper.find(".title"), 0);
});
it("should render .title", () => {
const wrapper = mountAndCheckProps({ scene1_title: "Foo" });
assert.equal(
wrapper
.find(".title")
.text()
.trim(),
"Foo"
);
});
it("should render light-theme .icon", () => {
const wrapper = mountAndCheckProps({
scene1_icon: "data:image/gif;base64,R0lGODl",
});
assert.equal(
wrapper.find(".icon-light-theme").prop("src"),
"data:image/gif;base64,R0lGODl"
);
});
it("should render dark-theme .icon", () => {
const wrapper = mountAndCheckProps({
scene1_icon_dark_theme: "data:image/gif;base64,R0lGODl",
});
assert.equal(
wrapper.find(".icon-dark-theme").prop("src"),
"data:image/gif;base64,R0lGODl"
);
});
it("should render .button_label and default className", () => {
const wrapper = mountAndCheckProps({ scene1_button_label: "Click here" });
const button = wrapper.find("button.ASRouterButton");
assert.equal(button.text(), "Click here");
assert.equal(button.prop("className"), "ASRouterButton secondary");
});
describe("#SignupView", () => {
let wrapper;
const fetchOk = { json: () => Promise.resolve({ status: "ok" }) };
const fetchFail = { json: () => Promise.resolve({ status: "fail" }) };
beforeEach(() => {
wrapper = mountAndCheckProps({
scene1_text: "bar",
scene2_email_placeholder_text: "Email",
scene2_text: "signup",
});
});
it("should set the input type if provided through props.inputType", () => {
wrapper.setProps({ inputType: "number" });
wrapper.setState({ expanded: true });
assert.equal(wrapper.find(".mainInput").instance().type, "number");
});
it("should validate via props.validateInput if provided", () => {
function validateInput(value, content) {
if (content.country === "CA" && value === "poutine") {
return "";
}
return "Must be poutine";
}
const setCustomValidity = sandbox.stub();
wrapper.setProps({
validateInput,
content: { ...DEFAULT_CONTENT, country: "CA" },
});
wrapper.setState({ expanded: true });
const input = wrapper.find(".mainInput");
input.instance().value = "poutine";
input.simulate("change", {
target: { value: "poutine", setCustomValidity },
});
assert.calledWith(setCustomValidity, "");
input.instance().value = "fried chicken";
input.simulate("change", {
target: { value: "fried chicken", setCustomValidity },
});
assert.calledWith(setCustomValidity, "Must be poutine");
});
it("should show the signup form if state.expanded is true", () => {
wrapper.setState({ expanded: true });
assert.isTrue(wrapper.find("form").exists());
});
it("should dismiss the snippet", () => {
wrapper.setState({ expanded: true });
wrapper.find(".ASRouterButton.secondary").simulate("click");
assert.calledOnce(wrapper.props().onDismiss);
});
it("should send a DISMISS event ping", () => {
wrapper.setState({ expanded: true });
wrapper.find(".ASRouterButton.secondary").simulate("click");
assert.equal(
wrapper.props().sendUserActionTelemetry.firstCall.args[0].event,
"DISMISS"
);
});
it("should render hidden inputs + email input", () => {
wrapper.setState({ expanded: true });
assert.lengthOf(wrapper.find("input[type='hidden']"), 1);
});
it("should open the SignupView when the action button is clicked", () => {
assert.isFalse(wrapper.find("form").exists());
wrapper.find(".ASRouterButton").simulate("click");
assert.isTrue(wrapper.state().expanded);
assert.isTrue(wrapper.find("form").exists());
});
it("should submit telemetry when the action button is clicked", () => {
assert.isFalse(wrapper.find("form").exists());
wrapper.find(".ASRouterButton").simulate("click");
assert.equal(
wrapper.props().sendUserActionTelemetry.firstCall.args[0].value,
"scene1-button-learn-more"
);
});
it("should submit form data when submitted", () => {
sandbox.stub(window, "fetch").resolves(fetchOk);
wrapper.setState({ expanded: true });
wrapper.find("form").simulate("submit");
assert.calledOnce(window.fetch);
});
it("should send user telemetry when submitted", () => {
wrapper.setState({ expanded: true });
wrapper.find("form").simulate("submit");
assert.equal(
wrapper.props().sendUserActionTelemetry.firstCall.args[0].value,
"conversion-subscribe-activation"
);
});
it("should set signupSuccess when submission status is ok", async () => {
sandbox.stub(window, "fetch").resolves(fetchOk);
wrapper.setState({ expanded: true });
await wrapper.instance().handleSubmit({ preventDefault: sandbox.stub() });
assert.equal(wrapper.state().signupSuccess, true);
assert.equal(wrapper.state().signupSubmitted, true);
assert.calledOnce(onBlockStub);
assert.calledWithExactly(onBlockStub, { preventDismiss: true });
});
it("should send user telemetry when submission status is ok", async () => {
sandbox.stub(window, "fetch").resolves(fetchOk);
wrapper.setState({ expanded: true });
await wrapper.instance().handleSubmit({ preventDefault: sandbox.stub() });
assert.equal(
wrapper.props().sendUserActionTelemetry.secondCall.args[0].value,
"subscribe-success"
);
});
it("should not block the snippet if submission failed", async () => {
sandbox.stub(window, "fetch").resolves(fetchFail);
wrapper.setState({ expanded: true });
await wrapper.instance().handleSubmit({ preventDefault: sandbox.stub() });
assert.equal(wrapper.state().signupSuccess, false);
assert.equal(wrapper.state().signupSubmitted, true);
assert.notCalled(onBlockStub);
});
it("should not block if do_not_autoblock is true", async () => {
sandbox.stub(window, "fetch").resolves(fetchOk);
wrapper = mountAndCheckProps({
scene1_text: "bar",
scene2_email_placeholder_text: "Email",
scene2_text: "signup",
do_not_autoblock: true,
});
wrapper.setState({ expanded: true });
await wrapper.instance().handleSubmit({ preventDefault: sandbox.stub() });
assert.equal(wrapper.state().signupSuccess, true);
assert.equal(wrapper.state().signupSubmitted, true);
assert.notCalled(onBlockStub);
});
it("should send user telemetry if submission failed", async () => {
sandbox.stub(window, "fetch").resolves(fetchFail);
wrapper.setState({ expanded: true });
await wrapper.instance().handleSubmit({ preventDefault: sandbox.stub() });
assert.equal(
wrapper.props().sendUserActionTelemetry.secondCall.args[0].value,
"subscribe-error"
);
});
it("should render the signup success message", () => {
wrapper.setProps({ content: { success_text: "success" } });
wrapper.setState({ signupSuccess: true, signupSubmitted: true });
assert.isTrue(wrapper.find(".submissionStatus").exists());
assert.propertyVal(
wrapper.find(RichText).props(),
"localization_id",
"success_text"
);
assert.propertyVal(
wrapper.find(RichText).props(),
"success_text",
"success"
);
assert.isFalse(wrapper.find(".ASRouterButton").exists());
});
it("should render the signup error message", () => {
wrapper.setProps({ content: { error_text: "trouble" } });
wrapper.setState({ signupSuccess: false, signupSubmitted: true });
assert.isTrue(wrapper.find(".submissionStatus").exists());
assert.propertyVal(
wrapper.find(RichText).props(),
"localization_id",
"error_text"
);
assert.propertyVal(
wrapper.find(RichText).props(),
"error_text",
"trouble"
);
assert.isTrue(wrapper.find(".ASRouterButton").exists());
});
it("should render the button to return to the signup form if there was an error", () => {
wrapper.setState({ signupSubmitted: true, signupSuccess: false });
const button = wrapper.find("button.ASRouterButton");
assert.equal(button.text(), "Try again");
wrapper.find(".ASRouterButton").simulate("click");
assert.equal(wrapper.state().signupSubmitted, false);
});
it("should not render the privacy notice checkbox if prop is missing", () => {
wrapper.setState({ expanded: true });
assert.isFalse(wrapper.find(".privacyNotice").exists());
});
it("should render the privacy notice checkbox if prop is provided", () => {
wrapper.setProps({
content: { ...DEFAULT_CONTENT, scene2_privacy_html: "privacy notice" },
});
wrapper.setState({ expanded: true });
assert.isTrue(wrapper.find(".privacyNotice").exists());
});
it("should not call fetch if form_method is GET", async () => {
sandbox.stub(window, "fetch").resolves(fetchOk);
wrapper.setProps({ form_method: "GET" });
wrapper.setState({ expanded: true });
await wrapper.instance().handleSubmit({ preventDefault: sandbox.stub() });
assert.notCalled(window.fetch);
});
it("should block the snippet when form_method is GET", () => {
wrapper.setProps({ form_method: "GET" });
wrapper.setState({ expanded: true });
wrapper.instance().handleSubmit({ preventDefault: sandbox.stub() });
assert.calledOnce(onBlockStub);
assert.calledWithExactly(onBlockStub, { preventDismiss: true });
});
});
});
================================================
FILE: test/unit/asrouter/templates/Trailhead.test.jsx
================================================
import { actionCreators as ac, actionTypes as at } from "common/Actions.jsm";
import { FxASignupForm } from "content-src/asrouter/components/FxASignupForm/FxASignupForm";
import { mount } from "enzyme";
import { OnboardingMessageProvider } from "lib/OnboardingMessageProvider.jsm";
import React from "react";
import { Trailhead } from "content-src/asrouter/templates/Trailhead/Trailhead";
export const CARDS = [
{
content: {
title: { string_id: "onboarding-private-browsing-title" },
text: { string_id: "onboarding-private-browsing-text" },
icon: "icon",
primary_button: {
label: { string_id: "onboarding-button-label-get-started" },
action: {
type: "OPEN_URL",
data: { args: "https://example.com/" },
},
},
},
},
];
describe("", () => {
let wrapper;
let dummyNode;
let dispatch;
let onAction;
let sandbox;
let onNextScene;
beforeEach(async () => {
sandbox = sinon.sandbox.create();
dispatch = sandbox.stub();
onAction = sandbox.stub();
onNextScene = sandbox.stub();
sandbox.stub(global, "fetch").resolves({
ok: true,
status: 200,
json: () => Promise.resolve({ flowId: 123, flowBeginTime: 456 }),
});
dummyNode = document.createElement("body");
sandbox.stub(dummyNode, "querySelector").returns(dummyNode);
const fakeDocument = {
get activeElement() {
return dummyNode;
},
get body() {
return dummyNode;
},
getElementById() {
return dummyNode;
},
};
const message = (await OnboardingMessageProvider.getUntranslatedMessages()).find(
msg => msg.id === "TRAILHEAD_1"
);
message.cards = CARDS;
wrapper = mount(
);
});
afterEach(() => {
sandbox.restore();
});
it("should render FxASignupForm with signup email", () => {
assert.lengthOf(wrapper.find(FxASignupForm), 1);
});
it("should emit UserEvent SKIPPED_SIGNIN and call nextScene when you click the start browsing button", () => {
let skipButton = wrapper.find(".trailheadStart");
assert.ok(skipButton.exists());
skipButton.simulate("click");
assert.calledOnce(onNextScene);
assert.calledOnce(dispatch);
assert.isUserEventAction(dispatch.firstCall.args[0]);
assert.calledWith(
dispatch,
ac.UserEvent({
event: at.SKIPPED_SIGNIN,
value: { has_flow_params: false },
})
);
});
it("should NOT emit UserEvent SKIPPED_SIGNIN when closeModal is triggered by visibilitychange event", () => {
wrapper.instance().closeModal({ type: "visibilitychange" });
assert.notCalled(dispatch);
});
it("should prevent submissions with no email", () => {
const form = wrapper.find("form");
const preventDefault = sandbox.stub();
form.simulate("submit", { preventDefault });
assert.calledOnce(preventDefault);
assert.notCalled(dispatch);
});
it("should emit UserEvent SUBMIT_EMAIL when you submit a valid email", () => {
let form = wrapper.find("form");
assert.ok(form.exists());
form.getDOMNode().elements.email.value = "a@b.c";
form.simulate("submit");
assert.calledOnce(dispatch);
assert.isUserEventAction(dispatch.firstCall.args[0]);
assert.calledWith(
dispatch,
ac.UserEvent({
event: at.SUBMIT_EMAIL,
value: { has_flow_params: false },
})
);
});
it("should keep focus in dialog when blurring start button", () => {
const skipButton = wrapper.find(".trailheadStart");
sandbox.stub(dummyNode, "focus");
skipButton.simulate("blur", { relatedTarget: dummyNode });
assert.calledOnce(dummyNode.focus);
});
});
================================================
FILE: test/unit/asrouter/templates/Triplets.test.jsx
================================================
import { mount } from "enzyme";
import { Triplets } from "content-src/asrouter/templates/FirstRun/Triplets";
import { OnboardingCard } from "content-src/asrouter/templates/OnboardingMessage/OnboardingMessage";
import React from "react";
const CARDS = [
{
id: "CARD_1",
content: {
title: { string_id: "onboarding-private-browsing-title" },
text: { string_id: "onboarding-private-browsing-text" },
icon: "icon",
primary_button: {
label: { string_id: "onboarding-button-label-get-started" },
action: {
type: "OPEN_URL",
data: { args: "https://example.com/" },
},
},
},
},
];
describe("", () => {
let wrapper;
let sandbox;
let sendTelemetryStub;
let onAction;
let onHide;
let onBlockById;
async function setup() {
sandbox = sinon.createSandbox();
sendTelemetryStub = sandbox.stub();
onAction = sandbox.stub();
onBlockById = sandbox.stub();
onHide = sandbox.stub();
wrapper = mount(
);
}
beforeEach(setup);
afterEach(() => {
sandbox.restore();
});
it("should add an expanded class to container if props.showCardPanel is true", () => {
wrapper.setProps({ showCardPanel: true });
assert.isTrue(
wrapper.find(".trailheadCards").hasClass("expanded"),
"has .expanded)"
);
});
it("should add a collapsed class to container if props.showCardPanel is true", () => {
wrapper.setProps({ showCardPanel: false });
assert.isFalse(
wrapper.find(".trailheadCards").hasClass("expanded"),
"has .expanded)"
);
});
it("should send telemetry and call props.hideContainer when the dismiss button is clicked", () => {
wrapper.find("button.icon-dismiss").simulate("click");
assert.calledOnce(onHide);
assert.calledWith(sendTelemetryStub, {
event: "DISMISS",
message_id: CARDS[0].id,
id: "onboarding-cards",
action: "onboarding_user_event",
});
});
it("should add utm_* query params to card actions and send the right ping when a card button is clicked", () => {
wrapper
.find(OnboardingCard)
.find("button.onboardingButton")
.simulate("click");
assert.calledOnce(onAction);
const url = onAction.firstCall.args[0].data.args;
assert.equal(
url,
"https://example.com/?utm_source=activity-stream&utm_campaign=firstrun&utm_medium=referral&utm_term=trailhead-join-card"
);
assert.calledWith(sendTelemetryStub, {
event: "CLICK_BUTTON",
message_id: CARDS[0].id,
id: "TRAILHEAD",
});
});
it("should not call blockById by default when a card button is clicked", () => {
wrapper
.find(OnboardingCard)
.find("button.onboardingButton")
.simulate("click");
assert.notCalled(onBlockById);
});
it("should call blockById when blockOnClick on message is true", () => {
CARDS[0].blockOnClick = true;
wrapper
.find(OnboardingCard)
.find("button.onboardingButton")
.simulate("click");
assert.calledOnce(onBlockById);
assert.calledWith(onBlockById, CARDS[0].id);
});
});
================================================
FILE: test/unit/asrouter/templates/isEmailOrPhoneNumber.test.js
================================================
import { isEmailOrPhoneNumber } from "content-src/asrouter/templates/SendToDeviceSnippet/isEmailOrPhoneNumber";
const CONTENT = {};
describe("isEmailOrPhoneNumber", () => {
it("should return 'email' for emails", () => {
assert.equal(isEmailOrPhoneNumber("foobar@asd.com", CONTENT), "email");
assert.equal(isEmailOrPhoneNumber("foobar@asd.co.uk", CONTENT), "email");
});
it("should return 'phone' for valid en-US/en-CA phone numbers", () => {
assert.equal(
isEmailOrPhoneNumber("14582731273", { locale: "en-US" }),
"phone"
);
assert.equal(
isEmailOrPhoneNumber("4582731273", { locale: "en-CA" }),
"phone"
);
});
it("should return an empty string for invalid phone number lengths in en-US/en-CA", () => {
// Not enough digits
assert.equal(isEmailOrPhoneNumber("4522", { locale: "en-US" }), "");
assert.equal(isEmailOrPhoneNumber("4522", { locale: "en-CA" }), "");
});
it("should return 'phone' for valid German phone numbers", () => {
assert.equal(
isEmailOrPhoneNumber("145827312732", { locale: "de" }),
"phone"
);
});
it("should return 'phone' for any number of digits in other locales", () => {
assert.equal(isEmailOrPhoneNumber("4", CONTENT), "phone");
});
it("should return an empty string for other invalid inputs", () => {
assert.equal(
isEmailOrPhoneNumber("abc", CONTENT),
"",
"abc should be invalid"
);
assert.equal(
isEmailOrPhoneNumber("abc@", CONTENT),
"",
"abc@ should be invalid"
);
assert.equal(
isEmailOrPhoneNumber("abc@foo", CONTENT),
"",
"abc@foo should be invalid"
);
assert.equal(
isEmailOrPhoneNumber("123d1232", CONTENT),
"",
"123d1232 should be invalid"
);
});
});
================================================
FILE: test/unit/common/Actions.test.js
================================================
import {
actionCreators as ac,
actionTypes as at,
actionUtils as au,
BACKGROUND_PROCESS,
CONTENT_MESSAGE_TYPE,
globalImportContext,
MAIN_MESSAGE_TYPE,
PRELOAD_MESSAGE_TYPE,
UI_CODE,
} from "common/Actions.jsm";
describe("Actions", () => {
it("should set globalImportContext to UI_CODE", () => {
assert.equal(globalImportContext, UI_CODE);
});
});
describe("ActionTypes", () => {
it("should be in alpha order", () => {
assert.equal(
Object.keys(at).join(", "),
Object.keys(at)
.sort()
.join(", ")
);
});
});
describe("ActionCreators", () => {
describe("_RouteMessage", () => {
it("should throw if options are not passed as the second param", () => {
assert.throws(() => {
au._RouteMessage({ type: "FOO" });
});
});
it("should set all defined options on the .meta property of the new action", () => {
assert.deepEqual(
au._RouteMessage(
{ type: "FOO", meta: { hello: "world" } },
{ from: "foo", to: "bar" }
),
{ type: "FOO", meta: { hello: "world", from: "foo", to: "bar" } }
);
});
it("should remove any undefined options related to message routing", () => {
const action = au._RouteMessage(
{ type: "FOO", meta: { fromTarget: "bar" } },
{ from: "foo", to: "bar" }
);
assert.isUndefined(action.meta.fromTarget);
});
});
describe("AlsoToMain", () => {
it("should create the right action", () => {
const action = { type: "FOO", data: "BAR" };
const newAction = ac.AlsoToMain(action);
assert.deepEqual(newAction, {
type: "FOO",
data: "BAR",
meta: { from: CONTENT_MESSAGE_TYPE, to: MAIN_MESSAGE_TYPE },
});
});
it("should add the fromTarget if it was supplied", () => {
const action = { type: "FOO", data: "BAR" };
const newAction = ac.AlsoToMain(action, "port123");
assert.equal(newAction.meta.fromTarget, "port123");
});
describe("isSendToMain", () => {
it("should return true if action is AlsoToMain", () => {
const newAction = ac.AlsoToMain({ type: "FOO" });
assert.isTrue(au.isSendToMain(newAction));
});
it("should return false if action is not AlsoToMain", () => {
assert.isFalse(au.isSendToMain({ type: "FOO" }));
});
});
});
describe("AlsoToOneContent", () => {
it("should create the right action", () => {
const action = { type: "FOO", data: "BAR" };
const targetId = "abc123";
const newAction = ac.AlsoToOneContent(action, targetId);
assert.deepEqual(newAction, {
type: "FOO",
data: "BAR",
meta: {
from: MAIN_MESSAGE_TYPE,
to: CONTENT_MESSAGE_TYPE,
toTarget: targetId,
},
});
});
it("should throw if no targetId is provided", () => {
assert.throws(() => {
ac.AlsoToOneContent({ type: "FOO" });
});
});
describe("isSendToOneContent", () => {
it("should return true if action is AlsoToOneContent", () => {
const newAction = ac.AlsoToOneContent({ type: "FOO" }, "foo123");
assert.isTrue(au.isSendToOneContent(newAction));
});
it("should return false if action is not AlsoToMain", () => {
assert.isFalse(au.isSendToOneContent({ type: "FOO" }));
assert.isFalse(
au.isSendToOneContent(ac.BroadcastToContent({ type: "FOO" }))
);
});
});
describe("isFromMain", () => {
it("should return true if action is AlsoToOneContent", () => {
const newAction = ac.AlsoToOneContent({ type: "FOO" }, "foo123");
assert.isTrue(au.isFromMain(newAction));
});
it("should return true if action is BroadcastToContent", () => {
const newAction = ac.BroadcastToContent({ type: "FOO" });
assert.isTrue(au.isFromMain(newAction));
});
it("should return false if action is AlsoToMain", () => {
const newAction = ac.AlsoToMain({ type: "FOO" });
assert.isFalse(au.isFromMain(newAction));
});
});
});
describe("BroadcastToContent", () => {
it("should create the right action", () => {
const action = { type: "FOO", data: "BAR" };
const newAction = ac.BroadcastToContent(action);
assert.deepEqual(newAction, {
type: "FOO",
data: "BAR",
meta: { from: MAIN_MESSAGE_TYPE, to: CONTENT_MESSAGE_TYPE },
});
});
describe("isBroadcastToContent", () => {
it("should return true if action is BroadcastToContent", () => {
assert.isTrue(
au.isBroadcastToContent(ac.BroadcastToContent({ type: "FOO" }))
);
});
it("should return false if action is not BroadcastToContent", () => {
assert.isFalse(au.isBroadcastToContent({ type: "FOO" }));
assert.isFalse(
au.isBroadcastToContent(
ac.AlsoToOneContent({ type: "FOO" }, "foo123")
)
);
});
});
});
describe("AlsoToPreloaded", () => {
it("should create the right action", () => {
const action = { type: "FOO", data: "BAR" };
const newAction = ac.AlsoToPreloaded(action);
assert.deepEqual(newAction, {
type: "FOO",
data: "BAR",
meta: { from: MAIN_MESSAGE_TYPE, to: PRELOAD_MESSAGE_TYPE },
});
});
});
describe("isSendToPreloaded", () => {
it("should return true if action is AlsoToPreloaded", () => {
assert.isTrue(au.isSendToPreloaded(ac.AlsoToPreloaded({ type: "FOO" })));
});
it("should return false if action is not AlsoToPreloaded", () => {
assert.isFalse(au.isSendToPreloaded({ type: "FOO" }));
assert.isFalse(
au.isSendToPreloaded(ac.BroadcastToContent({ type: "FOO" }))
);
});
});
describe("UserEvent", () => {
it("should include the given data", () => {
const data = { action: "foo" };
assert.equal(ac.UserEvent(data).data, data);
});
it("should wrap with AlsoToMain", () => {
const action = ac.UserEvent({ action: "foo" });
assert.isTrue(au.isSendToMain(action), "isSendToMain");
});
});
describe("ASRouterUserEvent", () => {
it("should include the given data", () => {
const data = { action: "foo" };
assert.equal(ac.ASRouterUserEvent(data).data, data);
});
it("should wrap with AlsoToMain", () => {
const action = ac.ASRouterUserEvent({ action: "foo" });
assert.isTrue(au.isSendToMain(action), "isSendToMain");
});
});
describe("UndesiredEvent", () => {
it("should include the given data", () => {
const data = { action: "foo" };
assert.equal(ac.UndesiredEvent(data).data, data);
});
it("should wrap with AlsoToMain if in UI code", () => {
assert.isTrue(
au.isSendToMain(ac.UndesiredEvent({ action: "foo" })),
"isSendToMain"
);
});
it("should not wrap with AlsoToMain if not in UI code", () => {
const action = ac.UndesiredEvent({ action: "foo" }, BACKGROUND_PROCESS);
assert.isFalse(au.isSendToMain(action), "isSendToMain");
});
});
describe("PerfEvent", () => {
it("should include the right data", () => {
const data = { action: "foo" };
assert.equal(ac.UndesiredEvent(data).data, data);
});
it("should wrap with AlsoToMain if in UI code", () => {
assert.isTrue(
au.isSendToMain(ac.PerfEvent({ action: "foo" })),
"isSendToMain"
);
});
it("should not wrap with AlsoToMain if not in UI code", () => {
const action = ac.PerfEvent({ action: "foo" }, BACKGROUND_PROCESS);
assert.isFalse(au.isSendToMain(action), "isSendToMain");
});
});
describe("ImpressionStats", () => {
it("should include the right data", () => {
const data = { action: "foo" };
assert.equal(ac.ImpressionStats(data).data, data);
});
it("should wrap with AlsoToMain if in UI code", () => {
assert.isTrue(
au.isSendToMain(ac.ImpressionStats({ action: "foo" })),
"isSendToMain"
);
});
it("should not wrap with AlsoToMain if not in UI code", () => {
const action = ac.ImpressionStats({ action: "foo" }, BACKGROUND_PROCESS);
assert.isFalse(au.isSendToMain(action), "isSendToMain");
});
});
describe("WebExtEvent", () => {
it("should set the provided type", () => {
const action = ac.WebExtEvent(at.WEBEXT_CLICK, {
source: "MyExtension",
url: "foo.com",
});
assert.equal(action.type, at.WEBEXT_CLICK);
});
it("should set the provided data", () => {
const data = { source: "MyExtension", url: "foo.com" };
const action = ac.WebExtEvent(at.WEBEXT_CLICK, data);
assert.equal(action.data, data);
});
it("should throw if the 'source' property is missing", () => {
assert.throws(() => {
ac.WebExtEvent(at.WEBEXT_CLICK, {});
});
});
});
});
describe("ActionUtils", () => {
describe("getPortIdOfSender", () => {
it("should return the PortID from a AlsoToMain action", () => {
const portID = "foo123";
const result = au.getPortIdOfSender(
ac.AlsoToMain({ type: "FOO" }, portID)
);
assert.equal(result, portID);
});
});
});
================================================
FILE: test/unit/common/Dedupe.test.js
================================================
import { Dedupe } from "common/Dedupe.jsm";
describe("Dedupe", () => {
let instance;
beforeEach(() => {
instance = new Dedupe();
});
describe("group", () => {
it("should remove duplicates inside the groups", () => {
const beforeItems = [[1, 1, 1], [2, 2, 2], [3, 3, 3]];
const afterItems = [[1], [2], [3]];
assert.deepEqual(instance.group(...beforeItems), afterItems);
});
it("should remove duplicates between groups, favouring earlier groups", () => {
const beforeItems = [[1, 2, 3], [2, 3, 4], [3, 4, 5]];
const afterItems = [[1, 2, 3], [4], [5]];
assert.deepEqual(instance.group(...beforeItems), afterItems);
});
it("should remove duplicates from groups of objects", () => {
instance = new Dedupe(item => item.id);
const beforeItems = [
[{ id: 1 }, { id: 1 }, { id: 2 }],
[{ id: 1 }, { id: 3 }, { id: 2 }],
[{ id: 1 }, { id: 2 }, { id: 5 }],
];
const afterItems = [[{ id: 1 }, { id: 2 }], [{ id: 3 }], [{ id: 5 }]];
assert.deepEqual(instance.group(...beforeItems), afterItems);
});
});
});
================================================
FILE: test/unit/common/PerfService.test.js
================================================
/* globals assert, beforeEach, describe, it */
import { _PerfService } from "common/PerfService.jsm";
import { FakePerformance } from "test/unit/utils.js";
let perfService;
describe("_PerfService", () => {
let sandbox;
let fakePerfObj;
beforeEach(() => {
sandbox = sinon.createSandbox();
fakePerfObj = new FakePerformance();
perfService = new _PerfService({ performanceObj: fakePerfObj });
});
afterEach(() => {
sandbox.restore();
});
describe("#absNow", () => {
it("should return a number > the time origin", () => {
const absNow = perfService.absNow();
assert.isAbove(absNow, perfService.timeOrigin);
});
});
describe("#getEntriesByName", () => {
it("should call getEntriesByName on the appropriate Window.performance", () => {
sandbox.spy(fakePerfObj, "getEntriesByName");
perfService.getEntriesByName("monkey", "mark");
assert.calledOnce(fakePerfObj.getEntriesByName);
assert.calledWithExactly(fakePerfObj.getEntriesByName, "monkey", "mark");
});
it("should return entries with the given name", () => {
sandbox.spy(fakePerfObj, "getEntriesByName");
perfService.mark("monkey");
perfService.mark("dog");
let marks = perfService.getEntriesByName("monkey", "mark");
assert.isArray(marks);
assert.lengthOf(marks, 1);
assert.propertyVal(marks[0], "name", "monkey");
});
});
describe("#getMostRecentAbsMarkStartByName", () => {
it("should throw an error if there is no mark with the given name", () => {
function bogusGet() {
perfService.getMostRecentAbsMarkStartByName("rheeeet");
}
assert.throws(bogusGet, Error, /No marks with the name/);
});
it("should return the Number from the most recent mark with the given name + the time origin", () => {
perfService.mark("dog");
perfService.mark("dog");
let absMarkStart = perfService.getMostRecentAbsMarkStartByName("dog");
// 2 because we want the result of the 2nd call to mark, and an instance
// of FakePerformance just returns the number of time mark has been
// called.
assert.equal(absMarkStart - perfService.timeOrigin, 2);
});
});
describe("#mark", () => {
it("should call the wrapped version of mark", () => {
sandbox.spy(fakePerfObj, "mark");
perfService.mark("monkey");
assert.calledOnce(fakePerfObj.mark);
assert.calledWithExactly(fakePerfObj.mark, "monkey");
});
});
describe("#timeOrigin", () => {
it("should get the origin of the wrapped performance object", () => {
assert.equal(perfService.timeOrigin, fakePerfObj.timeOrigin);
});
});
});
================================================
FILE: test/unit/common/Reducers.test.js
================================================
import { INITIAL_STATE, insertPinned, reducers } from "common/Reducers.jsm";
const {
TopSites,
App,
Snippets,
Prefs,
Dialog,
Sections,
Pocket,
DiscoveryStream,
Search,
ASRouter,
} = reducers;
import { actionTypes as at } from "common/Actions.jsm";
describe("Reducers", () => {
describe("App", () => {
it("should return the initial state", () => {
const nextState = App(undefined, { type: "FOO" });
assert.equal(nextState, INITIAL_STATE.App);
});
it("should set initialized to true on INIT", () => {
const nextState = App(undefined, { type: "INIT" });
assert.propertyVal(nextState, "initialized", true);
});
});
describe("TopSites", () => {
it("should return the initial state", () => {
const nextState = TopSites(undefined, { type: "FOO" });
assert.equal(nextState, INITIAL_STATE.TopSites);
});
it("should add top sites on TOP_SITES_UPDATED", () => {
const newRows = [{ url: "foo.com" }, { url: "bar.com" }];
const nextState = TopSites(undefined, {
type: at.TOP_SITES_UPDATED,
data: { links: newRows },
});
assert.equal(nextState.rows, newRows);
});
it("should not update state for empty action.data on TOP_SITES_UPDATED", () => {
const nextState = TopSites(undefined, { type: at.TOP_SITES_UPDATED });
assert.equal(nextState, INITIAL_STATE.TopSites);
});
it("should initialize prefs on TOP_SITES_UPDATED", () => {
const nextState = TopSites(undefined, {
type: at.TOP_SITES_UPDATED,
data: { links: [], pref: "foo" },
});
assert.equal(nextState.pref, "foo");
});
it("should pass prevState.prefs if not present in TOP_SITES_UPDATED", () => {
const nextState = TopSites(
{ prefs: "foo" },
{ type: at.TOP_SITES_UPDATED, data: { links: [] } }
);
assert.equal(nextState.prefs, "foo");
});
it("should set editForm.site to action.data on TOP_SITES_EDIT", () => {
const data = { index: 7 };
const nextState = TopSites(undefined, { type: at.TOP_SITES_EDIT, data });
assert.equal(nextState.editForm.index, data.index);
});
it("should set editForm to null on TOP_SITES_CANCEL_EDIT", () => {
const nextState = TopSites(undefined, { type: at.TOP_SITES_CANCEL_EDIT });
assert.isNull(nextState.editForm);
});
it("should preserve the editForm.index", () => {
const actionTypes = [
at.PREVIEW_RESPONSE,
at.PREVIEW_REQUEST,
at.PREVIEW_REQUEST_CANCEL,
];
actionTypes.forEach(type => {
const oldState = { editForm: { index: 0, previewUrl: "foo" } };
const action = { type, data: { url: "foo" } };
const nextState = TopSites(oldState, action);
assert.equal(nextState.editForm.index, 0);
});
});
it("should set previewResponse on PREVIEW_RESPONSE", () => {
const oldState = { editForm: { previewUrl: "url" } };
const action = {
type: at.PREVIEW_RESPONSE,
data: { preview: "data:123", url: "url" },
};
const nextState = TopSites(oldState, action);
assert.propertyVal(nextState.editForm, "previewResponse", "data:123");
});
it("should return previous state if action url does not match expected", () => {
const oldState = { editForm: { previewUrl: "foo" } };
const action = { type: at.PREVIEW_RESPONSE, data: { url: "bar" } };
const nextState = TopSites(oldState, action);
assert.equal(nextState, oldState);
});
it("should return previous state if editForm is not set", () => {
const actionTypes = [
at.PREVIEW_RESPONSE,
at.PREVIEW_REQUEST,
at.PREVIEW_REQUEST_CANCEL,
];
actionTypes.forEach(type => {
const oldState = { editForm: null };
const action = { type, data: { url: "bar" } };
const nextState = TopSites(oldState, action);
assert.equal(nextState, oldState, type);
});
});
it("should set previewResponse to null on PREVIEW_REQUEST", () => {
const oldState = { editForm: { previewResponse: "foo" } };
const action = { type: at.PREVIEW_REQUEST, data: {} };
const nextState = TopSites(oldState, action);
assert.propertyVal(nextState.editForm, "previewResponse", null);
});
it("should set previewUrl on PREVIEW_REQUEST", () => {
const oldState = { editForm: {} };
const action = { type: at.PREVIEW_REQUEST, data: { url: "bar" } };
const nextState = TopSites(oldState, action);
assert.propertyVal(nextState.editForm, "previewUrl", "bar");
});
it("should add screenshots for SCREENSHOT_UPDATED", () => {
const oldState = { rows: [{ url: "foo.com" }, { url: "bar.com" }] };
const action = {
type: at.SCREENSHOT_UPDATED,
data: { url: "bar.com", screenshot: "data:123" },
};
const nextState = TopSites(oldState, action);
assert.deepEqual(nextState.rows, [
{ url: "foo.com" },
{ url: "bar.com", screenshot: "data:123" },
]);
});
it("should not modify rows if nothing matches the url for SCREENSHOT_UPDATED", () => {
const oldState = { rows: [{ url: "foo.com" }, { url: "bar.com" }] };
const action = {
type: at.SCREENSHOT_UPDATED,
data: { url: "baz.com", screenshot: "data:123" },
};
const nextState = TopSites(oldState, action);
assert.deepEqual(nextState, oldState);
});
it("should bookmark an item on PLACES_BOOKMARK_ADDED", () => {
const oldState = { rows: [{ url: "foo.com" }, { url: "bar.com" }] };
const action = {
type: at.PLACES_BOOKMARK_ADDED,
data: {
url: "bar.com",
bookmarkGuid: "bookmark123",
bookmarkTitle: "Title for bar.com",
dateAdded: 1234567,
},
};
const nextState = TopSites(oldState, action);
const [, newRow] = nextState.rows;
// new row has bookmark data
assert.equal(newRow.url, action.data.url);
assert.equal(newRow.bookmarkGuid, action.data.bookmarkGuid);
assert.equal(newRow.bookmarkTitle, action.data.bookmarkTitle);
assert.equal(newRow.bookmarkDateCreated, action.data.dateAdded);
// old row is unchanged
assert.equal(nextState.rows[0], oldState.rows[0]);
});
it("should not update state for empty action.data on PLACES_BOOKMARK_ADDED", () => {
const nextState = TopSites(undefined, { type: at.PLACES_BOOKMARK_ADDED });
assert.equal(nextState, INITIAL_STATE.TopSites);
});
it("should remove a bookmark on PLACES_BOOKMARK_REMOVED", () => {
const oldState = {
rows: [
{ url: "foo.com" },
{
url: "bar.com",
bookmarkGuid: "bookmark123",
bookmarkTitle: "Title for bar.com",
dateAdded: 123456,
},
],
};
const action = {
type: at.PLACES_BOOKMARK_REMOVED,
data: { url: "bar.com" },
};
const nextState = TopSites(oldState, action);
const [, newRow] = nextState.rows;
// new row no longer has bookmark data
assert.equal(newRow.url, oldState.rows[1].url);
assert.isUndefined(newRow.bookmarkGuid);
assert.isUndefined(newRow.bookmarkTitle);
assert.isUndefined(newRow.bookmarkDateCreated);
// old row is unchanged
assert.deepEqual(nextState.rows[0], oldState.rows[0]);
});
it("should not update state for empty action.data on PLACES_BOOKMARK_REMOVED", () => {
const nextState = TopSites(undefined, {
type: at.PLACES_BOOKMARK_REMOVED,
});
assert.equal(nextState, INITIAL_STATE.TopSites);
});
it("should update prefs on TOP_SITES_PREFS_UPDATED", () => {
const state = TopSites(
{},
{ type: at.TOP_SITES_PREFS_UPDATED, data: { pref: "foo" } }
);
assert.equal(state.pref, "foo");
});
it("should not update state for empty action.data on PLACES_LINK_DELETED", () => {
const nextState = TopSites(undefined, { type: at.PLACES_LINK_DELETED });
assert.equal(nextState, INITIAL_STATE.TopSites);
});
it("should remove the site on PLACES_LINK_DELETED", () => {
const oldState = { rows: [{ url: "foo.com" }, { url: "bar.com" }] };
const deleteAction = {
type: at.PLACES_LINK_DELETED,
data: { url: "foo.com" },
};
const nextState = TopSites(oldState, deleteAction);
assert.deepEqual(nextState.rows, [{ url: "bar.com" }]);
});
it("should set showSearchShortcutsForm to true on TOP_SITES_OPEN_SEARCH_SHORTCUTS_MODAL", () => {
const data = { index: 7 };
const nextState = TopSites(undefined, {
type: at.TOP_SITES_OPEN_SEARCH_SHORTCUTS_MODAL,
data,
});
assert.isTrue(nextState.showSearchShortcutsForm);
});
it("should set showSearchShortcutsForm to false on TOP_SITES_CLOSE_SEARCH_SHORTCUTS_MODAL", () => {
const nextState = TopSites(undefined, {
type: at.TOP_SITES_CLOSE_SEARCH_SHORTCUTS_MODAL,
});
assert.isFalse(nextState.showSearchShortcutsForm);
});
it("should update searchShortcuts on UPDATE_SEARCH_SHORTCUTS", () => {
const shortcuts = [
{
keyword: "@google",
shortURL: "google",
url: "https://google.com",
searchIdentifier: /^google/,
},
{
keyword: "@baidu",
shortURL: "baidu",
url: "https://baidu.com",
searchIdentifier: /^baidu/,
},
];
const nextState = TopSites(undefined, {
type: at.UPDATE_SEARCH_SHORTCUTS,
data: { searchShortcuts: shortcuts },
});
assert.deepEqual(shortcuts, nextState.searchShortcuts);
});
it("should remove all content on SNIPPETS_PREVIEW_MODE", () => {
const oldState = { rows: [{ url: "foo.com" }, { url: "bar.com" }] };
const nextState = TopSites(oldState, { type: at.SNIPPETS_PREVIEW_MODE });
assert.lengthOf(nextState.rows, 0);
});
});
describe("Prefs", () => {
function prevState(custom = {}) {
return Object.assign({}, INITIAL_STATE.Prefs, custom);
}
it("should have the correct initial state", () => {
const state = Prefs(undefined, {});
assert.deepEqual(state, INITIAL_STATE.Prefs);
});
describe("PREFS_INITIAL_VALUES", () => {
it("should return a new object", () => {
const state = Prefs(undefined, {
type: at.PREFS_INITIAL_VALUES,
data: {},
});
assert.notEqual(
INITIAL_STATE.Prefs,
state,
"should not modify INITIAL_STATE"
);
});
it("should set initalized to true", () => {
const state = Prefs(undefined, {
type: at.PREFS_INITIAL_VALUES,
data: {},
});
assert.isTrue(state.initialized);
});
it("should set .values", () => {
const newValues = { foo: 1, bar: 2 };
const state = Prefs(undefined, {
type: at.PREFS_INITIAL_VALUES,
data: newValues,
});
assert.equal(state.values, newValues);
});
});
describe("PREF_CHANGED", () => {
it("should return a new Prefs object", () => {
const state = Prefs(undefined, {
type: at.PREF_CHANGED,
data: { name: "foo", value: 2 },
});
assert.notEqual(
INITIAL_STATE.Prefs,
state,
"should not modify INITIAL_STATE"
);
});
it("should set the changed pref", () => {
const state = Prefs(prevState({ foo: 1 }), {
type: at.PREF_CHANGED,
data: { name: "foo", value: 2 },
});
assert.equal(state.values.foo, 2);
});
it("should return a new .pref object instead of mutating", () => {
const oldState = prevState({ foo: 1 });
const state = Prefs(oldState, {
type: at.PREF_CHANGED,
data: { name: "foo", value: 2 },
});
assert.notEqual(oldState.values, state.values);
});
});
});
describe("Dialog", () => {
it("should return INITIAL_STATE by default", () => {
assert.equal(
INITIAL_STATE.Dialog,
Dialog(undefined, { type: "non_existent" })
);
});
it("should toggle visible to true on DIALOG_OPEN", () => {
const action = { type: at.DIALOG_OPEN };
const nextState = Dialog(INITIAL_STATE.Dialog, action);
assert.isTrue(nextState.visible);
});
it("should pass url data on DIALOG_OPEN", () => {
const action = { type: at.DIALOG_OPEN, data: "some url" };
const nextState = Dialog(INITIAL_STATE.Dialog, action);
assert.equal(nextState.data, action.data);
});
it("should toggle visible to false on DIALOG_CANCEL", () => {
const action = { type: at.DIALOG_CANCEL, data: "some url" };
const nextState = Dialog(INITIAL_STATE.Dialog, action);
assert.isFalse(nextState.visible);
});
it("should return inital state on DELETE_HISTORY_URL", () => {
const action = { type: at.DELETE_HISTORY_URL };
const nextState = Dialog(INITIAL_STATE.Dialog, action);
assert.deepEqual(INITIAL_STATE.Dialog, nextState);
});
});
describe("Sections", () => {
let oldState;
beforeEach(() => {
oldState = new Array(5).fill(null).map((v, i) => ({
id: `foo_bar_${i}`,
title: `Foo Bar ${i}`,
initialized: false,
rows: [
{ url: "www.foo.bar", pocket_id: 123 },
{ url: "www.other.url" },
],
order: i,
type: "history",
}));
});
it("should return INITIAL_STATE by default", () => {
assert.equal(
INITIAL_STATE.Sections,
Sections(undefined, { type: "non_existent" })
);
});
it("should remove the correct section on SECTION_DEREGISTER", () => {
const newState = Sections(oldState, {
type: at.SECTION_DEREGISTER,
data: "foo_bar_2",
});
assert.lengthOf(newState, 4);
const expectedNewState = oldState.splice(2, 1) && oldState;
assert.deepEqual(newState, expectedNewState);
});
it("should add a section on SECTION_REGISTER if it doesn't already exist", () => {
const action = {
type: at.SECTION_REGISTER,
data: { id: "foo_bar_5", title: "Foo Bar 5" },
};
const newState = Sections(oldState, action);
assert.lengthOf(newState, 6);
const insertedSection = newState.find(
section => section.id === "foo_bar_5"
);
assert.propertyVal(insertedSection, "title", action.data.title);
});
it("should set newSection.rows === [] if no rows are provided on SECTION_REGISTER", () => {
const action = {
type: at.SECTION_REGISTER,
data: { id: "foo_bar_5", title: "Foo Bar 5" },
};
const newState = Sections(oldState, action);
const insertedSection = newState.find(
section => section.id === "foo_bar_5"
);
assert.deepEqual(insertedSection.rows, []);
});
it("should update a section on SECTION_REGISTER if it already exists", () => {
const NEW_TITLE = "New Title";
const action = {
type: at.SECTION_REGISTER,
data: { id: "foo_bar_2", title: NEW_TITLE },
};
const newState = Sections(oldState, action);
assert.lengthOf(newState, 5);
const updatedSection = newState.find(
section => section.id === "foo_bar_2"
);
assert.ok(updatedSection && updatedSection.title === NEW_TITLE);
});
it("should set initialized to false on SECTION_REGISTER if there are no rows", () => {
const NEW_TITLE = "New Title";
const action = {
type: at.SECTION_REGISTER,
data: { id: "bloop", title: NEW_TITLE },
};
const newState = Sections(oldState, action);
const updatedSection = newState.find(section => section.id === "bloop");
assert.propertyVal(updatedSection, "initialized", false);
});
it("should set initialized to true on SECTION_REGISTER if there are rows", () => {
const NEW_TITLE = "New Title";
const action = {
type: at.SECTION_REGISTER,
data: { id: "bloop", title: NEW_TITLE, rows: [{}, {}] },
};
const newState = Sections(oldState, action);
const updatedSection = newState.find(section => section.id === "bloop");
assert.propertyVal(updatedSection, "initialized", true);
});
it("should have no effect on SECTION_UPDATE if the id doesn't exist", () => {
const action = {
type: at.SECTION_UPDATE,
data: { id: "fake_id", data: "fake_data" },
};
const newState = Sections(oldState, action);
assert.deepEqual(oldState, newState);
});
it("should update the section with the correct data on SECTION_UPDATE", () => {
const FAKE_DATA = { rows: ["some", "fake", "data"], foo: "bar" };
const action = {
type: at.SECTION_UPDATE,
data: Object.assign(FAKE_DATA, { id: "foo_bar_2" }),
};
const newState = Sections(oldState, action);
const updatedSection = newState.find(
section => section.id === "foo_bar_2"
);
assert.include(updatedSection, FAKE_DATA);
});
it("should set initialized to true on SECTION_UPDATE if rows is defined on action.data", () => {
const data = { rows: [], id: "foo_bar_2" };
const action = { type: at.SECTION_UPDATE, data };
const newState = Sections(oldState, action);
const updatedSection = newState.find(
section => section.id === "foo_bar_2"
);
assert.propertyVal(updatedSection, "initialized", true);
});
it("should retain pinned cards on SECTION_UPDATE", () => {
const ROW = { id: "row" };
let newState = Sections(oldState, {
type: at.SECTION_UPDATE,
data: Object.assign({ rows: [ROW] }, { id: "foo_bar_2" }),
});
let updatedSection = newState.find(section => section.id === "foo_bar_2");
assert.deepEqual(updatedSection.rows, [ROW]);
const PINNED_ROW = { id: "pinned", pinned: true, guid: "pinned" };
newState = Sections(newState, {
type: at.SECTION_UPDATE,
data: Object.assign({ rows: [PINNED_ROW] }, { id: "foo_bar_2" }),
});
updatedSection = newState.find(section => section.id === "foo_bar_2");
assert.deepEqual(updatedSection.rows, [PINNED_ROW]);
// Updating the section again should not duplicate pinned cards
newState = Sections(newState, {
type: at.SECTION_UPDATE,
data: Object.assign({ rows: [PINNED_ROW] }, { id: "foo_bar_2" }),
});
updatedSection = newState.find(section => section.id === "foo_bar_2");
assert.deepEqual(updatedSection.rows, [PINNED_ROW]);
// Updating the section should retain pinned card at its index
newState = Sections(newState, {
type: at.SECTION_UPDATE,
data: Object.assign({ rows: [ROW] }, { id: "foo_bar_2" }),
});
updatedSection = newState.find(section => section.id === "foo_bar_2");
assert.deepEqual(updatedSection.rows, [PINNED_ROW, ROW]);
// Clearing/Resetting the section should clear pinned cards
newState = Sections(newState, {
type: at.SECTION_UPDATE,
data: Object.assign({ rows: [] }, { id: "foo_bar_2" }),
});
updatedSection = newState.find(section => section.id === "foo_bar_2");
assert.deepEqual(updatedSection.rows, []);
});
it("should have no effect on SECTION_UPDATE_CARD if the id or url doesn't exist", () => {
const noIdAction = {
type: at.SECTION_UPDATE_CARD,
data: {
id: "non-existent",
url: "www.foo.bar",
options: { title: "New title" },
},
};
const noIdState = Sections(oldState, noIdAction);
const noUrlAction = {
type: at.SECTION_UPDATE_CARD,
data: {
id: "foo_bar_2",
url: "www.non-existent.url",
options: { title: "New title" },
},
};
const noUrlState = Sections(oldState, noUrlAction);
assert.deepEqual(noIdState, oldState);
assert.deepEqual(noUrlState, oldState);
});
it("should update the card with the correct data on SECTION_UPDATE_CARD", () => {
const action = {
type: at.SECTION_UPDATE_CARD,
data: {
id: "foo_bar_2",
url: "www.other.url",
options: { title: "Fake new title" },
},
};
const newState = Sections(oldState, action);
const updatedSection = newState.find(
section => section.id === "foo_bar_2"
);
const updatedCard = updatedSection.rows.find(
card => card.url === "www.other.url"
);
assert.propertyVal(updatedCard, "title", "Fake new title");
});
it("should only update the cards belonging to the right section on SECTION_UPDATE_CARD", () => {
const action = {
type: at.SECTION_UPDATE_CARD,
data: {
id: "foo_bar_2",
url: "www.other.url",
options: { title: "Fake new title" },
},
};
const newState = Sections(oldState, action);
newState.forEach((section, i) => {
if (section.id !== "foo_bar_2") {
assert.deepEqual(section, oldState[i]);
}
});
});
it("should allow action.data to set .initialized", () => {
const data = { rows: [], initialized: false, id: "foo_bar_2" };
const action = { type: at.SECTION_UPDATE, data };
const newState = Sections(oldState, action);
const updatedSection = newState.find(
section => section.id === "foo_bar_2"
);
assert.propertyVal(updatedSection, "initialized", false);
});
it("should dedupe based on dedupeConfigurations", () => {
const site = { url: "foo.com" };
const highlights = { rows: [site], id: "highlights" };
const topstories = { rows: [site], id: "topstories" };
const dedupeConfigurations = [
{ id: "topstories", dedupeFrom: ["highlights"] },
];
const action = { data: { dedupeConfigurations }, type: "SECTION_UPDATE" };
const state = [highlights, topstories];
const nextState = Sections(state, action);
assert.equal(nextState.find(s => s.id === "highlights").rows.length, 1);
assert.equal(nextState.find(s => s.id === "topstories").rows.length, 0);
});
it("should remove blocked and deleted urls from all rows in all sections", () => {
const blockAction = {
type: at.PLACES_LINK_BLOCKED,
data: { url: "www.foo.bar" },
};
const deleteAction = {
type: at.PLACES_LINK_DELETED,
data: { url: "www.foo.bar" },
};
const newBlockState = Sections(oldState, blockAction);
const newDeleteState = Sections(oldState, deleteAction);
newBlockState.concat(newDeleteState).forEach(section => {
assert.deepEqual(section.rows, [{ url: "www.other.url" }]);
});
});
it("should not update state for empty action.data on PLACES_LINK_DELETED", () => {
const nextState = Sections(undefined, { type: at.PLACES_LINK_DELETED });
assert.equal(nextState, INITIAL_STATE.Sections);
});
it("should remove all removed pocket urls", () => {
const removeAction = {
type: at.DELETE_FROM_POCKET,
data: { pocket_id: 123 },
};
const newBlockState = Sections(oldState, removeAction);
newBlockState.forEach(section => {
assert.deepEqual(section.rows, [{ url: "www.other.url" }]);
});
});
it("should archive all archived pocket urls", () => {
const removeAction = {
type: at.ARCHIVE_FROM_POCKET,
data: { pocket_id: 123 },
};
const newBlockState = Sections(oldState, removeAction);
newBlockState.forEach(section => {
assert.deepEqual(section.rows, [{ url: "www.other.url" }]);
});
});
it("should not update state for empty action.data on PLACES_BOOKMARK_ADDED", () => {
const nextState = Sections(undefined, { type: at.PLACES_BOOKMARK_ADDED });
assert.equal(nextState, INITIAL_STATE.Sections);
});
it("should bookmark an item when PLACES_BOOKMARK_ADDED is received", () => {
const action = {
type: at.PLACES_BOOKMARK_ADDED,
data: {
url: "www.foo.bar",
bookmarkGuid: "bookmark123",
bookmarkTitle: "Title for bar.com",
dateAdded: 1234567,
},
};
const nextState = Sections(oldState, action);
// check a section to ensure the correct url was bookmarked
const [newRow, oldRow] = nextState[0].rows;
// new row has bookmark data
assert.equal(newRow.url, action.data.url);
assert.equal(newRow.type, "bookmark");
assert.equal(newRow.bookmarkGuid, action.data.bookmarkGuid);
assert.equal(newRow.bookmarkTitle, action.data.bookmarkTitle);
assert.equal(newRow.bookmarkDateCreated, action.data.dateAdded);
// old row is unchanged
assert.equal(oldRow, oldState[0].rows[1]);
});
it("should not update state for empty action.data on PLACES_BOOKMARK_REMOVED", () => {
const nextState = Sections(undefined, {
type: at.PLACES_BOOKMARK_REMOVED,
});
assert.equal(nextState, INITIAL_STATE.Sections);
});
it("should remove the bookmark when PLACES_BOOKMARK_REMOVED is received", () => {
const action = {
type: at.PLACES_BOOKMARK_REMOVED,
data: {
url: "www.foo.bar",
bookmarkGuid: "bookmark123",
},
};
// add some bookmark data for the first url in rows
oldState.forEach(item => {
item.rows[0].bookmarkGuid = "bookmark123";
item.rows[0].bookmarkTitle = "Title for bar.com";
item.rows[0].bookmarkDateCreated = 1234567;
item.rows[0].type = "bookmark";
});
const nextState = Sections(oldState, action);
// check a section to ensure the correct bookmark was removed
const [newRow, oldRow] = nextState[0].rows;
// new row isn't a bookmark
assert.equal(newRow.url, action.data.url);
assert.equal(newRow.type, "history");
assert.isUndefined(newRow.bookmarkGuid);
assert.isUndefined(newRow.bookmarkTitle);
assert.isUndefined(newRow.bookmarkDateCreated);
// old row is unchanged
assert.equal(oldRow, oldState[0].rows[1]);
});
it("should not update state for empty action.data on PLACES_SAVED_TO_POCKET", () => {
const nextState = Sections(undefined, {
type: at.PLACES_SAVED_TO_POCKET,
});
assert.equal(nextState, INITIAL_STATE.Sections);
});
it("should add a pocked item on PLACES_SAVED_TO_POCKET", () => {
const action = {
type: at.PLACES_SAVED_TO_POCKET,
data: {
url: "www.foo.bar",
pocket_id: 1234,
title: "Title for bar.com",
},
};
const nextState = Sections(oldState, action);
// check a section to ensure the correct url was saved to pocket
const [newRow, oldRow] = nextState[0].rows;
// new row has pocket data
assert.equal(newRow.url, action.data.url);
assert.equal(newRow.type, "pocket");
assert.equal(newRow.pocket_id, action.data.pocket_id);
assert.equal(newRow.title, action.data.title);
// old row is unchanged
assert.equal(oldRow, oldState[0].rows[1]);
});
it("should remove all content on SNIPPETS_PREVIEW_MODE", () => {
const previewMode = { type: at.SNIPPETS_PREVIEW_MODE };
const newState = Sections(oldState, previewMode);
newState.forEach(section => {
assert.lengthOf(section.rows, 0);
});
});
});
describe("#insertPinned", () => {
let links;
beforeEach(() => {
links = new Array(12).fill(null).map((v, i) => ({ url: `site${i}.com` }));
});
it("should place pinned links where they belong", () => {
const pinned = [
{ url: "http://github.com/mozilla/activity-stream", title: "moz/a-s" },
{ url: "http://example.com", title: "example" },
];
const result = insertPinned(links, pinned);
for (let index of [0, 1]) {
assert.equal(result[index].url, pinned[index].url);
assert.ok(result[index].isPinned);
assert.equal(result[index].pinIndex, index);
}
assert.deepEqual(result.slice(2), links);
});
it("should handle empty slots in the pinned list", () => {
const pinned = [
null,
{ url: "http://github.com/mozilla/activity-stream", title: "moz/a-s" },
null,
null,
{ url: "http://example.com", title: "example" },
];
const result = insertPinned(links, pinned);
for (let index of [1, 4]) {
assert.equal(result[index].url, pinned[index].url);
assert.ok(result[index].isPinned);
assert.equal(result[index].pinIndex, index);
}
result.splice(4, 1);
result.splice(1, 1);
assert.deepEqual(result, links);
});
it("should handle a pinned site past the end of the list of links", () => {
const pinned = [];
pinned[11] = {
url: "http://github.com/mozilla/activity-stream",
title: "moz/a-s",
};
const result = insertPinned([], pinned);
assert.equal(result[11].url, pinned[11].url);
assert.isTrue(result[11].isPinned);
assert.equal(result[11].pinIndex, 11);
});
it("should unpin previously pinned links no longer in the pinned list", () => {
const pinned = [];
links[2].isPinned = true;
links[2].pinIndex = 2;
const result = insertPinned(links, pinned);
assert.notProperty(result[2], "isPinned");
assert.notProperty(result[2], "pinIndex");
});
it("should handle a link present in both the links and pinned list", () => {
const pinned = [links[7]];
const result = insertPinned(links, pinned);
assert.equal(links.length, result.length);
});
it("should not modify the original data", () => {
const pinned = [{ url: "http://example.com" }];
insertPinned(links, pinned);
assert.equal(typeof pinned[0].isPinned, "undefined");
});
});
describe("Snippets", () => {
it("should return INITIAL_STATE by default", () => {
assert.equal(
Snippets(undefined, { type: "some_action" }),
INITIAL_STATE.Snippets
);
});
it("should set initialized to true on a SNIPPETS_DATA action", () => {
const state = Snippets(undefined, { type: at.SNIPPETS_DATA, data: {} });
assert.isTrue(state.initialized);
});
it("should set the snippet data on a SNIPPETS_DATA action", () => {
const data = { snippetsURL: "foo.com", version: 4 };
const state = Snippets(undefined, { type: at.SNIPPETS_DATA, data });
assert.propertyVal(state, "snippetsURL", data.snippetsURL);
assert.propertyVal(state, "version", data.version);
});
it("should reset to the initial state on a SNIPPETS_RESET action", () => {
const state = Snippets(
{ initialized: true, foo: "bar" },
{ type: at.SNIPPETS_RESET }
);
assert.equal(state, INITIAL_STATE.Snippets);
});
it("should set the new blocklist on SNIPPET_BLOCKED", () => {
const state = Snippets(
{ blockList: [] },
{ type: at.SNIPPET_BLOCKED, data: 1 }
);
assert.deepEqual(state.blockList, [1]);
});
it("should clear the blocklist on SNIPPETS_BLOCKLIST_CLEARED", () => {
const state = Snippets(
{ blockList: [1, 2] },
{ type: at.SNIPPETS_BLOCKLIST_CLEARED }
);
assert.deepEqual(state.blockList, []);
});
});
describe("Pocket", () => {
it("should return INITIAL_STATE by default", () => {
assert.equal(
Pocket(undefined, { type: "some_action" }),
INITIAL_STATE.Pocket
);
});
it("should set waitingForSpoc on a POCKET_WAITING_FOR_SPOC action", () => {
const state = Pocket(undefined, {
type: at.POCKET_WAITING_FOR_SPOC,
data: false,
});
assert.isFalse(state.waitingForSpoc);
});
it("should have undefined for initial isUserLoggedIn state", () => {
assert.isNull(Pocket(undefined, { type: "some_action" }).isUserLoggedIn);
});
it("should set isUserLoggedIn to false on a POCKET_LOGGED_IN with null", () => {
const state = Pocket(undefined, {
type: at.POCKET_LOGGED_IN,
data: null,
});
assert.isFalse(state.isUserLoggedIn);
});
it("should set isUserLoggedIn to false on a POCKET_LOGGED_IN with false", () => {
const state = Pocket(undefined, {
type: at.POCKET_LOGGED_IN,
data: false,
});
assert.isFalse(state.isUserLoggedIn);
});
it("should set isUserLoggedIn to true on a POCKET_LOGGED_IN with true", () => {
const state = Pocket(undefined, {
type: at.POCKET_LOGGED_IN,
data: true,
});
assert.isTrue(state.isUserLoggedIn);
});
it("should set pocketCta with correct object on a POCKET_CTA", () => {
const data = {
cta_button: "cta button",
cta_text: "cta text",
cta_url: "https://cta-url.com",
use_cta: true,
};
const state = Pocket(undefined, { type: at.POCKET_CTA, data });
assert.equal(state.pocketCta.ctaButton, data.cta_button);
assert.equal(state.pocketCta.ctaText, data.cta_text);
assert.equal(state.pocketCta.ctaUrl, data.cta_url);
assert.equal(state.pocketCta.useCta, data.use_cta);
});
});
describe("DiscoveryStream", () => {
it("should return INITIAL_STATE by default", () => {
assert.equal(
DiscoveryStream(undefined, { type: "some_action" }),
INITIAL_STATE.DiscoveryStream
);
});
it("should set isPrivacyInfoModalVisible to true with SHOW_PRIVACY_INFO", () => {
const state = DiscoveryStream(undefined, {
type: at.SHOW_PRIVACY_INFO,
});
assert.equal(state.isPrivacyInfoModalVisible, true);
});
it("should set isPrivacyInfoModalVisible to false with HIDE_PRIVACY_INFO", () => {
const state = DiscoveryStream(undefined, {
type: at.HIDE_PRIVACY_INFO,
});
assert.equal(state.isPrivacyInfoModalVisible, false);
});
it("should set layout data with DISCOVERY_STREAM_LAYOUT_UPDATE", () => {
const state = DiscoveryStream(undefined, {
type: at.DISCOVERY_STREAM_LAYOUT_UPDATE,
data: { layout: ["test"], lastUpdated: 123 },
});
assert.equal(state.layout[0], "test");
assert.equal(state.lastUpdated, 123);
});
it("should reset layout data with DISCOVERY_STREAM_LAYOUT_RESET", () => {
const layoutData = { layout: ["test"], lastUpdated: 123 };
const feedsData = {
"https://foo.com/feed1": { lastUpdated: 123, data: [1, 2, 3] },
};
const spocsData = {
lastUpdated: 123,
spocs: [1, 2, 3],
};
let state = DiscoveryStream(undefined, {
type: at.DISCOVERY_STREAM_LAYOUT_UPDATE,
data: layoutData,
});
state = DiscoveryStream(state, {
type: at.DISCOVERY_STREAM_FEEDS_UPDATE,
data: feedsData,
});
state = DiscoveryStream(state, {
type: at.DISCOVERY_STREAM_SPOCS_UPDATE,
data: spocsData,
});
state = DiscoveryStream(state, {
type: at.DISCOVERY_STREAM_LAYOUT_RESET,
});
assert.deepEqual(state, INITIAL_STATE.DiscoveryStream);
});
it("should set config data with DISCOVERY_STREAM_CONFIG_CHANGE", () => {
const state = DiscoveryStream(undefined, {
type: at.DISCOVERY_STREAM_CONFIG_CHANGE,
data: { enabled: true },
});
assert.deepEqual(state.config, { enabled: true });
});
it("should set feeds as loaded with DISCOVERY_STREAM_FEEDS_UPDATE", () => {
const state = DiscoveryStream(undefined, {
type: at.DISCOVERY_STREAM_FEEDS_UPDATE,
});
assert.isTrue(state.feeds.loaded);
});
it("should set spoc_endpoint and spocs_per_domain with DISCOVERY_STREAM_SPOCS_ENDPOINT", () => {
const state = DiscoveryStream(undefined, {
type: at.DISCOVERY_STREAM_SPOCS_ENDPOINT,
data: { url: "foo.com", spocs_per_domain: 2 },
});
assert.equal(state.spocs.spocs_endpoint, "foo.com");
assert.equal(state.spocs.spocs_per_domain, 2);
});
it("should use initial state with DISCOVERY_STREAM_SPOCS_PLACEMENTS", () => {
const state = DiscoveryStream(undefined, {
type: at.DISCOVERY_STREAM_SPOCS_PLACEMENTS,
data: {},
});
assert.deepEqual(state.spocs.placements, []);
});
it("should set placements with DISCOVERY_STREAM_SPOCS_PLACEMENTS", () => {
const state = DiscoveryStream(undefined, {
type: at.DISCOVERY_STREAM_SPOCS_PLACEMENTS,
data: {
placements: [1, 2, 3],
},
});
assert.deepEqual(state.spocs.placements, [1, 2, 3]);
});
it("should set spocs with DISCOVERY_STREAM_SPOCS_UPDATE", () => {
const data = {
lastUpdated: 123,
spocs: [1, 2, 3],
};
const state = DiscoveryStream(undefined, {
type: at.DISCOVERY_STREAM_SPOCS_UPDATE,
data,
});
assert.deepEqual(state.spocs, {
spocs_endpoint: "",
spocs_per_domain: 1,
data: [1, 2, 3],
lastUpdated: 123,
loaded: true,
frequency_caps: [],
blocked: [],
placements: [],
});
});
it("should default to a single spoc placement", () => {
const deleteAction = {
type: at.DISCOVERY_STREAM_LINK_BLOCKED,
data: { url: "https://foo.com" },
};
const oldState = {
spocs: {
data: {
spocs: [{ url: "test-spoc.com" }],
},
loaded: true,
},
feeds: {
data: {},
loaded: true,
},
};
const newState = DiscoveryStream(oldState, deleteAction);
assert.equal(newState.spocs.data.spocs.length, 1);
});
it("should handle no data from DISCOVERY_STREAM_SPOCS_UPDATE", () => {
const data = null;
const state = DiscoveryStream(undefined, {
type: at.DISCOVERY_STREAM_SPOCS_UPDATE,
data,
});
assert.deepEqual(state.spocs, INITIAL_STATE.DiscoveryStream.spocs);
});
it("should add blocked spocs to blocked array with DISCOVERY_STREAM_SPOC_BLOCKED", () => {
const firstState = DiscoveryStream(undefined, {
type: at.DISCOVERY_STREAM_SPOC_BLOCKED,
data: { url: "https://foo.com" },
});
const secondState = DiscoveryStream(firstState, {
type: at.DISCOVERY_STREAM_SPOC_BLOCKED,
data: { url: "https://bar.com" },
});
assert.deepEqual(firstState.spocs.blocked, ["https://foo.com"]);
assert.deepEqual(secondState.spocs.blocked, [
"https://foo.com",
"https://bar.com",
]);
});
it("should not update state for empty action.data on DISCOVERY_STREAM_LINK_BLOCKED", () => {
const newState = DiscoveryStream(undefined, {
type: at.DISCOVERY_STREAM_LINK_BLOCKED,
});
assert.equal(newState, INITIAL_STATE.DiscoveryStream);
});
it("should not update state if feeds are not loaded", () => {
const deleteAction = {
type: at.DISCOVERY_STREAM_LINK_BLOCKED,
data: { url: "foo.com" },
};
const newState = DiscoveryStream(undefined, deleteAction);
assert.equal(newState, INITIAL_STATE.DiscoveryStream);
});
it("should not update state if spocs and feeds data is undefined", () => {
const deleteAction = {
type: at.DISCOVERY_STREAM_LINK_BLOCKED,
data: { url: "foo.com" },
};
const oldState = {
spocs: {
data: {},
loaded: true,
placements: [{ name: "spocs" }],
},
feeds: {
data: {},
loaded: true,
},
};
const newState = DiscoveryStream(oldState, deleteAction);
assert.deepEqual(newState, oldState);
});
it("should remove the site on DISCOVERY_STREAM_LINK_BLOCKED from spocs if feeds data is empty", () => {
const deleteAction = {
type: at.DISCOVERY_STREAM_LINK_BLOCKED,
data: { url: "https://foo.com" },
};
const oldState = {
spocs: {
data: {
spocs: [{ url: "https://foo.com" }, { url: "test-spoc.com" }],
},
loaded: true,
placements: [{ name: "spocs" }],
},
feeds: {
data: {},
loaded: true,
},
};
const newState = DiscoveryStream(oldState, deleteAction);
assert.deepEqual(newState.spocs.data.spocs, [{ url: "test-spoc.com" }]);
});
it("should remove the site on DISCOVERY_STREAM_LINK_BLOCKED from feeds if spocs data is empty", () => {
const deleteAction = {
type: at.DISCOVERY_STREAM_LINK_BLOCKED,
data: { url: "https://foo.com" },
};
const oldState = {
spocs: {
data: {},
loaded: true,
placements: [{ name: "spocs" }],
},
feeds: {
data: {
"https://foo.com/feed1": {
data: {
recommendations: [
{ url: "https://foo.com" },
{ url: "test.com" },
],
},
},
},
loaded: true,
},
};
const newState = DiscoveryStream(oldState, deleteAction);
assert.deepEqual(
newState.feeds.data["https://foo.com/feed1"].data.recommendations,
[{ url: "test.com" }]
);
});
it("should remove the site on DISCOVERY_STREAM_LINK_BLOCKED from both feeds and spocs", () => {
const oldState = {
feeds: {
data: {
"https://foo.com/feed1": {
data: {
recommendations: [
{ url: "https://foo.com" },
{ url: "test.com" },
],
},
},
},
loaded: true,
},
spocs: {
data: {
spocs: [{ url: "https://foo.com" }, { url: "test-spoc.com" }],
},
loaded: true,
placements: [{ name: "spocs" }],
},
};
const deleteAction = {
type: at.DISCOVERY_STREAM_LINK_BLOCKED,
data: { url: "https://foo.com" },
};
const newState = DiscoveryStream(oldState, deleteAction);
assert.deepEqual(newState.spocs.data.spocs, [{ url: "test-spoc.com" }]);
assert.deepEqual(
newState.feeds.data["https://foo.com/feed1"].data.recommendations,
[{ url: "test.com" }]
);
});
it("should not update state for empty action.data on PLACES_SAVED_TO_POCKET", () => {
const newState = DiscoveryStream(undefined, {
type: at.PLACES_SAVED_TO_POCKET,
});
assert.equal(newState, INITIAL_STATE.DiscoveryStream);
});
it("should add pocket_id on PLACES_SAVED_TO_POCKET in both feeds and spocs", () => {
const oldState = {
feeds: {
data: {
"https://foo.com/feed1": {
data: {
recommendations: [
{ url: "https://foo.com" },
{ url: "test.com" },
],
},
},
},
loaded: true,
},
spocs: {
data: {
spocs: [{ url: "https://foo.com" }, { url: "test-spoc.com" }],
},
placements: [{ name: "spocs" }],
loaded: true,
},
};
const action = {
type: at.PLACES_SAVED_TO_POCKET,
data: {
url: "https://foo.com",
pocket_id: 1234,
open_url: "https://foo-1234",
},
};
const newState = DiscoveryStream(oldState, action);
assert.lengthOf(newState.spocs.data.spocs, 2);
assert.equal(
newState.spocs.data.spocs[0].pocket_id,
action.data.pocket_id
);
assert.equal(newState.spocs.data.spocs[0].open_url, action.data.open_url);
assert.isUndefined(newState.spocs.data.spocs[1].pocket_id);
assert.lengthOf(
newState.feeds.data["https://foo.com/feed1"].data.recommendations,
2
);
assert.equal(
newState.feeds.data["https://foo.com/feed1"].data.recommendations[0]
.pocket_id,
action.data.pocket_id
);
assert.equal(
newState.feeds.data["https://foo.com/feed1"].data.recommendations[0]
.open_url,
action.data.open_url
);
assert.isUndefined(
newState.feeds.data["https://foo.com/feed1"].data.recommendations[1]
.pocket_id
);
});
it("should not update state for empty action.data on DELETE_FROM_POCKET", () => {
const newState = DiscoveryStream(undefined, {
type: at.DELETE_FROM_POCKET,
});
assert.equal(newState, INITIAL_STATE.DiscoveryStream);
});
it("should remove site on DELETE_FROM_POCKET in both feeds and spocs", () => {
const oldState = {
feeds: {
data: {
"https://foo.com/feed1": {
data: {
recommendations: [
{ url: "https://foo.com", pocket_id: 1234 },
{ url: "test.com" },
],
},
},
},
loaded: true,
},
spocs: {
data: {
spocs: [
{ url: "https://foo.com", pocket_id: 1234 },
{ url: "test-spoc.com" },
],
},
loaded: true,
placements: [{ name: "spocs" }],
},
};
const deleteAction = {
type: at.DELETE_FROM_POCKET,
data: {
pocket_id: 1234,
},
};
const newState = DiscoveryStream(oldState, deleteAction);
assert.deepEqual(newState.spocs.data.spocs, [{ url: "test-spoc.com" }]);
assert.deepEqual(
newState.feeds.data["https://foo.com/feed1"].data.recommendations,
[{ url: "test.com" }]
);
});
it("should remove site on ARCHIVE_FROM_POCKET in both feeds and spocs", () => {
const oldState = {
feeds: {
data: {
"https://foo.com/feed1": {
data: {
recommendations: [
{ url: "https://foo.com", pocket_id: 1234 },
{ url: "test.com" },
],
},
},
},
loaded: true,
},
spocs: {
data: {
spocs: [
{ url: "https://foo.com", pocket_id: 1234 },
{ url: "test-spoc.com" },
],
},
loaded: true,
placements: [{ name: "spocs" }],
},
};
const deleteAction = {
type: at.ARCHIVE_FROM_POCKET,
data: {
pocket_id: 1234,
},
};
const newState = DiscoveryStream(oldState, deleteAction);
assert.deepEqual(newState.spocs.data.spocs, [{ url: "test-spoc.com" }]);
assert.deepEqual(
newState.feeds.data["https://foo.com/feed1"].data.recommendations,
[{ url: "test.com" }]
);
});
it("should add boookmark details on PLACES_BOOKMARK_ADDED in both feeds and spocs", () => {
const oldState = {
feeds: {
data: {
"https://foo.com/feed1": {
data: {
recommendations: [
{ url: "https://foo.com" },
{ url: "test.com" },
],
},
},
},
loaded: true,
},
spocs: {
data: {
spocs: [{ url: "https://foo.com" }, { url: "test-spoc.com" }],
},
loaded: true,
placements: [{ name: "spocs" }],
},
};
const bookmarkAction = {
type: at.PLACES_BOOKMARK_ADDED,
data: {
url: "https://foo.com",
bookmarkGuid: "bookmark123",
bookmarkTitle: "Title for bar.com",
dateAdded: 1234567,
},
};
const newState = DiscoveryStream(oldState, bookmarkAction);
assert.lengthOf(newState.spocs.data.spocs, 2);
assert.equal(
newState.spocs.data.spocs[0].bookmarkGuid,
bookmarkAction.data.bookmarkGuid
);
assert.equal(
newState.spocs.data.spocs[0].bookmarkTitle,
bookmarkAction.data.bookmarkTitle
);
assert.isUndefined(newState.spocs.data.spocs[1].bookmarkGuid);
assert.lengthOf(
newState.feeds.data["https://foo.com/feed1"].data.recommendations,
2
);
assert.equal(
newState.feeds.data["https://foo.com/feed1"].data.recommendations[0]
.bookmarkGuid,
bookmarkAction.data.bookmarkGuid
);
assert.equal(
newState.feeds.data["https://foo.com/feed1"].data.recommendations[0]
.bookmarkTitle,
bookmarkAction.data.bookmarkTitle
);
assert.isUndefined(
newState.feeds.data["https://foo.com/feed1"].data.recommendations[1]
.bookmarkGuid
);
});
it("should remove boookmark details on PLACES_BOOKMARK_REMOVED in both feeds and spocs", () => {
const oldState = {
feeds: {
data: {
"https://foo.com/feed1": {
data: {
recommendations: [
{
url: "https://foo.com",
bookmarkGuid: "bookmark123",
bookmarkTitle: "Title for bar.com",
},
{ url: "test.com" },
],
},
},
},
loaded: true,
},
spocs: {
data: {
spocs: [
{
url: "https://foo.com",
bookmarkGuid: "bookmark123",
bookmarkTitle: "Title for bar.com",
},
{ url: "test-spoc.com" },
],
},
loaded: true,
placements: [{ name: "spocs" }],
},
};
const action = {
type: at.PLACES_BOOKMARK_REMOVED,
data: {
url: "https://foo.com",
},
};
const newState = DiscoveryStream(oldState, action);
assert.lengthOf(newState.spocs.data.spocs, 2);
assert.isUndefined(newState.spocs.data.spocs[0].bookmarkGuid);
assert.isUndefined(newState.spocs.data.spocs[0].bookmarkTitle);
assert.lengthOf(
newState.feeds.data["https://foo.com/feed1"].data.recommendations,
2
);
assert.isUndefined(
newState.feeds.data["https://foo.com/feed1"].data.recommendations[0]
.bookmarkGuid
);
assert.isUndefined(
newState.feeds.data["https://foo.com/feed1"].data.recommendations[0]
.bookmarkTitle
);
});
});
describe("Search", () => {
it("should return INITIAL_STATE by default", () => {
assert.equal(
Search(undefined, { type: "some_action" }),
INITIAL_STATE.Search
);
});
it("should set hide to true on HIDE_SEARCH", () => {
const nextState = Search(undefined, { type: "HIDE_SEARCH" });
assert.propertyVal(nextState, "hide", true);
});
it("should set focus to true on FAKE_FOCUS_SEARCH", () => {
const nextState = Search(undefined, { type: "FAKE_FOCUS_SEARCH" });
assert.propertyVal(nextState, "fakeFocus", true);
});
it("should set focus and hide to false on SHOW_SEARCH", () => {
const nextState = Search(undefined, { type: "SHOW_SEARCH" });
assert.propertyVal(nextState, "fakeFocus", false);
assert.propertyVal(nextState, "hide", false);
});
});
it("should set initialized to true on AS_ROUTER_INITIALIZED", () => {
const nextState = ASRouter(undefined, { type: "AS_ROUTER_INITIALIZED" });
assert.propertyVal(nextState, "initialized", true);
});
});
================================================
FILE: test/unit/content-src/components/ASRouterAdmin.test.jsx
================================================
import {
ASRouterAdminInner,
CollapseToggle,
DiscoveryStreamAdmin,
ToggleStoryButton,
} from "content-src/components/ASRouterAdmin/ASRouterAdmin";
import { GlobalOverrider } from "test/unit/utils";
import React from "react";
import { shallow } from "enzyme";
describe("ASRouterAdmin", () => {
let globals;
let sandbox;
let sendMessageStub;
let addListenerStub;
let removeListenerStub;
let wrapper;
let FAKE_PROVIDER_PREF = [
{
enabled: true,
id: "snippets_local_testing",
localProvider: "SnippetsProvider",
type: "local",
},
];
let FAKE_PROVIDER = [
{
enabled: true,
id: "snippets_local_testing",
localProvider: "SnippetsProvider",
messages: [],
type: "local",
},
];
beforeEach(() => {
globals = new GlobalOverrider();
sandbox = sinon.createSandbox();
sendMessageStub = sandbox.stub();
addListenerStub = sandbox.stub();
removeListenerStub = sandbox.stub();
globals.set("RPMSendAsyncMessage", sendMessageStub);
globals.set("RPMAddMessageListener", addListenerStub);
globals.set("RPMRemoveMessageListener", removeListenerStub);
wrapper = shallow(
);
});
afterEach(() => {
sandbox.restore();
globals.restore();
});
it("should render ASRouterAdmin component", () => {
assert.ok(wrapper.exists());
});
it("should send ADMIN_CONNECT_STATE on mount", () => {
assert.calledOnce(sendMessageStub);
assert.propertyVal(
sendMessageStub.firstCall.args[1],
"type",
"ADMIN_CONNECT_STATE"
);
});
it("should set a listener on mount", () => {
assert.calledOnce(addListenerStub);
assert.calledWithExactly(
addListenerStub,
sinon.match.string,
wrapper.instance().onMessage
);
});
it("should remove listener on unmount", () => {
wrapper.unmount();
assert.calledOnce(removeListenerStub);
});
it("should set a .collapsed class on the outer div if props.collapsed is true", () => {
wrapper.setProps({ collapsed: true });
assert.isTrue(wrapper.find(".asrouter-admin").hasClass("collapsed"));
});
it("should set a .expanded class on the outer div if props.collapsed is false", () => {
wrapper.setProps({ collapsed: false });
assert.isTrue(wrapper.find(".asrouter-admin").hasClass("expanded"));
assert.isFalse(wrapper.find(".asrouter-admin").hasClass("collapsed"));
});
describe("#getSection", () => {
it("should render a message provider section by default", () => {
assert.equal(
wrapper
.find("h2")
.at(2)
.text(),
"Messages"
);
});
it("should render a targeting section for targeting route", () => {
wrapper = shallow(
);
assert.equal(
wrapper
.find("h2")
.at(0)
.text(),
"Targeting Utilities"
);
});
it("should render a pocket section for pocket route", () => {
wrapper = shallow(
);
assert.equal(
wrapper
.find("h2")
.at(0)
.text(),
"Pocket"
);
});
it("should render a DS section for DS route", () => {
wrapper = shallow(
);
assert.equal(
wrapper
.find("h2")
.at(0)
.text(),
"Discovery Stream"
);
});
it("should render two error messages", () => {
wrapper = shallow(
);
const firstError = {
timestamp: Date.now() + 100,
error: { message: "first" },
};
const secondError = {
timestamp: Date.now(),
error: { message: "second" },
};
wrapper.setState({
providers: [{ id: "foo", errors: [firstError, secondError] }],
});
assert.equal(
wrapper
.find("tbody tr")
.at(0)
.find("td")
.at(0)
.text(),
"foo"
);
assert.lengthOf(wrapper.find("tbody tr"), 2);
assert.equal(
wrapper
.find("tbody tr")
.at(0)
.find("td")
.at(1)
.text(),
secondError.error.message
);
});
});
describe("#render", () => {
beforeEach(() => {
wrapper.setState({
providerPrefs: [],
providers: [],
userPrefs: {},
});
});
describe("#renderProviders", () => {
it("should render the provider", () => {
wrapper.setState({
providerPrefs: FAKE_PROVIDER_PREF,
providers: FAKE_PROVIDER,
});
// Header + 1 item
assert.lengthOf(wrapper.find(".message-item"), 2);
});
});
describe("#renderMessages", () => {
beforeEach(() => {
wrapper.setState({
messageFilter: "all",
messageBlockList: [],
messageImpressions: { foo: 2 },
});
});
it("should render a message when no filtering is applied", () => {
wrapper.setState({
messages: [{ id: "foo" }],
});
assert.lengthOf(wrapper.find(".message-id"), 1);
wrapper.find(".message-item button.primary").simulate("click");
// first call is ADMIN_CONNECT_STATE
assert.propertyVal(
sendMessageStub.secondCall.args[1],
"type",
"BLOCK_MESSAGE_BY_ID"
);
assert.propertyVal(
sendMessageStub.secondCall.args[1].data,
"id",
"foo"
);
});
it("should render a blocked message", () => {
wrapper.setState({
messages: [{ id: "foo" }],
messageBlockList: ["foo"],
});
assert.lengthOf(wrapper.find(".message-item.blocked"), 1);
wrapper.find(".message-item.blocked button").simulate("click");
// first call is ADMIN_CONNECT_STATE
assert.propertyVal(
sendMessageStub.secondCall.args[1],
"type",
"UNBLOCK_MESSAGE_BY_ID"
);
assert.propertyVal(
sendMessageStub.secondCall.args[1].data,
"id",
"foo"
);
});
it("should render a message if provider matches filter", () => {
wrapper.setState({
messageFilter: "messageProvider",
messages: [{ id: "foo", provider: "messageProvider" }],
});
assert.lengthOf(wrapper.find(".message-id"), 1);
});
it("should override with the selected message", () => {
wrapper.setState({
messageFilter: "messageProvider",
messages: [{ id: "foo", provider: "messageProvider" }],
});
assert.lengthOf(wrapper.find(".message-id"), 1);
wrapper.find(".message-item button:not(.primary)").simulate("click");
// first call is ADMIN_CONNECT_STATE
assert.propertyVal(
sendMessageStub.secondCall.args[1],
"type",
"OVERRIDE_MESSAGE"
);
assert.propertyVal(
sendMessageStub.secondCall.args[1].data,
"id",
"foo"
);
});
it("should hide message if provider filter changes", () => {
wrapper.setState({
messageFilter: "messageProvider",
messages: [{ id: "foo", provider: "messageProvider" }],
});
assert.lengthOf(wrapper.find(".message-id"), 1);
wrapper.find("select").simulate("change", { target: { value: "bar" } });
assert.lengthOf(wrapper.find(".message-id"), 0);
});
});
});
describe("#DiscoveryStream", () => {
it("should render a DiscoveryStreamAdmin component", () => {
wrapper = shallow(
);
assert.equal(
wrapper
.find("h3")
.at(0)
.text(),
"Endpoint variant"
);
});
it("should render a spoc in DiscoveryStreamAdmin component", () => {
wrapper = shallow(
);
wrapper.instance().onStoryToggle({ id: 12345 });
const messageSummary = wrapper.find(".message-summary").at(0);
const pre = messageSummary.find("pre").at(0);
const spocText = pre.text();
assert.equal(spocText, '{\n "id": 12345\n}');
});
});
describe("#ToggleStoryButton", () => {
it("should fire onClick in toggle button", async () => {
let result = "";
function onClick(spoc) {
result = spoc;
}
wrapper = shallow( );
wrapper.find("button").simulate("click");
assert.equal(result, "spoc");
});
});
});
describe("CollapseToggle", () => {
let wrapper;
beforeEach(() => {
wrapper = shallow( );
});
describe("rendering inner content", () => {
it("should not render ASRouterAdminInner for about:newtab (no hash)", () => {
wrapper.setProps({ location: { hash: "", routes: [""] } });
assert.lengthOf(wrapper.find(ASRouterAdminInner), 0);
});
it("should render ASRouterAdminInner for about:newtab#asrouter and subroutes", () => {
wrapper.setProps({ location: { hash: "#asrouter", routes: [""] } });
assert.lengthOf(wrapper.find(ASRouterAdminInner), 1);
wrapper.setProps({ location: { hash: "#asrouter-foo", routes: [""] } });
assert.lengthOf(wrapper.find(ASRouterAdminInner), 1);
});
it("should render ASRouterAdminInner for about:newtab#devtools and subroutes", () => {
wrapper.setProps({ location: { hash: "#devtools", routes: [""] } });
assert.lengthOf(wrapper.find(ASRouterAdminInner), 1);
wrapper.setProps({ location: { hash: "#devtools-foo", routes: [""] } });
assert.lengthOf(wrapper.find(ASRouterAdminInner), 1);
});
});
});
================================================
FILE: test/unit/content-src/components/Base.test.jsx
================================================
import { _Base as Base, BaseContent } from "content-src/components/Base/Base";
import { ASRouterAdmin } from "content-src/components/ASRouterAdmin/ASRouterAdmin";
import { ErrorBoundary } from "content-src/components/ErrorBoundary/ErrorBoundary";
import React from "react";
import { Search } from "content-src/components/Search/Search";
import { shallow } from "enzyme";
describe(" ", () => {
let DEFAULT_PROPS = {
store: { getState: () => {} },
App: { initialized: true },
Prefs: { values: {} },
Sections: [],
DiscoveryStream: { config: { enabled: false } },
dispatch: () => {},
};
it("should render Base component", () => {
const wrapper = shallow( );
assert.ok(wrapper.exists());
});
it("should render the BaseContent component, passing through all props", () => {
const wrapper = shallow( );
assert.deepEqual(wrapper.find(BaseContent).props(), DEFAULT_PROPS);
});
it("should render an ErrorBoundary with class base-content-fallback", () => {
const wrapper = shallow( );
assert.equal(
wrapper
.find(ErrorBoundary)
.first()
.prop("className"),
"base-content-fallback"
);
});
it("should render an ASRouterAdmin if the devtools pref is true", () => {
const wrapper = shallow(
);
assert.lengthOf(wrapper.find(ASRouterAdmin), 1);
});
it("should not render an ASRouterAdmin if the devtools pref is false", () => {
const wrapper = shallow(
);
assert.lengthOf(wrapper.find(ASRouterAdmin), 0);
});
});
describe("", () => {
let DEFAULT_PROPS = {
store: { getState: () => {} },
App: { initialized: true },
Prefs: { values: {} },
Sections: [],
DiscoveryStream: { config: { enabled: false } },
dispatch: () => {},
};
it("should render an ErrorBoundary with a Search child", () => {
const searchEnabledProps = Object.assign({}, DEFAULT_PROPS, {
Prefs: { values: { showSearch: true } },
});
const wrapper = shallow( );
assert.isTrue(
wrapper
.find(Search)
.parent()
.is(ErrorBoundary)
);
});
it("should render only search if no Sections are enabled", () => {
const onlySearchProps = Object.assign({}, DEFAULT_PROPS, {
Sections: [{ id: "highlights", enabled: false }],
Prefs: { values: { showSearch: true } },
});
const wrapper = shallow( );
assert.lengthOf(wrapper.find(".only-search"), 1);
});
it("should render only search if only highlights is available in DS", () => {
const onlySearchProps = Object.assign({}, DEFAULT_PROPS, {
Sections: [{ id: "highlights", enabled: true }],
DiscoveryStream: { config: { enabled: true } },
Prefs: { values: { showSearch: true } },
});
const wrapper = shallow( );
assert.lengthOf(wrapper.find(".only-search"), 1);
});
});
================================================
FILE: test/unit/content-src/components/Card.test.jsx
================================================
import { actionCreators as ac, actionTypes as at } from "common/Actions.jsm";
import {
_Card as Card,
PlaceholderCard,
} from "content-src/components/Card/Card";
import { combineReducers, createStore } from "redux";
import { GlobalOverrider } from "test/unit/utils";
import { INITIAL_STATE, reducers } from "common/Reducers.jsm";
import { cardContextTypes } from "content-src/components/Card/types";
import { ContextMenuButton } from "content-src/components/ContextMenu/ContextMenuButton";
import { LinkMenu } from "content-src/components/LinkMenu/LinkMenu";
import { Provider } from "react-redux";
import React from "react";
import { shallow, mount } from "enzyme";
let DEFAULT_PROPS = {
dispatch: sinon.stub(),
index: 0,
link: {
hostname: "foo",
title: "A title for foo",
url: "http://www.foo.com",
type: "history",
description: "A description for foo",
image: "http://www.foo.com/img.png",
guid: 1,
},
eventSource: "TOP_STORIES",
shouldSendImpressionStats: true,
contextMenuOptions: ["Separator"],
};
let DEFAULT_BLOB_IMAGE = {
path: "/testpath",
data: new Blob([0]),
};
function mountCardWithProps(props) {
const store = createStore(combineReducers(reducers), INITIAL_STATE);
return mount(
);
}
describe("", () => {
let globals;
let wrapper;
beforeEach(() => {
globals = new GlobalOverrider();
wrapper = mountCardWithProps(DEFAULT_PROPS);
});
afterEach(() => {
DEFAULT_PROPS.dispatch.reset();
globals.restore();
});
it("should render a Card component", () => assert.ok(wrapper.exists()));
it("should add the right url", () => {
assert.propertyVal(
wrapper.find("a").props(),
"href",
DEFAULT_PROPS.link.url
);
// test that pocket cards get a special open_url href
const pocketLink = Object.assign({}, DEFAULT_PROPS.link, {
open_url: "getpocket.com/foo",
type: "pocket",
});
wrapper = mount(
);
assert.propertyVal(wrapper.find("a").props(), "href", pocketLink.open_url);
});
it("should display a title", () =>
assert.equal(wrapper.find(".card-title").text(), DEFAULT_PROPS.link.title));
it("should display a description", () =>
assert.equal(
wrapper.find(".card-description").text(),
DEFAULT_PROPS.link.description
));
it("should display a host name", () =>
assert.equal(wrapper.find(".card-host-name").text(), "foo"));
it("should have a link menu button", () =>
assert.ok(wrapper.find(".context-menu-button").exists()));
it("should render a link menu when button is clicked", () => {
const button = wrapper.find(".context-menu-button");
assert.equal(wrapper.find(LinkMenu).length, 0);
button.simulate("click", { preventDefault: () => {} });
assert.equal(wrapper.find(LinkMenu).length, 1);
});
it("should pass dispatch, source, onUpdate, site, options, and index to LinkMenu", () => {
wrapper
.find(".context-menu-button")
.simulate("click", { preventDefault: () => {} });
const { dispatch, source, onUpdate, site, options, index } = wrapper
.find(LinkMenu)
.props();
assert.equal(dispatch, DEFAULT_PROPS.dispatch);
assert.equal(source, DEFAULT_PROPS.eventSource);
assert.ok(onUpdate);
assert.equal(site, DEFAULT_PROPS.link);
assert.equal(options, DEFAULT_PROPS.contextMenuOptions);
assert.equal(index, DEFAULT_PROPS.index);
});
it("should pass through the correct menu options to LinkMenu if overridden by individual card", () => {
const link = Object.assign({}, DEFAULT_PROPS.link);
link.contextMenuOptions = ["CheckBookmark"];
wrapper = mountCardWithProps(Object.assign({}, DEFAULT_PROPS, { link }));
wrapper
.find(".context-menu-button")
.simulate("click", { preventDefault: () => {} });
const { options } = wrapper.find(LinkMenu).props();
assert.equal(options, link.contextMenuOptions);
});
it("should have a context based on type", () => {
wrapper = shallow( );
const context = wrapper.find(".card-context");
const { icon, fluentID } = cardContextTypes[DEFAULT_PROPS.link.type];
assert.isTrue(context.childAt(0).hasClass(`icon-${icon}`));
assert.isTrue(context.childAt(1).hasClass("card-context-label"));
assert.equal(context.childAt(1).prop("data-l10n-id"), fluentID);
});
it("should support setting custom context", () => {
const linkWithCustomContext = {
type: "history",
context: "Custom",
icon: "icon-url",
};
wrapper = shallow(
);
const context = wrapper.find(".card-context");
const { icon } = cardContextTypes[DEFAULT_PROPS.link.type];
assert.isFalse(context.childAt(0).hasClass(`icon-${icon}`));
assert.equal(
context.childAt(0).props().style.backgroundImage,
"url('icon-url')"
);
assert.isTrue(context.childAt(1).hasClass("card-context-label"));
assert.equal(context.childAt(1).text(), linkWithCustomContext.context);
});
it("should parse args for fluent correctly", () => {
const title = '"fluent"';
const link = { ...DEFAULT_PROPS.link, title };
wrapper = mountCardWithProps({ ...DEFAULT_PROPS, link });
let button = wrapper.find(ContextMenuButton).find("button");
assert.equal(button.prop("data-l10n-args"), JSON.stringify({ title }));
});
it("should have .active class, on card-outer if context menu is open", () => {
const button = wrapper.find(ContextMenuButton);
assert.isFalse(
wrapper.find(".card-outer").hasClass("active"),
"does not have active class"
);
button.simulate("click", { preventDefault: () => {} });
assert.isTrue(
wrapper.find(".card-outer").hasClass("active"),
"has active class"
);
});
it("should send SHOW_DOWNLOAD_FILE if we clicked on a download", () => {
const downloadLink = {
type: "download",
url: "download.mov",
};
wrapper = mountCardWithProps(
Object.assign({}, DEFAULT_PROPS, { link: downloadLink })
);
const card = wrapper.find(".card");
card.simulate("click", { preventDefault: () => {} });
assert.calledThrice(DEFAULT_PROPS.dispatch);
assert.equal(
DEFAULT_PROPS.dispatch.firstCall.args[0].type,
at.SHOW_DOWNLOAD_FILE
);
assert.deepEqual(
DEFAULT_PROPS.dispatch.firstCall.args[0].data,
downloadLink
);
});
it("should send OPEN_LINK if we clicked on anything other than a download", () => {
const nonDownloadLink = {
type: "history",
url: "download.mov",
};
wrapper = mountCardWithProps(
Object.assign({}, DEFAULT_PROPS, { link: nonDownloadLink })
);
const card = wrapper.find(".card");
const event = {
altKey: "1",
button: "2",
ctrlKey: "3",
metaKey: "4",
shiftKey: "5",
};
card.simulate(
"click",
Object.assign({}, event, { preventDefault: () => {} })
);
assert.calledThrice(DEFAULT_PROPS.dispatch);
assert.equal(DEFAULT_PROPS.dispatch.firstCall.args[0].type, at.OPEN_LINK);
});
describe("card image display", () => {
const DEFAULT_BLOB_URL = "blob://test";
let url;
beforeEach(() => {
url = {
createObjectURL: globals.sandbox.stub().returns(DEFAULT_BLOB_URL),
revokeObjectURL: globals.sandbox.spy(),
};
globals.set("URL", url);
});
afterEach(() => {
globals.restore();
});
it("should display a regular image correctly and not call revokeObjectURL when unmounted", () => {
wrapper = shallow( );
assert.isUndefined(wrapper.state("cardImage").path);
assert.equal(wrapper.state("cardImage").url, DEFAULT_PROPS.link.image);
assert.equal(
wrapper.find(".card-preview-image").props().style.backgroundImage,
`url(${wrapper.state("cardImage").url})`
);
wrapper.unmount();
assert.notCalled(url.revokeObjectURL);
});
it("should display a blob image correctly and revoke blob url when unmounted", () => {
const link = Object.assign({}, DEFAULT_PROPS.link, {
image: DEFAULT_BLOB_IMAGE,
});
wrapper = shallow( );
assert.equal(wrapper.state("cardImage").path, DEFAULT_BLOB_IMAGE.path);
assert.equal(wrapper.state("cardImage").url, DEFAULT_BLOB_URL);
assert.equal(
wrapper.find(".card-preview-image").props().style.backgroundImage,
`url(${wrapper.state("cardImage").url})`
);
wrapper.unmount();
assert.calledOnce(url.revokeObjectURL);
});
it("should not show an image if there isn't one and not call revokeObjectURL when unmounted", () => {
const link = Object.assign({}, DEFAULT_PROPS.link);
delete link.image;
wrapper = shallow( );
assert.isNull(wrapper.state("cardImage"));
assert.lengthOf(wrapper.find(".card-preview-image"), 0);
wrapper.unmount();
assert.notCalled(url.revokeObjectURL);
});
it("should remove current card image if new image is not present", () => {
wrapper = shallow( );
const otherLink = Object.assign({}, DEFAULT_PROPS.link);
delete otherLink.image;
wrapper.setProps(Object.assign({}, DEFAULT_PROPS, { link: otherLink }));
assert.isNull(wrapper.state("cardImage"));
});
it("should not create or revoke urls if normal image is already in state", () => {
wrapper = shallow( );
wrapper.setProps(DEFAULT_PROPS);
assert.notCalled(url.createObjectURL);
assert.notCalled(url.revokeObjectURL);
});
it("should not create or revoke more urls if blob image is already in state", () => {
const link = Object.assign({}, DEFAULT_PROPS.link, {
image: DEFAULT_BLOB_IMAGE,
});
wrapper = shallow( );
assert.calledOnce(url.createObjectURL);
assert.notCalled(url.revokeObjectURL);
wrapper.setProps(Object.assign({}, DEFAULT_PROPS, { link }));
assert.calledOnce(url.createObjectURL);
assert.notCalled(url.revokeObjectURL);
});
it("should create blob urls for new blobs and revoke existing ones", () => {
const link = Object.assign({}, DEFAULT_PROPS.link, {
image: DEFAULT_BLOB_IMAGE,
});
wrapper = shallow( );
assert.calledOnce(url.createObjectURL);
assert.notCalled(url.revokeObjectURL);
const otherLink = Object.assign({}, DEFAULT_PROPS.link, {
image: { path: "/newpath", data: new Blob([0]) },
});
wrapper.setProps(Object.assign({}, DEFAULT_PROPS, { link: otherLink }));
assert.calledTwice(url.createObjectURL);
assert.calledOnce(url.revokeObjectURL);
});
it("should not call createObjectURL and revokeObjectURL for normal images", () => {
wrapper = shallow( );
assert.notCalled(url.createObjectURL);
assert.notCalled(url.revokeObjectURL);
const otherLink = Object.assign({}, DEFAULT_PROPS.link, {
image: "https://other/image",
});
wrapper.setProps(Object.assign({}, DEFAULT_PROPS, { link: otherLink }));
assert.notCalled(url.createObjectURL);
assert.notCalled(url.revokeObjectURL);
});
});
describe("image loading", () => {
let link;
let triggerImage = {};
let uniqueLink = 0;
beforeEach(() => {
global.Image.prototype = {
addEventListener(event, callback) {
triggerImage[event] = () => Promise.resolve(callback());
},
};
link = Object.assign({}, DEFAULT_PROPS.link);
link.image += uniqueLink++;
wrapper = shallow( );
});
it("should have a loaded preview image when the image is loaded", () => {
assert.isFalse(wrapper.find(".card-preview-image").hasClass("loaded"));
wrapper.setState({ imageLoaded: true });
assert.isTrue(wrapper.find(".card-preview-image").hasClass("loaded"));
});
it("should start not loaded", () => {
assert.isFalse(wrapper.state("imageLoaded"));
});
it("should be loaded after load", async () => {
await triggerImage.load();
assert.isTrue(wrapper.state("imageLoaded"));
});
it("should be not be loaded after error ", async () => {
await triggerImage.error();
assert.isFalse(wrapper.state("imageLoaded"));
});
it("should be not be loaded if image changes", async () => {
await triggerImage.load();
const otherLink = Object.assign({}, link, {
image: "https://other/image",
});
wrapper.setProps(Object.assign({}, DEFAULT_PROPS, { link: otherLink }));
assert.isFalse(wrapper.state("imageLoaded"));
});
});
describe("placeholder=true", () => {
beforeEach(() => {
wrapper = mount( );
});
it("should render when placeholder=true", () => {
assert.ok(wrapper.exists());
});
it("should add a placeholder class to the outer element", () => {
assert.isTrue(wrapper.find(".card-outer").hasClass("placeholder"));
});
it("should not have a context menu button or LinkMenu", () => {
assert.isFalse(
wrapper.find(ContextMenuButton).exists(),
"context menu button"
);
assert.isFalse(wrapper.find(LinkMenu).exists(), "LinkMenu");
});
it("should not call onLinkClick when the link is clicked", () => {
const spy = sinon.spy(wrapper.instance(), "onLinkClick");
const card = wrapper.find(".card");
card.simulate("click");
assert.notCalled(spy);
});
});
describe("#trackClick", () => {
it("should call dispatch when the link is clicked with the right data", () => {
const card = wrapper.find(".card");
const event = {
altKey: "1",
button: "2",
ctrlKey: "3",
metaKey: "4",
shiftKey: "5",
};
card.simulate(
"click",
Object.assign({}, event, { preventDefault: () => {} })
);
assert.calledThrice(DEFAULT_PROPS.dispatch);
// first dispatch call is the AlsoToMain message which will open a link in a window, and send some event data
assert.equal(DEFAULT_PROPS.dispatch.firstCall.args[0].type, at.OPEN_LINK);
assert.deepEqual(
DEFAULT_PROPS.dispatch.firstCall.args[0].data.event,
event
);
// second dispatch call is a UserEvent action for telemetry
assert.isUserEventAction(DEFAULT_PROPS.dispatch.secondCall.args[0]);
assert.calledWith(
DEFAULT_PROPS.dispatch.secondCall,
ac.UserEvent({
event: "CLICK",
source: DEFAULT_PROPS.eventSource,
action_position: DEFAULT_PROPS.index,
})
);
// third dispatch call is to send impression stats
assert.calledWith(
DEFAULT_PROPS.dispatch.thirdCall,
ac.ImpressionStats({
source: DEFAULT_PROPS.eventSource,
click: 0,
tiles: [{ id: DEFAULT_PROPS.link.guid, pos: DEFAULT_PROPS.index }],
})
);
});
it("should provide card_type to telemetry info if type is not history", () => {
const link = Object.assign({}, DEFAULT_PROPS.link);
link.type = "bookmark";
wrapper = mount( );
const card = wrapper.find(".card");
const event = {
altKey: "1",
button: "2",
ctrlKey: "3",
metaKey: "4",
shiftKey: "5",
};
card.simulate(
"click",
Object.assign({}, event, { preventDefault: () => {} })
);
assert.isUserEventAction(DEFAULT_PROPS.dispatch.secondCall.args[0]);
assert.calledWith(
DEFAULT_PROPS.dispatch.secondCall,
ac.UserEvent({
event: "CLICK",
source: DEFAULT_PROPS.eventSource,
action_position: DEFAULT_PROPS.index,
value: { card_type: link.type },
})
);
});
it("should notify Web Extensions with WEBEXT_CLICK if props.isWebExtension is true", () => {
wrapper = mountCardWithProps(
Object.assign({}, DEFAULT_PROPS, {
isWebExtension: true,
eventSource: "MyExtension",
index: 3,
})
);
const card = wrapper.find(".card");
const event = { preventDefault() {} };
card.simulate("click", event);
assert.calledWith(
DEFAULT_PROPS.dispatch,
ac.WebExtEvent(at.WEBEXT_CLICK, {
source: "MyExtension",
url: DEFAULT_PROPS.link.url,
action_position: 3,
})
);
});
});
});
describe(" ", () => {
it("should render a Card with placeholder=true", () => {
const wrapper = mount(
);
assert.isTrue(wrapper.find(Card).props().placeholder);
});
});
================================================
FILE: test/unit/content-src/components/CollapsibleSection.test.jsx
================================================
import { actionTypes as at } from "common/Actions.jsm";
import { CollapsibleSection } from "content-src/components/CollapsibleSection/CollapsibleSection";
import { ErrorBoundary } from "content-src/components/ErrorBoundary/ErrorBoundary";
import { mount } from "enzyme";
import React from "react";
const DEFAULT_PROPS = {
id: "cool",
className: "cool-section",
title: "Cool Section",
prefName: "collapseSection",
collapsed: false,
document: {
addEventListener: () => {},
removeEventListener: () => {},
visibilityState: "visible",
},
dispatch: () => {},
};
describe("CollapsibleSection", () => {
let wrapper;
function setup(props = {}) {
const customProps = Object.assign({}, DEFAULT_PROPS, props);
wrapper = mount(
foo
);
}
beforeEach(() => setup());
it("should render the component", () => {
assert.ok(wrapper.exists());
});
it("should render an ErrorBoundary with class section-body-fallback", () => {
assert.equal(
wrapper
.find(ErrorBoundary)
.first()
.prop("className"),
"section-body-fallback"
);
});
it("should have collapsed class if 'prefName' pref is true", () => {
setup({ collapsed: true });
assert.ok(
wrapper
.find(".collapsible-section")
.first()
.hasClass("collapsed")
);
});
it("should fire a pref change event when section title is clicked", done => {
function dispatch(a) {
if (a.type === at.UPDATE_SECTION_PREFS) {
assert.equal(a.data.id, DEFAULT_PROPS.id);
assert.equal(a.data.value.collapsed, true);
done();
}
}
setup({ dispatch });
wrapper
.find(".click-target")
.at(0)
.simulate("click");
});
it("should not fire a pref change when section title is clicked if sectionBody is falsy", () => {
const dispatch = sinon.spy();
setup({ dispatch });
delete wrapper.find(CollapsibleSection).instance().sectionBody;
wrapper
.find(".click-target")
.at(0)
.simulate("click");
assert.notCalled(dispatch);
});
it("should enable animations if the tab is visible", () => {
wrapper.instance().enableOrDisableAnimation();
assert.ok(wrapper.instance().state.enableAnimation);
});
it("should disable animations if the tab is in the background", () => {
const doc = Object.assign({}, DEFAULT_PROPS.document, {
visibilityState: "hidden",
});
setup({ document: doc });
wrapper.instance().enableOrDisableAnimation();
assert.isFalse(wrapper.instance().state.enableAnimation);
});
describe("without collapsible pref", () => {
let dispatch;
beforeEach(() => {
dispatch = sinon.stub();
setup({ collapsed: undefined, dispatch });
});
it("should render the section uncollapsed", () => {
assert.isFalse(
wrapper
.find(".collapsible-section")
.first()
.hasClass("collapsed")
);
});
it("should not render the arrow if no collapsible pref exists for the section", () => {
assert.lengthOf(wrapper.find(".click-target .collapsible-arrow"), 0);
});
it("should not trigger a dispatch when the section title is clicked ", () => {
wrapper
.find(".click-target")
.at(0)
.simulate("click");
assert.notCalled(dispatch);
});
});
describe("icon", () => {
it("should use the icon prop value as the url if it starts with `moz-extension://`", () => {
const icon = "moz-extension://some/extension/path";
setup({ icon });
const props = wrapper
.find(".icon")
.first()
.props();
assert.equal(props.style.backgroundImage, `url('${icon}')`);
});
it("should use set the icon-* class if a string that doesn't start with `moz-extension://` is provided", () => {
setup({ icon: "cool" });
assert.ok(
wrapper
.find(".icon")
.first()
.hasClass("icon-cool")
);
});
it("should use the icon `webextension` if no other is provided", () => {
setup({ icon: undefined });
assert.ok(
wrapper
.find(".icon")
.first()
.hasClass("icon-webextension")
);
});
});
describe("maxHeight", () => {
const maxHeight = "123px";
const setState = state =>
wrapper.setState(Object.assign({ maxHeight }, state || {}));
const checkHeight = val =>
assert.equal(
wrapper.find(".section-body").instance().style.maxHeight,
val
);
it("should have no max-height normally to avoid unexpected cropping", () => {
setState();
checkHeight("");
});
it("should have a max-height when animating open to a target height", () => {
setState({ isAnimating: true });
checkHeight(maxHeight);
});
it("should not have a max-height when already collapsed", () => {
setup({ collapsed: true });
checkHeight("");
});
it("should not have a max-height when animating closed to a css-set 0", () => {
setup({ collapsed: true });
setState({ isAnimating: true });
checkHeight("");
});
});
});
================================================
FILE: test/unit/content-src/components/ComponentPerfTimer.test.jsx
================================================
import { actionCreators as ac, actionTypes as at } from "common/Actions.jsm";
import { ComponentPerfTimer } from "content-src/components/ComponentPerfTimer/ComponentPerfTimer";
import createMockRaf from "mock-raf";
import React from "react";
import { shallow } from "enzyme";
const perfSvc = {
mark() {},
getMostRecentAbsMarkStartByName() {},
};
let DEFAULT_PROPS = {
initialized: true,
rows: [],
id: "highlights",
dispatch() {},
perfSvc,
};
describe("", () => {
let mockRaf;
let sandbox;
let wrapper;
const InnerEl = () => Inner Element
;
beforeEach(() => {
mockRaf = createMockRaf();
sandbox = sinon.createSandbox();
sandbox.stub(window, "requestAnimationFrame").callsFake(mockRaf.raf);
wrapper = shallow(
);
});
afterEach(() => {
sandbox.restore();
});
it("should render props.children", () => {
assert.ok(wrapper.contains( ));
});
describe("#constructor", () => {
beforeEach(() => {
sandbox.stub(ComponentPerfTimer.prototype, "_maybeSendBadStateEvent");
sandbox.stub(
ComponentPerfTimer.prototype,
"_ensureFirstRenderTsRecorded"
);
wrapper = shallow(
,
{ disableLifecycleMethods: true }
);
});
it("should have the correct defaults", () => {
const instance = wrapper.instance();
assert.isFalse(instance._reportMissingData);
assert.isFalse(instance._timestampHandled);
assert.isFalse(instance._recordedFirstRender);
});
});
describe("#render", () => {
beforeEach(() => {
sandbox.stub(DEFAULT_PROPS, "id").value("fake_section");
sandbox.stub(ComponentPerfTimer.prototype, "_maybeSendBadStateEvent");
sandbox.stub(
ComponentPerfTimer.prototype,
"_ensureFirstRenderTsRecorded"
);
wrapper = shallow(
);
});
it("should not call telemetry on sections that we don't want to record", () => {
const instance = wrapper.instance();
assert.notCalled(instance._maybeSendBadStateEvent);
assert.notCalled(instance._ensureFirstRenderTsRecorded);
});
});
describe("#_componentDidMount", () => {
it("should call _maybeSendPaintedEvent", () => {
const instance = wrapper.instance();
const stub = sandbox.stub(instance, "_maybeSendPaintedEvent");
instance.componentDidMount();
assert.calledOnce(stub);
});
it("should not call _maybeSendPaintedEvent if id not in RECORDED_SECTIONS", () => {
sandbox.stub(DEFAULT_PROPS, "id").value("topstories");
wrapper = shallow(
);
const instance = wrapper.instance();
const stub = sandbox.stub(instance, "_maybeSendPaintedEvent");
instance.componentDidMount();
assert.notCalled(stub);
});
});
describe("#_componentDidUpdate", () => {
it("should call _maybeSendPaintedEvent", () => {
const instance = wrapper.instance();
const maybeSendPaintStub = sandbox.stub(
instance,
"_maybeSendPaintedEvent"
);
instance.componentDidUpdate();
assert.calledOnce(maybeSendPaintStub);
});
it("should not call _maybeSendPaintedEvent if id not in RECORDED_SECTIONS", () => {
sandbox.stub(DEFAULT_PROPS, "id").value("topstories");
wrapper = shallow(
);
const instance = wrapper.instance();
const stub = sandbox.stub(instance, "_maybeSendPaintedEvent");
instance.componentDidUpdate();
assert.notCalled(stub);
});
});
describe("_ensureFirstRenderTsRecorded", () => {
let recordFirstRenderStub;
beforeEach(() => {
sandbox.stub(ComponentPerfTimer.prototype, "_maybeSendBadStateEvent");
recordFirstRenderStub = sandbox.stub(
ComponentPerfTimer.prototype,
"_ensureFirstRenderTsRecorded"
);
});
it("should set _recordedFirstRender", () => {
sandbox.stub(DEFAULT_PROPS, "initialized").value(false);
wrapper = shallow(
);
const instance = wrapper.instance();
assert.isFalse(instance._recordedFirstRender);
recordFirstRenderStub.callThrough();
instance._ensureFirstRenderTsRecorded();
assert.isTrue(instance._recordedFirstRender);
});
it("should mark first_render_ts", () => {
sandbox.stub(DEFAULT_PROPS, "initialized").value(false);
wrapper = shallow(
);
const instance = wrapper.instance();
const stub = sandbox.stub(perfSvc, "mark");
recordFirstRenderStub.callThrough();
instance._ensureFirstRenderTsRecorded();
assert.calledOnce(stub);
assert.calledWithExactly(stub, `${DEFAULT_PROPS.id}_first_render_ts`);
});
});
describe("#_maybeSendBadStateEvent", () => {
let sendBadStateStub;
beforeEach(() => {
sendBadStateStub = sandbox.stub(
ComponentPerfTimer.prototype,
"_maybeSendBadStateEvent"
);
sandbox.stub(
ComponentPerfTimer.prototype,
"_ensureFirstRenderTsRecorded"
);
});
it("should set this._reportMissingData=true when called with initialized === false", () => {
sandbox.stub(DEFAULT_PROPS, "initialized").value(false);
wrapper = shallow(
);
const instance = wrapper.instance();
assert.isFalse(instance._reportMissingData);
sendBadStateStub.callThrough();
instance._maybeSendBadStateEvent();
assert.isTrue(instance._reportMissingData);
});
it("should call _sendBadStateEvent if initialized & other metrics have been recorded", () => {
const instance = wrapper.instance();
const stub = sandbox.stub(instance, "_sendBadStateEvent");
instance._reportMissingData = true;
instance._timestampHandled = true;
instance._recordedFirstRender = true;
sendBadStateStub.callThrough();
instance._maybeSendBadStateEvent();
assert.calledOnce(stub);
assert.isFalse(instance._reportMissingData);
});
});
describe("#_maybeSendPaintedEvent", () => {
it("should call _sendPaintedEvent if props.initialized is true", () => {
sandbox.stub(DEFAULT_PROPS, "initialized").value(true);
wrapper = shallow(
,
{ disableLifecycleMethods: true }
);
const instance = wrapper.instance();
const stub = sandbox.stub(instance, "_afterFramePaint");
assert.isFalse(instance._timestampHandled);
instance._maybeSendPaintedEvent();
assert.calledOnce(stub);
assert.calledWithExactly(stub, instance._sendPaintedEvent);
assert.isTrue(wrapper.instance()._timestampHandled);
});
it("should not call _sendPaintedEvent if this._timestampHandled is true", () => {
const instance = wrapper.instance();
const spy = sinon.spy(instance, "_afterFramePaint");
instance._timestampHandled = true;
instance._maybeSendPaintedEvent();
spy.neverCalledWith(instance._sendPaintedEvent);
});
it("should not call _sendPaintedEvent if component not initialized", () => {
sandbox.stub(DEFAULT_PROPS, "initialized").value(false);
wrapper = shallow(
);
const instance = wrapper.instance();
const spy = sinon.spy(instance, "_afterFramePaint");
instance._maybeSendPaintedEvent();
spy.neverCalledWith(instance._sendPaintedEvent);
});
});
describe("#_afterFramePaint", () => {
it("should call callback after the requestAnimationFrame callback returns", () =>
new Promise(resolve => {
// Setting the callback to resolve is the test that it does finally get
// called at the correct time, after the event loop ticks again.
// If it doesn't get called, this test will time out.
const callback = sandbox.spy(resolve);
const instance = wrapper.instance();
instance._afterFramePaint(callback);
assert.notCalled(callback);
mockRaf.step({ count: 1 });
}));
});
describe("#_sendBadStateEvent", () => {
it("should call perfSvc.mark", () => {
sandbox.spy(perfSvc, "mark");
const key = `${DEFAULT_PROPS.id}_data_ready_ts`;
wrapper.instance()._sendBadStateEvent();
assert.calledOnce(perfSvc.mark);
assert.calledWithExactly(perfSvc.mark, key);
});
it("should call compute the delta from first render to data ready", () => {
sandbox.stub(perfSvc, "getMostRecentAbsMarkStartByName");
wrapper
.instance()
._sendBadStateEvent(`${DEFAULT_PROPS.id}_data_ready_ts`);
assert.calledTwice(perfSvc.getMostRecentAbsMarkStartByName);
assert.calledWithExactly(
perfSvc.getMostRecentAbsMarkStartByName,
`${DEFAULT_PROPS.id}_data_ready_ts`
);
assert.calledWithExactly(
perfSvc.getMostRecentAbsMarkStartByName,
`${DEFAULT_PROPS.id}_first_render_ts`
);
});
it("should call dispatch SAVE_SESSION_PERF_DATA", () => {
sandbox
.stub(perfSvc, "getMostRecentAbsMarkStartByName")
.withArgs("highlights_first_render_ts")
.returns(0.5)
.withArgs("highlights_data_ready_ts")
.returns(3.2);
const dispatch = sandbox.spy(DEFAULT_PROPS, "dispatch");
wrapper = shallow(
);
wrapper.instance()._sendBadStateEvent();
assert.calledOnce(dispatch);
assert.calledWithExactly(
dispatch,
ac.OnlyToMain({
type: at.SAVE_SESSION_PERF_DATA,
data: { [`${DEFAULT_PROPS.id}_data_late_by_ms`]: 2 },
})
);
});
});
describe("#_sendPaintedEvent", () => {
beforeEach(() => {
sandbox.stub(ComponentPerfTimer.prototype, "_maybeSendBadStateEvent");
sandbox.stub(
ComponentPerfTimer.prototype,
"_ensureFirstRenderTsRecorded"
);
});
it("should not call mark with the wrong id", () => {
sandbox.stub(perfSvc, "mark");
sandbox.stub(DEFAULT_PROPS, "id").value("fake_id");
wrapper = shallow(
);
wrapper.instance()._sendPaintedEvent();
assert.notCalled(perfSvc.mark);
});
it("should call mark with the correct topsites", () => {
sandbox.stub(perfSvc, "mark");
sandbox.stub(DEFAULT_PROPS, "id").value("topsites");
wrapper = shallow(
);
wrapper.instance()._sendPaintedEvent();
assert.calledOnce(perfSvc.mark);
assert.calledWithExactly(perfSvc.mark, "topsites_first_painted_ts");
});
it("should not call getMostRecentAbsMarkStartByName if id!=topsites", () => {
sandbox.stub(perfSvc, "getMostRecentAbsMarkStartByName");
sandbox.stub(DEFAULT_PROPS, "id").value("fake_id");
wrapper = shallow(
);
wrapper.instance()._sendPaintedEvent();
assert.notCalled(perfSvc.getMostRecentAbsMarkStartByName);
});
it("should call getMostRecentAbsMarkStartByName for topsites", () => {
sandbox.stub(perfSvc, "getMostRecentAbsMarkStartByName");
sandbox.stub(DEFAULT_PROPS, "id").value("topsites");
wrapper = shallow(
);
wrapper.instance()._sendPaintedEvent();
assert.calledOnce(perfSvc.getMostRecentAbsMarkStartByName);
assert.calledWithExactly(
perfSvc.getMostRecentAbsMarkStartByName,
"topsites_first_painted_ts"
);
});
it("should dispatch SAVE_SESSION_PERF_DATA", () => {
sandbox.stub(perfSvc, "getMostRecentAbsMarkStartByName").returns(42);
sandbox.stub(DEFAULT_PROPS, "id").value("topsites");
const dispatch = sandbox.spy(DEFAULT_PROPS, "dispatch");
wrapper = shallow(
);
wrapper.instance()._sendPaintedEvent();
assert.calledOnce(dispatch);
assert.calledWithExactly(
dispatch,
ac.OnlyToMain({
type: at.SAVE_SESSION_PERF_DATA,
data: { topsites_first_painted_ts: 42 },
})
);
});
});
});
================================================
FILE: test/unit/content-src/components/ConfirmDialog.test.jsx
================================================
import { actionCreators as ac, actionTypes as at } from "common/Actions.jsm";
import { _ConfirmDialog as ConfirmDialog } from "content-src/components/ConfirmDialog/ConfirmDialog";
import React from "react";
import { shallow } from "enzyme";
describe("", () => {
let wrapper;
let dispatch;
let ConfirmDialogProps;
beforeEach(() => {
dispatch = sinon.stub();
ConfirmDialogProps = {
visible: true,
data: {
onConfirm: [],
cancel_button_string_id: "newtab-topsites-delete-history-button",
confirm_button_string_id: "newtab-topsites-cancel-button",
eventSource: "HIGHLIGHTS",
},
};
wrapper = shallow(
);
});
it("should render an overlay", () => {
assert.ok(wrapper.find(".modal-overlay").exists());
});
it("should render a modal", () => {
assert.ok(wrapper.find(".confirmation-dialog").exists());
});
it("should not render if visible is false", () => {
ConfirmDialogProps.visible = false;
wrapper = shallow(
);
assert.lengthOf(wrapper.find(".confirmation-dialog"), 0);
});
it("should display an icon if we provide one in props", () => {
const iconName = "modal-icon";
// If there is no icon in the props, we shouldn't display an icon
assert.lengthOf(wrapper.find(`.icon-${iconName}`), 0);
ConfirmDialogProps.data.icon = iconName;
wrapper = shallow(
);
// But if we do provide an icon - we should show it
assert.lengthOf(wrapper.find(`.icon-${iconName}`), 1);
});
describe("fluent message check", () => {
it("should render the message body sent via props", () => {
Object.assign(ConfirmDialogProps.data, {
body_string_id: ["foo", "bar"],
});
wrapper = shallow(
);
let msgs = wrapper.find(".modal-message").find("p");
assert.equal(msgs.length, ConfirmDialogProps.data.body_string_id.length);
msgs.forEach((fm, i) =>
assert.equal(
fm.prop("data-l10n-id"),
ConfirmDialogProps.data.body_string_id[i]
)
);
});
it("should render the correct primary button text", () => {
Object.assign(ConfirmDialogProps.data, {
confirm_button_string_id: "primary_foo",
});
wrapper = shallow(
);
let doneLabel = wrapper.find(".actions").childAt(1);
assert.ok(doneLabel.exists());
assert.equal(
doneLabel.prop("data-l10n-id"),
ConfirmDialogProps.data.confirm_button_string_id
);
});
});
describe("click events", () => {
it("should emit AlsoToMain DIALOG_CANCEL when you click the overlay", () => {
let overlay = wrapper.find(".modal-overlay");
assert.ok(overlay.exists());
overlay.simulate("click");
// Two events are emitted: UserEvent+AlsoToMain.
assert.calledTwice(dispatch);
assert.propertyVal(dispatch.firstCall.args[0], "type", at.DIALOG_CANCEL);
assert.calledWith(dispatch, { type: at.DIALOG_CANCEL });
});
it("should emit UserEvent DIALOG_CANCEL when you click the overlay", () => {
let overlay = wrapper.find(".modal-overlay");
assert.ok(overlay);
overlay.simulate("click");
// Two events are emitted: UserEvent+AlsoToMain.
assert.calledTwice(dispatch);
assert.isUserEventAction(dispatch.secondCall.args[0]);
assert.calledWith(
dispatch,
ac.UserEvent({ event: at.DIALOG_CANCEL, source: "HIGHLIGHTS" })
);
});
it("should emit AlsoToMain DIALOG_CANCEL on cancel", () => {
let cancelButton = wrapper.find(".actions").childAt(0);
assert.ok(cancelButton);
cancelButton.simulate("click");
// Two events are emitted: UserEvent+AlsoToMain.
assert.calledTwice(dispatch);
assert.propertyVal(dispatch.firstCall.args[0], "type", at.DIALOG_CANCEL);
assert.calledWith(dispatch, { type: at.DIALOG_CANCEL });
});
it("should emit UserEvent DIALOG_CANCEL on cancel", () => {
let cancelButton = wrapper.find(".actions").childAt(0);
assert.ok(cancelButton);
cancelButton.simulate("click");
// Two events are emitted: UserEvent+AlsoToMain.
assert.calledTwice(dispatch);
assert.isUserEventAction(dispatch.secondCall.args[0]);
assert.calledWith(
dispatch,
ac.UserEvent({ event: at.DIALOG_CANCEL, source: "HIGHLIGHTS" })
);
});
it("should emit UserEvent on primary button", () => {
Object.assign(ConfirmDialogProps.data, {
body_string_id: ["foo", "bar"],
onConfirm: [
ac.AlsoToMain({ type: at.DELETE_URL, data: "foo.bar" }),
ac.UserEvent({ event: "DELETE" }),
],
});
wrapper = shallow(
);
let doneButton = wrapper.find(".actions").childAt(1);
assert.ok(doneButton);
doneButton.simulate("click");
// Two events are emitted: UserEvent+AlsoToMain.
assert.isUserEventAction(dispatch.secondCall.args[0]);
assert.calledTwice(dispatch);
assert.calledWith(dispatch, ConfirmDialogProps.data.onConfirm[1]);
});
it("should emit AlsoToMain on primary button", () => {
Object.assign(ConfirmDialogProps.data, {
body_string_id: ["foo", "bar"],
onConfirm: [
ac.AlsoToMain({ type: at.DELETE_URL, data: "foo.bar" }),
ac.UserEvent({ event: "DELETE" }),
],
});
wrapper = shallow(
);
let doneButton = wrapper.find(".actions").childAt(1);
assert.ok(doneButton);
doneButton.simulate("click");
// Two events are emitted: UserEvent+AlsoToMain.
assert.calledTwice(dispatch);
assert.calledWith(dispatch, ConfirmDialogProps.data.onConfirm[0]);
});
});
});
================================================
FILE: test/unit/content-src/components/ContextMenu.test.jsx
================================================
import {
ContextMenu,
ContextMenuItem,
} from "content-src/components/ContextMenu/ContextMenu";
import { ContextMenuButton } from "content-src/components/ContextMenu/ContextMenuButton";
import { mount, shallow } from "enzyme";
import React from "react";
const DEFAULT_PROPS = {
onUpdate: () => {},
options: [],
tabbableOptionsLength: 0,
};
const DEFAULT_MENU_OPTIONS = [
"MoveUp",
"MoveDown",
"Separator",
"RemoveSection",
"CheckCollapsed",
"Separator",
"ManageSection",
];
const FakeMenu = props => {
return {props.children}
;
};
describe("", () => {
let sandbox;
beforeEach(() => {
sandbox = sinon.createSandbox();
});
afterEach(() => {
sandbox.restore();
});
it("should call onUpdate when clicked", () => {
const onUpdate = sandbox.spy();
const wrapper = mount(
);
wrapper.find(".context-menu-button").simulate("click");
assert.calledOnce(onUpdate);
});
it("should call onUpdate when activated with Enter", () => {
const onUpdate = sandbox.spy();
const wrapper = mount(
);
wrapper.find(".context-menu-button").simulate("keydown", { key: "Enter" });
assert.calledOnce(onUpdate);
});
it("should call onClick", () => {
const onClick = sandbox.spy(ContextMenuButton.prototype, "onClick");
const wrapper = mount(
);
wrapper.find("button").simulate("click");
assert.calledOnce(onClick);
});
it("should have a default keyboardAccess prop of false", () => {
const wrapper = mount(
);
wrapper.setState({ showContextMenu: true });
assert.equal(wrapper.find(ContextMenu).prop("keyboardAccess"), false);
});
it("should pass the keyboardAccess prop down to ContextMenu", () => {
const wrapper = mount(
);
wrapper.setState({ showContextMenu: true, contextMenuKeyboard: true });
assert.equal(wrapper.find(ContextMenu).prop("keyboardAccess"), true);
});
it("should call focusFirst when keyboardAccess is true", () => {
const wrapper = mount(
);
const focusFirst = sandbox.spy(ContextMenuItem.prototype, "focusFirst");
wrapper.setState({ showContextMenu: true, contextMenuKeyboard: true });
assert.calledOnce(focusFirst);
});
});
describe("", () => {
it("should render all the options provided", () => {
const options = [
{ label: "item1" },
{ type: "separator" },
{ label: "item2" },
];
const wrapper = shallow(
);
assert.lengthOf(wrapper.find(".context-menu-list").children(), 3);
});
it("should not add a link for a separator", () => {
const options = [{ label: "item1" }, { type: "separator" }];
const wrapper = shallow(
);
assert.lengthOf(wrapper.find(".separator"), 1);
});
it("should add a link for all types that are not separators", () => {
const options = [{ label: "item1" }, { type: "separator" }];
const wrapper = shallow(
);
assert.lengthOf(wrapper.find(ContextMenuItem), 1);
});
it("should add an icon to items that need icons", () => {
const options = [{ label: "item1", icon: "icon1" }, { type: "separator" }];
const wrapper = mount( );
assert.lengthOf(wrapper.find(".icon-icon1"), 1);
});
it("should be tabbable", () => {
const options = [{ label: "item1", icon: "icon1" }, { type: "separator" }];
const wrapper = mount( );
assert.equal(
wrapper.find(".context-menu-item").props().role,
"presentation"
);
});
it("should call onUpdate with false when an option is clicked", () => {
const onUpdate = sinon.spy();
const onClick = sinon.spy();
const wrapper = mount(
);
wrapper.find(".context-menu-item button").simulate("click");
assert.calledOnce(onUpdate);
assert.calledOnce(onClick);
});
it("should not have disabled className by default", () => {
const options = [{ label: "item1", icon: "icon1" }, { type: "separator" }];
const wrapper = mount( );
assert.lengthOf(wrapper.find(".context-menu-item a.disabled"), 0);
});
it("should add disabled className to any disabled options", () => {
const options = [
{ label: "item1", icon: "icon1", disabled: true },
{ type: "separator" },
];
const wrapper = mount( );
assert.lengthOf(wrapper.find(".context-menu-item button.disabled"), 1);
});
it("should have the context-menu-item class", () => {
const options = [{ label: "item1", icon: "icon1" }];
const wrapper = mount( );
assert.lengthOf(wrapper.find(".context-menu-item"), 1);
});
it("should call onClick when onKeyDown is called with Enter", () => {
const onClick = sinon.spy();
const wrapper = mount(
);
wrapper
.find(".context-menu-item button")
.simulate("keydown", { key: "Enter" });
assert.calledOnce(onClick);
});
it("should call focusSibling when onKeyDown is called with ArrowUp", () => {
const wrapper = mount(
);
const focusSibling = sinon.stub(
wrapper.find(ContextMenuItem).instance(),
"focusSibling"
);
wrapper
.find(".context-menu-item button")
.simulate("keydown", { key: "ArrowUp" });
assert.calledOnce(focusSibling);
});
it("should call focusSibling when onKeyDown is called with ArrowDown", () => {
const wrapper = mount(
);
const focusSibling = sinon.stub(
wrapper.find(ContextMenuItem).instance(),
"focusSibling"
);
wrapper
.find(".context-menu-item button")
.simulate("keydown", { key: "ArrowDown" });
assert.calledOnce(focusSibling);
});
});
================================================
FILE: test/unit/content-src/components/DiscoveryStreamBase.test.jsx
================================================
import {
_DiscoveryStreamBase as DiscoveryStreamBase,
isAllowedCSS,
} from "content-src/components/DiscoveryStreamBase/DiscoveryStreamBase";
import { GlobalOverrider } from "test/unit/utils";
import { CardGrid } from "content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid";
import { CollapsibleSection } from "content-src/components/CollapsibleSection/CollapsibleSection";
import { DSMessage } from "content-src/components/DiscoveryStreamComponents/DSMessage/DSMessage";
import { Hero } from "content-src/components/DiscoveryStreamComponents/Hero/Hero";
import { HorizontalRule } from "content-src/components/DiscoveryStreamComponents/HorizontalRule/HorizontalRule";
import { List } from "content-src/components/DiscoveryStreamComponents/List/List";
import { Navigation } from "content-src/components/DiscoveryStreamComponents/Navigation/Navigation";
import React from "react";
import { shallow } from "enzyme";
import { SectionTitle } from "content-src/components/DiscoveryStreamComponents/SectionTitle/SectionTitle";
import { TopSites } from "content-src/components/DiscoveryStreamComponents/TopSites/TopSites";
describe("", () => {
it("should allow colors", () => {
assert.isTrue(isAllowedCSS("color", "red"));
});
it("should allow resource urls", () => {
assert.isTrue(
isAllowedCSS(
"background-image",
`url("resource://activity-stream/data/content/assets/glyph-info-16.svg")`
)
);
});
it("should allow chrome urls", () => {
assert.isTrue(
isAllowedCSS(
"background-image",
`url("chrome://browser/skin/history.svg")`
)
);
});
it("should allow allowed https urls", () => {
assert.isTrue(
isAllowedCSS(
"background-image",
`url("https://img-getpocket.cdn.mozilla.net/media/image.png")`
)
);
});
it("should disallow other https urls", () => {
assert.isFalse(
isAllowedCSS(
"background-image",
`url("https://mozilla.org/media/image.png")`
)
);
});
it("should disallow other protocols", () => {
assert.isFalse(
isAllowedCSS(
"background-image",
`url("ftp://mozilla.org/media/image.png")`
)
);
});
it("should allow allowed multiple valid urls", () => {
assert.isTrue(
isAllowedCSS(
"background-image",
`url("https://img-getpocket.cdn.mozilla.net/media/image.png"), url("chrome://browser/skin/history.svg")`
)
);
});
it("should disallow if any invaild", () => {
assert.isFalse(
isAllowedCSS(
"background-image",
`url("chrome://browser/skin/history.svg"), url("ftp://mozilla.org/media/image.png")`
)
);
});
});
describe("", () => {
let wrapper;
let globals;
let sandbox;
function mountComponent(props = {}) {
const defaultProps = {
config: { collapsible: true },
layout: [],
feeds: { loaded: true },
spocs: {
loaded: true,
data: { spocs: null },
},
...props,
};
return shallow(
);
}
beforeEach(() => {
globals = new GlobalOverrider();
sandbox = sinon.createSandbox();
wrapper = mountComponent();
});
afterEach(() => {
sandbox.restore();
globals.restore();
});
it("should render something if spocs are not loaded", () => {
wrapper = mountComponent({
spocs: { loaded: false, data: { spocs: null } },
});
assert.notEqual(wrapper.type(), null);
});
it("should render something if feeds are not loaded", () => {
wrapper = mountComponent({ feeds: { loaded: false } });
assert.notEqual(wrapper.type(), null);
});
it("should render nothing with no layout", () => {
assert.ok(wrapper.exists());
assert.isEmpty(wrapper.children());
});
it("should render a HorizontalRule component", () => {
wrapper = mountComponent({
layout: [{ components: [{ type: "HorizontalRule" }] }],
});
assert.equal(
wrapper
.find(".ds-column-grid div")
.children()
.at(0)
.type(),
HorizontalRule
);
});
it("should render a List component", () => {
wrapper = mountComponent({
layout: [{ components: [{ properties: {}, type: "List" }] }],
});
assert.equal(
wrapper
.find(".ds-column-grid div")
.children()
.at(0)
.type(),
List
);
});
it("should render a Hero component", () => {
wrapper = mountComponent({
layout: [{ components: [{ properties: {}, type: "Hero" }] }],
});
assert.equal(
wrapper
.find(".ds-column-grid div")
.children()
.at(0)
.type(),
Hero
);
});
it("should render a CardGrid component", () => {
wrapper = mountComponent({
layout: [{ components: [{ properties: {}, type: "CardGrid" }] }],
});
assert.equal(
wrapper
.find(".ds-column-grid div")
.children()
.at(0)
.type(),
CardGrid
);
});
it("should render a Navigation component", () => {
wrapper = mountComponent({
layout: [{ components: [{ properties: {}, type: "Navigation" }] }],
});
assert.equal(
wrapper
.find(".ds-column-grid div")
.children()
.at(0)
.type(),
Navigation
);
});
it("should render nothing if there was only a Message", () => {
wrapper = mountComponent({
layout: [
{ components: [{ header: {}, properties: {}, type: "Message" }] },
],
});
assert.isEmpty(wrapper.children());
});
it("should render a regular Message when not collapsible", () => {
wrapper = mountComponent({
config: { collapsible: false },
layout: [
{ components: [{ header: {}, properties: {}, type: "Message" }] },
],
});
assert.equal(
wrapper
.find(".ds-column-grid div")
.children()
.at(0)
.type(),
DSMessage
);
});
it("should convert first Message component to CollapsibleSection", () => {
wrapper = mountComponent({
layout: [
{
components: [
{ header: {}, properties: {}, type: "Message" },
{ type: "HorizontalRule" },
],
},
],
});
assert.equal(
wrapper
.children()
.at(0)
.type(),
CollapsibleSection
);
});
it("should render a Message component", () => {
wrapper = mountComponent({
layout: [
{
components: [
{ header: {}, type: "Message" },
{ properties: {}, type: "Message" },
],
},
],
});
assert.equal(
wrapper
.find(".ds-column-grid div")
.children()
.at(0)
.type(),
DSMessage
);
});
it("should render a SectionTitle component", () => {
wrapper = mountComponent({
layout: [{ components: [{ properties: {}, type: "SectionTitle" }] }],
});
assert.equal(
wrapper
.find(".ds-column-grid div")
.children()
.at(0)
.type(),
SectionTitle
);
});
it("should render TopSites", () => {
wrapper = mountComponent({
layout: [{ components: [{ properties: {}, type: "TopSites" }] }],
});
assert.equal(
wrapper
.find(".ds-column-grid div")
.children()
.at(0)
.type(),
TopSites
);
});
describe("#onStyleMount", () => {
let parseStub;
beforeEach(() => {
parseStub = sandbox.stub();
globals.set("JSON", { parse: parseStub });
});
afterEach(() => {
sandbox.restore();
globals.restore();
});
it("should return if no style", () => {
assert.isUndefined(wrapper.instance().onStyleMount());
assert.notCalled(parseStub);
});
it("should insert rules", () => {
const sheetStub = { insertRule: sandbox.stub(), cssRules: [{}] };
parseStub.returns([
[
null,
{
".ds-message": "margin-bottom: -20px",
},
null,
null,
],
]);
wrapper.instance().onStyleMount({ sheet: sheetStub, dataset: {} });
assert.calledOnce(sheetStub.insertRule);
assert.calledWithExactly(sheetStub.insertRule, "DUMMY#CSS.SELECTOR {}");
});
});
});
================================================
FILE: test/unit/content-src/components/DiscoveryStreamComponents/CardGrid.test.jsx
================================================
import { CardGrid } from "content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid";
import { DSCard } from "content-src/components/DiscoveryStreamComponents/DSCard/DSCard";
import React from "react";
import { shallow } from "enzyme";
describe("", () => {
let wrapper;
beforeEach(() => {
wrapper = shallow( );
});
it("should render an empty div", () => {
assert.ok(wrapper.exists());
assert.lengthOf(wrapper.children(), 0);
});
it("should render DSCards", () => {
wrapper.setProps({ items: 2, data: { recommendations: [{}, {}] } });
assert.lengthOf(wrapper.find(".ds-card-grid").children(), 2);
assert.equal(
wrapper
.find(".ds-card-grid")
.children()
.at(0)
.type(),
DSCard
);
});
it("should add divisible-by-4 to the grid", () => {
wrapper.setProps({ items: 4, data: { recommendations: [{}, {}] } });
assert.ok(wrapper.find(".ds-card-grid-divisible-by-4").exists());
});
it("should add divisible-by-3 to the grid", () => {
wrapper.setProps({ items: 3, data: { recommendations: [{}, {}] } });
assert.ok(wrapper.find(".ds-card-grid-divisible-by-3").exists());
});
});
================================================
FILE: test/unit/content-src/components/DiscoveryStreamComponents/DSCard.test.jsx
================================================
import {
DSCard,
DefaultMeta,
PlaceholderDSCard,
CTAButtonMeta,
} from "content-src/components/DiscoveryStreamComponents/DSCard/DSCard";
import {
DSContextFooter,
StatusMessage,
} from "content-src/components/DiscoveryStreamComponents/DSContextFooter/DSContextFooter";
import { actionCreators as ac } from "common/Actions.jsm";
import { DSLinkMenu } from "content-src/components/DiscoveryStreamComponents/DSLinkMenu/DSLinkMenu";
import React from "react";
import { SafeAnchor } from "content-src/components/DiscoveryStreamComponents/SafeAnchor/SafeAnchor";
import { shallow, mount } from "enzyme";
describe("", () => {
let wrapper;
let sandbox;
beforeEach(() => {
wrapper = shallow( );
wrapper.setState({ isSeen: true });
sandbox = sinon.createSandbox();
});
afterEach(() => {
sandbox.restore();
});
it("should render", () => {
assert.ok(wrapper.exists());
assert.ok(wrapper.find(".ds-card"));
});
it("should render a SafeAnchor", () => {
wrapper.setProps({ url: "https://foo.com" });
assert.equal(
wrapper
.children()
.at(0)
.type(),
SafeAnchor
);
assert.propertyVal(
wrapper
.children()
.at(0)
.props(),
"url",
"https://foo.com"
);
});
it("should pass onLinkClick prop", () => {
assert.propertyVal(
wrapper
.children()
.at(0)
.props(),
"onLinkClick",
wrapper.instance().onLinkClick
);
});
it("should render DSLinkMenu", () => {
assert.equal(
wrapper
.children()
.at(1)
.type(),
DSLinkMenu
);
});
it("should start with no .active class", () => {
assert.equal(wrapper.find(".active").length, 0);
});
it("should render badges for pocket, bookmark when not a spoc element ", () => {
wrapper = mount( );
wrapper.setState({ isSeen: true });
const contextFooter = wrapper.find(DSContextFooter);
assert.lengthOf(contextFooter.find(StatusMessage), 1);
});
it("should render Sponsored Context for a spoc element", () => {
const context = "Sponsored by Foo";
wrapper = mount( );
wrapper.setState({ isSeen: true });
const contextFooter = wrapper.find(DSContextFooter);
assert.lengthOf(contextFooter.find(StatusMessage), 0);
assert.equal(contextFooter.find(".story-sponsored-label").text(), context);
});
describe("onLinkClick", () => {
let dispatch;
beforeEach(() => {
dispatch = sandbox.stub();
wrapper = shallow( );
wrapper.setState({ isSeen: true });
});
it("should call dispatch with the correct events", () => {
wrapper.setProps({ id: "fooidx", pos: 1, type: "foo" });
wrapper.instance().onLinkClick();
assert.calledTwice(dispatch);
assert.calledWith(
dispatch,
ac.UserEvent({
event: "CLICK",
source: "FOO",
action_position: 1,
value: { card_type: "organic" },
})
);
assert.calledWith(
dispatch,
ac.ImpressionStats({
click: 0,
source: "FOO",
tiles: [{ id: "fooidx", pos: 1 }],
})
);
});
it("should set the right card_type on spocs", () => {
wrapper.setProps({ id: "fooidx", pos: 1, type: "foo", flightId: 12345 });
wrapper.instance().onLinkClick();
assert.calledTwice(dispatch);
assert.calledWith(
dispatch,
ac.UserEvent({
event: "CLICK",
source: "FOO",
action_position: 1,
value: { card_type: "spoc" },
})
);
assert.calledWith(
dispatch,
ac.ImpressionStats({
click: 0,
source: "FOO",
tiles: [{ id: "fooidx", pos: 1 }],
})
);
});
it("should call dispatch with a shim", () => {
wrapper.setProps({
id: "fooidx",
pos: 1,
type: "foo",
shim: {
click: "click shim",
},
});
wrapper.instance().onLinkClick();
assert.calledTwice(dispatch);
assert.calledWith(
dispatch,
ac.UserEvent({
event: "CLICK",
source: "FOO",
action_position: 1,
value: { card_type: "organic" },
})
);
assert.calledWith(
dispatch,
ac.ImpressionStats({
click: 0,
source: "FOO",
tiles: [{ id: "fooidx", pos: 1, shim: "click shim" }],
})
);
});
});
describe("DSCard with CTA", () => {
beforeEach(() => {
wrapper = mount( );
wrapper.setState({ isSeen: true });
});
it("should render Default Meta", () => {
const default_meta = wrapper.find(DefaultMeta);
assert.ok(default_meta.exists());
});
it("should not render cta-link for item with no cta", () => {
const meta = wrapper.find(DefaultMeta);
assert.notOk(meta.find(".cta-link").exists());
});
it("should not render cta-link by default when item has cta and cta_variant not link", () => {
wrapper.setProps({ cta: "test" });
const meta = wrapper.find(DefaultMeta);
assert.notOk(meta.find(".cta-link").exists());
});
it("should render cta-link by default when item has cta and cta_variant as link", () => {
wrapper.setProps({ cta: "test", cta_variant: "link" });
const meta = wrapper.find(DefaultMeta);
assert.equal(meta.find(".cta-link").text(), "test");
});
it("should not render cta-button for non spoc content", () => {
wrapper.setProps({ cta: "test", cta_variant: "button" });
const meta = wrapper.find(CTAButtonMeta);
assert.lengthOf(meta.find(".cta-button"), 0);
});
it("should render cta-button when item has cta and cta_variant is button and is spoc", () => {
wrapper.setProps({
cta: "test",
cta_variant: "button",
context: "Sponsored by Foo",
});
const meta = wrapper.find(CTAButtonMeta);
assert.equal(meta.find(".cta-button").text(), "test");
});
it("should not render Sponsored by label in footer for spoc item with cta_variant button", () => {
wrapper.setProps({
cta: "test",
context: "Sponsored by test",
cta_variant: "button",
});
assert.ok(wrapper.find(CTAButtonMeta).exists());
assert.notOk(wrapper.find(DSContextFooter).exists());
});
it("should render sponsor text on top for spoc item and cta button variant", () => {
wrapper.setProps({
sponsor: "Test",
context: "Sponsored by test",
cta_variant: "button",
});
assert.ok(wrapper.find(CTAButtonMeta).exists());
const meta = wrapper.find(CTAButtonMeta);
assert.equal(meta.find(".source").text(), "Test · Sponsored");
});
});
describe("DSCard with Intersection Observer", () => {
beforeEach(() => {
wrapper = shallow( );
});
it("should render card when seen", () => {
let card = wrapper.find("div.ds-card.placeholder");
assert.lengthOf(card, 1);
wrapper.instance().observer = {
unobserve: sandbox.stub(),
};
wrapper.instance().placeholderElement = "element";
wrapper.instance().onSeen([
{
isIntersecting: true,
},
]);
assert.isTrue(wrapper.instance().state.isSeen);
card = wrapper.find("div.ds-card.placeholder");
assert.lengthOf(card, 0);
assert.lengthOf(wrapper.find(SafeAnchor), 1);
assert.calledOnce(wrapper.instance().observer.unobserve);
assert.calledWith(wrapper.instance().observer.unobserve, "element");
});
it("should setup proper placholder ref for isSeen", () => {
wrapper.instance().setPlaceholderRef("element");
assert.equal(wrapper.instance().placeholderElement, "element");
});
it("should setup observer on componentDidMount", () => {
wrapper = mount( );
assert.isTrue(!!wrapper.instance().observer);
});
});
describe("DSCard with Idle Callback", () => {
let windowStub = {
requestIdleCallback: sinon.stub().returns(1),
cancelIdleCallback: sinon.stub(),
};
beforeEach(() => {
wrapper = shallow( );
});
it("should call requestIdleCallback on componentDidMount", () => {
assert.calledOnce(windowStub.requestIdleCallback);
});
it("should call cancelIdleCallback on componentWillUnmount", () => {
wrapper.instance().componentWillUnmount();
assert.calledOnce(windowStub.cancelIdleCallback);
});
});
});
describe(" component", () => {
it("should have placeholder prop", () => {
const wrapper = shallow( );
const card = wrapper.find(DSCard);
assert.lengthOf(card, 1);
const placeholder = wrapper.find(DSCard).prop("placeholder");
assert.isTrue(placeholder);
});
it("should contain placeholder div", () => {
const wrapper = shallow( );
wrapper.setState({ isSeen: true });
const card = wrapper.find("div.ds-card.placeholder");
assert.lengthOf(card, 1);
});
it("should not be clickable", () => {
const wrapper = shallow( );
wrapper.setState({ isSeen: true });
const anchor = wrapper.find("SafeAnchor.ds-card-link");
assert.lengthOf(anchor, 0);
});
it("should not have context menu", () => {
const wrapper = shallow( );
wrapper.setState({ isSeen: true });
const linkMenu = wrapper.find(DSLinkMenu);
assert.lengthOf(linkMenu, 0);
});
});
================================================
FILE: test/unit/content-src/components/DiscoveryStreamComponents/DSContextFooter.test.jsx
================================================
import {
DSContextFooter,
StatusMessage,
} from "content-src/components/DiscoveryStreamComponents/DSContextFooter/DSContextFooter";
import React from "react";
import { mount } from "enzyme";
import { cardContextTypes } from "content-src/components/Card/types.js";
describe("", () => {
let wrapper;
let sandbox;
const bookmarkBadge = "bookmark";
const removeBookmarkBadge = "removedBookmark";
const context = "Sponsored by Babel";
const engagement = "Popular";
beforeEach(() => {
wrapper = mount( );
sandbox = sinon.createSandbox();
});
afterEach(() => {
sandbox.restore();
});
it("should render", () => {
assert.isTrue(wrapper.exists());
assert.isOk(wrapper.find(".story-footer"));
});
it("should not render an engagement status if display_engagement_labels is false", () => {
wrapper = mount(
);
const engagementLabel = wrapper.find(".story-view-count");
assert.equal(engagementLabel.length, 0);
});
it("should render an engagement status if no badge and spoc passed", () => {
wrapper = mount(
);
const engagementLabel = wrapper.find(".story-view-count");
assert.equal(engagementLabel.text(), engagement);
});
it("should render a badge if a proper badge prop is passed", () => {
wrapper = mount(
);
const { fluentID } = cardContextTypes[bookmarkBadge];
assert.lengthOf(wrapper.find(".story-view-count"), 0);
const statusLabel = wrapper.find(".story-context-label");
assert.equal(statusLabel.prop("data-l10n-id"), fluentID);
});
it("should only render a sponsored context if pass a sponsored context", async () => {
wrapper = mount(
);
assert.lengthOf(wrapper.find(".story-view-count"), 0);
assert.lengthOf(wrapper.find(StatusMessage), 0);
assert.equal(wrapper.find(".story-sponsored-label").text(), context);
});
it("should render a new badge if props change from an old badge to a new one", async () => {
wrapper = mount( );
const { fluentID: bookmarkFluentID } = cardContextTypes[bookmarkBadge];
const bookmarkStatusMessage = wrapper.find(
`div[data-l10n-id='${bookmarkFluentID}']`
);
assert.isTrue(bookmarkStatusMessage.exists());
const { fluentID: removeBookmarkFluentID } = cardContextTypes[
removeBookmarkBadge
];
wrapper.setProps({ context_type: removeBookmarkBadge });
await wrapper.update();
assert.isEmpty(bookmarkStatusMessage);
const removedBookmarkStatusMessage = wrapper.find(
`div[data-l10n-id='${removeBookmarkFluentID}']`
);
assert.isTrue(removedBookmarkStatusMessage.exists());
});
});
================================================
FILE: test/unit/content-src/components/DiscoveryStreamComponents/DSDismiss.test.jsx
================================================
import { DSDismiss } from "content-src/components/DiscoveryStreamComponents/DSDismiss/DSDismiss";
import React from "react";
import { shallow } from "enzyme";
describe("", () => {
const fakeSpoc = {
url: "https://foo.com",
guid: "1234",
};
let wrapper;
let sandbox;
let dispatchStub;
beforeEach(() => {
sandbox = sinon.createSandbox();
dispatchStub = sandbox.stub();
wrapper = shallow(
);
});
afterEach(() => {
sandbox.restore();
});
it("should render", () => {
assert.ok(wrapper.exists());
assert.ok(wrapper.find(".ds-dismiss").exists());
});
it("should render proper hover state", () => {
wrapper.instance().onHover();
assert.ok(wrapper.find(".hovering").exists());
wrapper.instance().offHover();
assert.ok(!wrapper.find(".hovering").exists());
});
it("should dispatch a BlockUrl event on click", () => {
wrapper.instance().onDismissClick();
assert.calledThrice(dispatchStub);
assert.deepEqual(dispatchStub.firstCall.args[0].data, {
url: "https://foo.com",
pocket_id: undefined,
});
assert.deepEqual(dispatchStub.secondCall.args[0].data, {
event: "BLOCK",
source: "DISCOVERY_STREAM",
action_position: 0,
url: "https://foo.com",
guid: "1234",
});
assert.deepEqual(dispatchStub.thirdCall.args[0].data, {
source: "DISCOVERY_STREAM",
block: 0,
tiles: [
{
id: "1234",
pos: 0,
},
],
});
});
});
================================================
FILE: test/unit/content-src/components/DiscoveryStreamComponents/DSEmptyState.test.jsx
================================================
import { DSEmptyState } from "content-src/components/DiscoveryStreamComponents/DSEmptyState/DSEmptyState";
import React from "react";
import { shallow } from "enzyme";
describe("", () => {
let wrapper;
beforeEach(() => {
wrapper = shallow( );
});
it("should render", () => {
assert.ok(wrapper.exists());
assert.ok(wrapper.find(".section-empty-state").exists());
});
it("should render defaultempty state message", () => {
assert.ok(wrapper.find(".empty-state-message").exists());
const header = wrapper.find(
"h2[data-l10n-id='newtab-discovery-empty-section-topstories-header']"
);
const paragraph = wrapper.find(
"p[data-l10n-id='newtab-discovery-empty-section-topstories-content']"
);
assert.ok(header.exists());
assert.ok(paragraph.exists());
});
it("should render failed state message", () => {
wrapper = shallow( );
const button = wrapper.find(
"button[data-l10n-id='newtab-discovery-empty-section-topstories-try-again-button']"
);
assert.ok(button.exists());
});
it("should render waiting state message", () => {
wrapper = shallow( );
const button = wrapper.find(
"button[data-l10n-id='newtab-discovery-empty-section-topstories-loading']"
);
assert.ok(button.exists());
});
it("should dispatch DISCOVERY_STREAM_RETRY_FEED on failed state button click", () => {
const dispatch = sinon.spy();
wrapper = shallow(
);
wrapper.find("button.try-again-button").simulate("click");
assert.calledTwice(dispatch);
let [action] = dispatch.firstCall.args;
assert.equal(action.type, "DISCOVERY_STREAM_FEED_UPDATE");
assert.deepEqual(action.data.feed, {
url: "https://foo.com",
data: { status: "waiting" },
});
[action] = dispatch.secondCall.args;
assert.equal(action.type, "DISCOVERY_STREAM_RETRY_FEED");
assert.deepEqual(action.data.feed, { url: "https://foo.com", data: {} });
});
});
================================================
FILE: test/unit/content-src/components/DiscoveryStreamComponents/DSImage.test.jsx
================================================
import { DSImage } from "content-src/components/DiscoveryStreamComponents/DSImage/DSImage";
import { mount } from "enzyme";
import React from "react";
describe("Discovery Stream ", () => {
let sandbox;
beforeEach(() => {
sandbox = sinon.createSandbox();
});
it("should have a child with class ds-image", () => {
const img = mount( );
const child = img.find(".ds-image");
assert.lengthOf(child, 1);
});
it("should set proper sources if only `source` is available", () => {
const img = mount( );
img.setState({
isSeen: true,
containerWidth: 640,
});
assert.equal(
img.find("img").prop("src"),
"https://placekitten.com/g/640/480"
);
});
it("should set proper sources if `rawSource` is available", () => {
const img = mount(
);
img.setState({
isSeen: true,
containerWidth: 640,
containerHeight: 480,
});
assert.equal(
img.find("img").prop("src"),
"https://img-getpocket.cdn.mozilla.net/640x480/filters:format(jpeg):quality(60):no_upscale():strip_exif()/https%3A%2F%2Fplacekitten.com%2Fg%2F640%2F480"
);
assert.equal(
img.find("img").prop("srcSet"),
"https://img-getpocket.cdn.mozilla.net/1280x960/filters:format(jpeg):quality(60):no_upscale():strip_exif()/https%3A%2F%2Fplacekitten.com%2Fg%2F640%2F480 2x"
);
});
it("should fall back to unoptimized when optimized failed", () => {
const img = mount(
);
img.setState({
isSeen: true,
containerWidth: 640,
containerHeight: 480,
});
img.instance().onOptimizedImageError();
img.update();
assert.equal(
img.find("img").prop("src"),
"https://placekitten.com/g/640/480"
);
});
it("should render a placeholder broken image when image failed", () => {
const img = mount( );
img.setState({ isSeen: true });
img.instance().onNonOptimizedImageError();
img.update();
assert.equal(img.find("div").prop("className"), "broken-image");
});
it("should update state when seen", () => {
const img = mount(
);
img.instance().onSeen([
{
isIntersecting: true,
boundingClientRect: {
width: 640,
height: 480,
},
},
]);
assert.equal(img.state().containerWidth, 640);
assert.equal(img.state().containerHeight, 480);
assert.propertyVal(img.state(), "isSeen", true);
});
it("should stop observing when removed", () => {
const img = mount( );
const { observer } = img.instance();
sandbox.stub(observer, "unobserve");
img.unmount();
assert.calledOnce(observer.unobserve);
});
describe("DSImage with Idle Callback", () => {
let wrapper;
let windowStub = {
requestIdleCallback: sinon.stub().returns(1),
cancelIdleCallback: sinon.stub(),
};
beforeEach(() => {
wrapper = mount( );
});
it("should call requestIdleCallback on componentDidMount", () => {
assert.calledOnce(windowStub.requestIdleCallback);
});
it("should call cancelIdleCallback on componentWillUnmount", () => {
wrapper.instance().componentWillUnmount();
assert.calledOnce(windowStub.cancelIdleCallback);
});
});
});
================================================
FILE: test/unit/content-src/components/DiscoveryStreamComponents/DSLinkMenu.test.jsx
================================================
import { mount, shallow } from "enzyme";
import { DSLinkMenu } from "content-src/components/DiscoveryStreamComponents/DSLinkMenu/DSLinkMenu";
import { ContextMenuButton } from "content-src/components/ContextMenu/ContextMenuButton";
import { LinkMenu } from "content-src/components/LinkMenu/LinkMenu";
import React from "react";
describe("", () => {
let wrapper;
let parentNode;
describe("DS link menu actions", () => {
beforeEach(() => {
wrapper = mount( );
parentNode = wrapper.getDOMNode().parentNode;
});
afterEach(() => {
wrapper.unmount();
});
it("Should remove active on Menu Update", () => {
// Add active class name to DSLinkMenu parent node
// to simulate menu open state
parentNode.classList.add("active");
assert.equal(parentNode.className, "active");
wrapper.instance().onMenuUpdate(false);
wrapper.update();
assert.isEmpty(parentNode.className);
});
it("Should add active on Menu Show", async () => {
wrapper.instance().nextAnimationFrame = () => {};
await wrapper.instance().onMenuShow();
wrapper.update();
assert.equal(parentNode.className, "active");
});
it("Should add last-item to support resized window", async () => {
const fakeWindow = { scrollMaxX: "20" };
wrapper = mount( );
parentNode = wrapper.getDOMNode().parentNode;
wrapper.instance().nextAnimationFrame = () => {};
await wrapper.instance().onMenuShow();
wrapper.update();
assert.equal(parentNode.className, "last-item active");
});
it("Should call rAF from nextAnimationFrame", () => {
const fakeWindow = { requestAnimationFrame: sinon.stub() };
wrapper = mount( );
wrapper.instance().nextAnimationFrame();
assert.calledOnce(fakeWindow.requestAnimationFrame);
});
it("should remove .active and .last-item classes from the parent component", () => {
const instance = wrapper.instance();
const remove = sinon.stub();
instance.contextMenuButtonRef = {
current: {
parentElement: { parentElement: { classList: { remove } } },
},
};
instance.onMenuUpdate();
assert.calledOnce(remove);
});
it("should add .active and .last-item classes to the parent component", async () => {
const instance = wrapper.instance();
const add = sinon.stub();
instance.nextAnimationFrame = () => {};
instance.contextMenuButtonRef = {
current: { parentElement: { parentElement: { classList: { add } } } },
};
await instance.onMenuShow();
assert.calledOnce(add);
});
it("should parse args for fluent correctly ", () => {
const title = '"fluent"';
wrapper = mount( );
const button = wrapper.find(
"button[data-l10n-id='newtab-menu-content-tooltip']"
);
assert.equal(button.prop("data-l10n-args"), JSON.stringify({ title }));
});
});
describe("DS context menu options", () => {
const ValidDSLinkMenuProps = {
site: {},
};
beforeEach(() => {
wrapper = shallow( );
});
it("should render a context menu button", () => {
assert.ok(wrapper.exists());
assert.ok(
wrapper.find(ContextMenuButton).exists(),
"context menu button exists"
);
});
it("should render LinkMenu when context menu button is clicked", () => {
let button = wrapper.find(ContextMenuButton);
button.simulate("click", { preventDefault: () => {} });
assert.equal(wrapper.find(LinkMenu).length, 1);
});
it("should pass dispatch, onShow, site, options, shouldSendImpressionStats, source and index to LinkMenu", () => {
wrapper
.find(ContextMenuButton)
.simulate("click", { preventDefault: () => {} });
const linkMenuProps = wrapper.find(LinkMenu).props();
[
"dispatch",
"onShow",
"site",
"index",
"options",
"source",
"shouldSendImpressionStats",
].forEach(prop => assert.property(linkMenuProps, prop));
});
it("should pass through the correct menu options to LinkMenu", () => {
wrapper
.find(ContextMenuButton)
.simulate("click", { preventDefault: () => {} });
const linkMenuProps = wrapper.find(LinkMenu).props();
assert.deepEqual(linkMenuProps.options, [
"CheckBookmarkOrArchive",
"CheckSavedToPocket",
"Separator",
"OpenInNewWindow",
"OpenInPrivateWindow",
"Separator",
"BlockUrl",
]);
});
it("should pass through the correct menu options to LinkMenu for spocs", () => {
wrapper = shallow(
);
wrapper
.find(ContextMenuButton)
.simulate("click", { preventDefault: () => {} });
const linkMenuProps = wrapper.find(LinkMenu).props();
assert.deepEqual(linkMenuProps.options, [
"CheckBookmarkOrArchive",
"CheckSavedToPocket",
"Separator",
"OpenInNewWindow",
"OpenInPrivateWindow",
"Separator",
"BlockUrl",
"ShowPrivacyInfo",
]);
});
});
});
================================================
FILE: test/unit/content-src/components/DiscoveryStreamComponents/DSMessage.test.jsx
================================================
import { DSMessage } from "content-src/components/DiscoveryStreamComponents/DSMessage/DSMessage";
import React from "react";
import { SafeAnchor } from "content-src/components/DiscoveryStreamComponents/SafeAnchor/SafeAnchor";
import { FluentOrText } from "content-src/components/FluentOrText/FluentOrText";
import { mount } from "enzyme";
describe("", () => {
let wrapper;
beforeEach(() => {
wrapper = mount( );
});
it("should render", () => {
assert.ok(wrapper.exists());
assert.ok(wrapper.find(".ds-message").exists());
});
it("should render an icon", () => {
wrapper.setProps({ icon: "foo" });
assert.ok(wrapper.find(".glyph").exists());
assert.propertyVal(
wrapper.find(".glyph").props().style,
"backgroundImage",
`url(foo)`
);
});
it("should render a title", () => {
wrapper.setProps({ title: "foo" });
assert.ok(wrapper.find(".title-text").exists());
assert.equal(wrapper.find(".title-text").text(), "foo");
});
it("should render a SafeAnchor", () => {
wrapper.setProps({ link_text: "foo", link_url: "https://foo.com" });
assert.equal(
wrapper
.find(".title")
.children()
.at(0)
.type(),
SafeAnchor
);
});
it("should render a FluentOrText", () => {
wrapper.setProps({
link_text: "link_text",
title: "title",
link_url: "https://link_url.com",
});
assert.equal(
wrapper
.find(".title-text")
.children()
.at(0)
.type(),
FluentOrText
);
assert.equal(
wrapper
.find(".link a")
.children()
.at(0)
.type(),
FluentOrText
);
});
});
================================================
FILE: test/unit/content-src/components/DiscoveryStreamComponents/DSPrivacyModal.test.jsx
================================================
import { DSPrivacyModal } from "content-src/components/DiscoveryStreamComponents/DSPrivacyModal/DSPrivacyModal";
import { shallow, mount } from "enzyme";
import { actionCreators as ac } from "common/Actions.jsm";
import React from "react";
describe("Discovery Stream ", () => {
let sandbox;
let dispatch;
let wrapper;
beforeEach(() => {
sandbox = sinon.createSandbox();
dispatch = sandbox.stub();
wrapper = shallow( );
});
afterEach(() => {
sandbox.restore();
});
it("should contain a privacy notice", () => {
const modal = mount( );
const child = modal.find(".privacy-notice");
assert.lengthOf(child, 1);
});
it("should call dispatch when modal is closed", () => {
wrapper.instance().closeModal();
assert.calledOnce(dispatch);
});
it("should call dispatch with the correct events", () => {
wrapper.instance().onLinkClick();
assert.calledOnce(dispatch);
assert.calledWith(
dispatch,
ac.UserEvent({
event: "CLICK_PRIVACY_INFO",
source: "DS_PRIVACY_MODAL",
})
);
});
});
================================================
FILE: test/unit/content-src/components/DiscoveryStreamComponents/DSTextPromo.test.jsx
================================================
import { DSTextPromo } from "content-src/components/DiscoveryStreamComponents/DSTextPromo/DSTextPromo";
import React from "react";
import { shallow } from "enzyme";
describe("", () => {
let wrapper;
let sandbox;
let dispatchStub;
beforeEach(() => {
sandbox = sinon.createSandbox();
dispatchStub = sandbox.stub();
wrapper = shallow(
);
});
afterEach(() => {
sandbox.restore();
});
it("should render", () => {
assert.ok(wrapper.exists());
assert.ok(wrapper.find(".ds-text-promo").exists());
});
it("should render a header", () => {
wrapper.setProps({ header: "foo" });
assert.ok(wrapper.find(".text").exists());
});
it("should render a subtitle", () => {
wrapper.setProps({ subtitle: "foo" });
assert.ok(wrapper.find(".subtitle").exists());
});
it("should dispatch a click event on click", () => {
wrapper.instance().onLinkClick();
assert.calledTwice(dispatchStub);
assert.deepEqual(dispatchStub.firstCall.args[0].data, {
event: "CLICK",
source: "TEXTPROMO",
action_position: 0,
});
assert.deepEqual(dispatchStub.secondCall.args[0].data, {
source: "TEXTPROMO",
click: 0,
tiles: [{ id: "1234", pos: 0 }],
});
});
});
================================================
FILE: test/unit/content-src/components/DiscoveryStreamComponents/Hero.test.jsx
================================================
import {
DSCard,
PlaceholderDSCard,
} from "content-src/components/DiscoveryStreamComponents/DSCard/DSCard";
import {
DSContextFooter,
StatusMessage,
} from "content-src/components/DiscoveryStreamComponents/DSContextFooter/DSContextFooter";
import { actionCreators as ac } from "common/Actions.jsm";
import { DSEmptyState } from "content-src/components/DiscoveryStreamComponents/DSEmptyState/DSEmptyState";
import { Hero } from "content-src/components/DiscoveryStreamComponents/Hero/Hero";
import { List } from "content-src/components/DiscoveryStreamComponents/List/List";
import React from "react";
import { shallow } from "enzyme";
describe("", () => {
let DEFAULT_PROPS;
beforeEach(() => {
DEFAULT_PROPS = {
data: {
recommendations: [{ url: 1 }, { url: 2 }, { url: 3 }],
},
};
});
it("should render with nothing", () => {
const wrapper = shallow( );
assert.lengthOf(wrapper.find("a"), 0);
});
it("should return Empty State for no recommendations", () => {
const heroProps = {
data: { recommendations: [] },
header: { title: "headerTitle" },
};
const wrapper = shallow( );
const dsEmptyState = wrapper.find(DSEmptyState);
const dsHeader = wrapper.find(".ds-header");
const dsHero = wrapper.find(".ds-hero.empty");
assert.ok(wrapper.exists());
assert.lengthOf(dsEmptyState, 1);
assert.lengthOf(dsHeader, 1);
assert.lengthOf(dsHero, 1);
});
it("should render a hero link with expected url", () => {
const wrapper = shallow( );
assert.equal(
wrapper.find("SafeAnchor").prop("url"),
DEFAULT_PROPS.data.recommendations[0].url
);
});
it("should render badges for pocket, bookmark when not a spoc element ", () => {
const heroProps = {
data: { recommendations: [{ context_type: "bookmark" }] },
header: { title: "headerTitle" },
};
const wrapper = shallow( );
const contextFooter = wrapper.find(DSContextFooter).shallow();
assert.lengthOf(contextFooter.find(StatusMessage), 1);
});
it("should render Sponsored Context for a spoc element", () => {
const heroProps = {
data: {
recommendations: [
{ context_type: "bookmark", context: "Sponsored by Foo" },
],
},
header: { title: "headerTitle" },
};
const wrapper = shallow( );
const contextFooter = wrapper.find(DSContextFooter).shallow();
assert.lengthOf(contextFooter.find(StatusMessage), 0);
assert.equal(
contextFooter.find(".story-sponsored-label").text(),
heroProps.data.recommendations[0].context
);
});
describe("subComponent: cards", () => {
beforeEach(() => {
DEFAULT_PROPS.subComponentType = "cards";
});
it("should render no cards for 1 hero item", () => {
const wrapper = shallow( );
assert.lengthOf(wrapper.find(DSCard), 0);
});
it("should render 1 card with expected url for 2 hero items", () => {
const wrapper = shallow( );
assert.equal(
wrapper.find(DSCard).prop("url"),
DEFAULT_PROPS.data.recommendations[1].url
);
});
it("should return PlaceholderDSCard for recommendations less than items", () => {
const wrapper = shallow( );
const dsCard = wrapper.find(DSCard);
assert.lengthOf(dsCard, 2);
const placeholderDSCard = wrapper.find(PlaceholderDSCard);
assert.lengthOf(placeholderDSCard, 1);
});
});
describe("subComponent: list", () => {
beforeEach(() => {
DEFAULT_PROPS.subComponentType = "list";
});
it("should render list with no items for 1 hero item", () => {
const wrapper = shallow( );
assert.equal(wrapper.find(List).prop("items"), 0);
});
it("should render list with 1 item for 2 hero items", () => {
const wrapper = shallow( );
assert.equal(wrapper.find(List).prop("items"), 1);
});
});
describe("onLinkClick", () => {
let dispatch;
let sandbox;
let wrapper;
const heroProps = {
data: { recommendations: [{ url: 1, id: "foo-id", pos: 1 }] },
type: "foo",
items: 1,
};
beforeEach(() => {
sandbox = sinon.createSandbox();
dispatch = sandbox.stub();
wrapper = shallow( );
});
afterEach(() => {
sandbox.restore();
});
it("should call dispatch with the correct events", () => {
wrapper.instance().onLinkClick();
assert.calledTwice(dispatch);
assert.calledWith(
dispatch,
ac.UserEvent({
event: "CLICK",
source: "FOO",
action_position: 1,
})
);
assert.calledWith(
dispatch,
ac.ImpressionStats({
click: 0,
source: "FOO",
tiles: [{ id: "foo-id", pos: 1 }],
})
);
});
});
});
================================================
FILE: test/unit/content-src/components/DiscoveryStreamComponents/Highlights.test.jsx
================================================
import { combineReducers, createStore } from "redux";
import { INITIAL_STATE, reducers } from "common/Reducers.jsm";
import { Highlights } from "content-src/components/DiscoveryStreamComponents/Highlights/Highlights";
import { mount } from "enzyme";
import { Provider } from "react-redux";
import React from "react";
describe("Discovery Stream ", () => {
let wrapper;
afterEach(() => {
wrapper.unmount();
});
it("should render nothing with no highlights data", () => {
const store = createStore(combineReducers(reducers), { ...INITIAL_STATE });
wrapper = mount(
);
assert.ok(wrapper.isEmptyRender());
});
it("should render highlights", () => {
const store = createStore(combineReducers(reducers), {
...INITIAL_STATE,
Sections: [{ id: "highlights", enabled: true }],
});
wrapper = mount(
);
assert.lengthOf(wrapper.find(".ds-highlights"), 1);
});
});
================================================
FILE: test/unit/content-src/components/DiscoveryStreamComponents/HorizontalRule.test.jsx
================================================
import { HorizontalRule } from "content-src/components/DiscoveryStreamComponents/HorizontalRule/HorizontalRule";
import React from "react";
import { shallow } from "enzyme";
describe("", () => {
let wrapper;
beforeEach(() => {
wrapper = shallow( );
});
it("should render", () => {
assert.ok(wrapper.exists());
assert.ok(wrapper.find(".ds-hr").exists());
});
});
================================================
FILE: test/unit/content-src/components/DiscoveryStreamComponents/ImpressionStats.test.jsx
================================================
"use strict";
import {
ImpressionStats,
INTERSECTION_RATIO,
} from "content-src/components/DiscoveryStreamImpressionStats/ImpressionStats";
import { actionTypes as at } from "common/Actions.jsm";
import React from "react";
import { shallow } from "enzyme";
describe("", () => {
const SOURCE = "TEST_SOURCE";
const FullIntersectEntries = [
{ isIntersecting: true, intersectionRatio: INTERSECTION_RATIO },
];
const ZeroIntersectEntries = [
{ isIntersecting: false, intersectionRatio: 0 },
];
const PartialIntersectEntries = [
{ isIntersecting: true, intersectionRatio: INTERSECTION_RATIO / 2 },
];
// Build IntersectionObserver class with the arg `entries` for the intersect callback.
function buildIntersectionObserver(entries) {
return class {
constructor(callback) {
this.callback = callback;
}
observe() {
this.callback(entries);
}
unobserve() {}
};
}
const DEFAULT_PROPS = {
rows: [{ id: 1, pos: 0 }, { id: 2, pos: 1 }, { id: 3, pos: 2 }],
source: SOURCE,
IntersectionObserver: buildIntersectionObserver(FullIntersectEntries),
document: {
visibilityState: "visible",
addEventListener: sinon.stub(),
removeEventListener: sinon.stub(),
},
};
const InnerEl = () => Inner Element
;
function renderImpressionStats(props = {}) {
return shallow(
);
}
it("should render props.children", () => {
const wrapper = renderImpressionStats();
assert.ok(wrapper.contains( ));
});
it("should not send loaded content nor impression when the page is not visible", () => {
const dispatch = sinon.spy();
const props = {
dispatch,
document: {
visibilityState: "hidden",
addEventListener: sinon.spy(),
removeEventListener: sinon.spy(),
},
};
renderImpressionStats(props);
assert.notCalled(dispatch);
});
it("should noly send loaded content but not impression when the wrapped item is not visbible", () => {
const dispatch = sinon.spy();
const props = {
dispatch,
IntersectionObserver: buildIntersectionObserver(ZeroIntersectEntries),
};
renderImpressionStats(props);
// This one is for loaded content.
assert.calledOnce(dispatch);
const [action] = dispatch.firstCall.args;
assert.equal(action.type, at.DISCOVERY_STREAM_LOADED_CONTENT);
assert.equal(action.data.source, SOURCE);
assert.deepEqual(action.data.tiles, [
{ id: 1, pos: 0 },
{ id: 2, pos: 1 },
{ id: 3, pos: 2 },
]);
});
it("should not send impression when the wrapped item is visbible but below the ratio", () => {
const dispatch = sinon.spy();
const props = {
dispatch,
IntersectionObserver: buildIntersectionObserver(PartialIntersectEntries),
};
renderImpressionStats(props);
// This one is for loaded content.
assert.calledOnce(dispatch);
});
it("should send a loaded content and an impression when the page is visible and the wrapped item meets the visibility ratio", () => {
const dispatch = sinon.spy();
const props = {
dispatch,
IntersectionObserver: buildIntersectionObserver(FullIntersectEntries),
};
renderImpressionStats(props);
assert.calledTwice(dispatch);
let [action] = dispatch.firstCall.args;
assert.equal(action.type, at.DISCOVERY_STREAM_LOADED_CONTENT);
assert.equal(action.data.source, SOURCE);
assert.deepEqual(action.data.tiles, [
{ id: 1, pos: 0 },
{ id: 2, pos: 1 },
{ id: 3, pos: 2 },
]);
[action] = dispatch.secondCall.args;
assert.equal(action.type, at.DISCOVERY_STREAM_IMPRESSION_STATS);
assert.equal(action.data.source, SOURCE);
assert.deepEqual(action.data.tiles, [
{ id: 1, pos: 0 },
{ id: 2, pos: 1 },
{ id: 3, pos: 2 },
]);
});
it("should send a DISCOVERY_STREAM_SPOC_IMPRESSION when the wrapped item has a flightId", () => {
const dispatch = sinon.spy();
const flightId = "a_flight_id";
const props = {
dispatch,
flightId,
IntersectionObserver: buildIntersectionObserver(FullIntersectEntries),
};
renderImpressionStats(props);
// Loaded content + DISCOVERY_STREAM_SPOC_IMPRESSION + impression
assert.calledThrice(dispatch);
const [action] = dispatch.secondCall.args;
assert.equal(action.type, at.DISCOVERY_STREAM_SPOC_IMPRESSION);
assert.deepEqual(action.data, { flightId });
});
it("should send an impression when the wrapped item transiting from invisible to visible", () => {
const dispatch = sinon.spy();
const props = {
dispatch,
IntersectionObserver: buildIntersectionObserver(ZeroIntersectEntries),
};
const wrapper = renderImpressionStats(props);
// For the loaded content
assert.calledOnce(dispatch);
let [action] = dispatch.firstCall.args;
assert.equal(action.type, at.DISCOVERY_STREAM_LOADED_CONTENT);
assert.equal(action.data.source, SOURCE);
assert.deepEqual(action.data.tiles, [
{ id: 1, pos: 0 },
{ id: 2, pos: 1 },
{ id: 3, pos: 2 },
]);
dispatch.resetHistory();
wrapper.instance().impressionObserver.callback(FullIntersectEntries);
// For the impression
assert.calledOnce(dispatch);
[action] = dispatch.firstCall.args;
assert.equal(action.type, at.DISCOVERY_STREAM_IMPRESSION_STATS);
assert.deepEqual(action.data.tiles, [
{ id: 1, pos: 0 },
{ id: 2, pos: 1 },
{ id: 3, pos: 2 },
]);
});
it("should remove visibility change listener when the wrapper is removed", () => {
const props = {
dispatch: sinon.spy(),
document: {
visibilityState: "hidden",
addEventListener: sinon.spy(),
removeEventListener: sinon.spy(),
},
IntersectionObserver,
};
const wrapper = renderImpressionStats(props);
assert.calledWith(props.document.addEventListener, "visibilitychange");
const [, listener] = props.document.addEventListener.firstCall.args;
wrapper.unmount();
assert.calledWith(
props.document.removeEventListener,
"visibilitychange",
listener
);
});
it("should unobserve the intersection observer when the wrapper is removed", () => {
const IntersectionObserver = buildIntersectionObserver(
ZeroIntersectEntries
);
const spy = sinon.spy(IntersectionObserver.prototype, "unobserve");
const props = { dispatch: sinon.spy(), IntersectionObserver };
const wrapper = renderImpressionStats(props);
wrapper.unmount();
assert.calledOnce(spy);
});
it("should only send the latest impression on a visibility change", () => {
const listeners = new Set();
const props = {
dispatch: sinon.spy(),
document: {
visibilityState: "hidden",
addEventListener: (ev, cb) => listeners.add(cb),
removeEventListener: (ev, cb) => listeners.delete(cb),
},
};
const wrapper = renderImpressionStats(props);
// Update twice
wrapper.setProps({ ...props, ...{ rows: [{ id: 123, pos: 4 }] } });
wrapper.setProps({ ...props, ...{ rows: [{ id: 2432, pos: 5 }] } });
assert.notCalled(props.dispatch);
// Simulate listeners getting called
props.document.visibilityState = "visible";
listeners.forEach(l => l());
// Make sure we only sent the latest event
assert.calledTwice(props.dispatch);
const [action] = props.dispatch.firstCall.args;
assert.deepEqual(action.data.tiles, [{ id: 2432, pos: 5 }]);
});
});
================================================
FILE: test/unit/content-src/components/DiscoveryStreamComponents/List.test.jsx
================================================
import {
_List as List,
ListItem,
PlaceholderListItem,
} from "content-src/components/DiscoveryStreamComponents/List/List";
import { actionCreators as ac } from "common/Actions.jsm";
import {
DSContextFooter,
StatusMessage,
} from "content-src/components/DiscoveryStreamComponents/DSContextFooter/DSContextFooter";
import { DSEmptyState } from "content-src/components/DiscoveryStreamComponents/DSEmptyState/DSEmptyState";
import { DSLinkMenu } from "content-src/components/DiscoveryStreamComponents/DSLinkMenu/DSLinkMenu";
import { GlobalOverrider } from "test/unit/utils";
import React from "react";
import { shallow } from "enzyme";
describe(" presentation component", () => {
const ValidRecommendations = [
{ url: 1 },
{ url: 2 },
{ context: "test spoc", url: 3 },
];
const ValidListProps = {
data: {
recommendations: ValidRecommendations,
},
feed: {
url: "fakeFeedUrl",
},
header: {
title: "fakeFeedTitle",
},
};
it("should return null if feed.data is falsy", () => {
const ListProps = {
data: { feeds: { a: "stuff" } },
};
const wrapper = shallow(
);
assert.isNull(wrapper.getElement());
});
it("should return Empty State for no recommendations", () => {
const ListProps = {
data: { recommendations: [] },
header: { title: "headerTitle" },
};
const wrapper = shallow(
);
const dsEmptyState = wrapper.find(DSEmptyState);
const dsHeader = wrapper.find(".ds-header");
const dsList = wrapper.find(".ds-list.empty");
assert.ok(wrapper.exists());
assert.lengthOf(dsEmptyState, 1);
assert.lengthOf(dsHeader, 1);
assert.lengthOf(dsList, 1);
});
it("should return something containing a if props are valid", () => {
const wrapper = shallow(
);
const list = wrapper.find("ul");
assert.ok(wrapper.exists());
assert.lengthOf(list, 1);
});
it("should return the right number of ListItems if props are valid", () => {
const wrapper = shallow(
);
const listItem = wrapper.find(ListItem);
assert.lengthOf(listItem, ValidRecommendations.length);
});
it("should return fewer ListItems for fewer items", () => {
const wrapper = shallow(
);
const listItem = wrapper.find(ListItem);
assert.lengthOf(listItem, 1);
});
it("should return PlaceHolderListItem for recommendations less than items", () => {
const wrapper = shallow(
);
const listItem = wrapper.find(ListItem);
assert.lengthOf(listItem, 3);
const placeholderListItem = wrapper.find(PlaceholderListItem);
assert.lengthOf(placeholderListItem, 1);
});
it("should return fewer ListItems for starting point", () => {
const wrapper = shallow(
);
const listItem = wrapper.find(ListItem);
assert.lengthOf(listItem, ValidRecommendations.length - 1);
});
it("should return expected ListItems when offset", () => {
const wrapper = shallow(
);
const listItemUrls = wrapper.find(ListItem).map(i => i.prop("url"));
assert.sameOrderedMembers(listItemUrls, [
ValidRecommendations[1].url,
ValidRecommendations[2].url,
]);
});
it("should return expected spoc ListItem", () => {
const wrapper = shallow(
);
const listItemContext = wrapper.find(ListItem).map(i => i.prop("context"));
assert.sameOrderedMembers(listItemContext, [
undefined,
undefined,
ValidRecommendations[2].context,
]);
});
});
describe(" presentation component", () => {
const ValidListItemProps = {
url: "FAKE_URL",
title: "FAKE_TITLE",
domain: "example.com",
image_src: "FAKE_IMAGE_SRC",
context_type: "pocket",
};
const ValidSpocListItemProps = {
url: "FAKE_URL",
title: "FAKE_TITLE",
domain: "example.com",
image_src: "FAKE_IMAGE_SRC",
context_type: "pocket",
context: "FAKE_CONTEXT",
};
let globals;
beforeEach(() => {
globals = new GlobalOverrider();
});
afterEach(() => {
globals.sandbox.restore();
});
it("should contain 'a.ds-list-item-link' with the props.url set", () => {
const wrapper = shallow( );
const anchors = wrapper.find(
`SafeAnchor.ds-list-item-link[url="${ValidListItemProps.url}"]`
);
assert.lengthOf(anchors, 1);
});
it("should render badges for pocket, bookmark when not a spoc element ", () => {
const wrapper = shallow( );
const contextFooter = wrapper.find(DSContextFooter).shallow();
assert.lengthOf(contextFooter.find(StatusMessage), 1);
});
it("should render Sponsored Context for a spoc element", () => {
const wrapper = shallow( );
const contextFooter = wrapper.find(DSContextFooter).shallow();
assert.lengthOf(contextFooter.find(StatusMessage), 0);
assert.equal(
contextFooter.find(".story-sponsored-label").text(),
ValidSpocListItemProps.context
);
});
describe("onLinkClick", () => {
let dispatch;
let sandbox;
let wrapper;
beforeEach(() => {
sandbox = sinon.createSandbox();
dispatch = sandbox.stub();
wrapper = shallow(
);
});
afterEach(() => {
sandbox.restore();
});
it("should call dispatch with the correct events", () => {
wrapper.setProps({ id: "foo-id", pos: 1, type: "foo" });
wrapper.instance().onLinkClick();
assert.calledTwice(dispatch);
assert.calledWith(
dispatch,
ac.UserEvent({
event: "CLICK",
source: "FOO",
action_position: 1,
value: { card_type: "organic" },
})
);
assert.calledWith(
dispatch,
ac.ImpressionStats({
click: 0,
source: "FOO",
tiles: [{ id: "foo-id", pos: 1 }],
})
);
});
it("should set the right card_type on spocs", () => {
wrapper.setProps({ id: "foo-id", pos: 1, type: "foo", flightId: 12345 });
wrapper.instance().onLinkClick();
assert.calledTwice(dispatch);
assert.calledWith(
dispatch,
ac.UserEvent({
event: "CLICK",
source: "FOO",
action_position: 1,
value: { card_type: "spoc" },
})
);
assert.calledWith(
dispatch,
ac.ImpressionStats({
click: 0,
source: "FOO",
tiles: [{ id: "foo-id", pos: 1 }],
})
);
});
});
});
describe(" component", () => {
it("should have placeholder prop", () => {
const wrapper = shallow( );
const listItem = wrapper.find(ListItem);
assert.lengthOf(listItem, 1);
const placeholder = wrapper.find(ListItem).prop("placeholder");
assert.isTrue(placeholder);
});
it("should contain placeholder listitem", () => {
const wrapper = shallow( );
const listItem = wrapper.find("li.ds-list-item.placeholder");
assert.lengthOf(listItem, 1);
});
it("should not be clickable", () => {
const wrapper = shallow( );
const anchor = wrapper.find("SafeAnchor.ds-list-item-link");
assert.lengthOf(anchor, 1);
const linkClick = anchor.prop("onLinkClick");
assert.isUndefined(linkClick);
});
it("should not have context menu", () => {
const wrapper = shallow( );
const linkMenu = wrapper.find(DSLinkMenu);
assert.lengthOf(linkMenu, 0);
});
});
================================================
FILE: test/unit/content-src/components/DiscoveryStreamComponents/Navigation.test.jsx
================================================
import {
Navigation,
Topic,
} from "content-src/components/DiscoveryStreamComponents/Navigation/Navigation";
import React from "react";
import { SafeAnchor } from "content-src/components/DiscoveryStreamComponents/SafeAnchor/SafeAnchor";
import { FluentOrText } from "content-src/components/FluentOrText/FluentOrText";
import { shallow, mount } from "enzyme";
describe("", () => {
let wrapper;
beforeEach(() => {
wrapper = mount( );
});
it("should render", () => {
assert.ok(wrapper.exists());
});
it("should render a title", () => {
wrapper.setProps({ header: { title: "Foo" } });
assert.equal(wrapper.find(".ds-header").text(), "Foo");
});
it("should render a FluentOrText", () => {
wrapper.setProps({ header: { title: "Foo" } });
assert.equal(
wrapper
.find(".ds-navigation")
.children()
.at(0)
.type(),
FluentOrText
);
});
it("should render 2 Topics", () => {
wrapper.setProps({
links: [
{ url: "https://foo.com", name: "foo" },
{ url: "https://bar.com", name: "bar" },
],
});
assert.lengthOf(wrapper.find("ul").children(), 2);
});
});
describe("", () => {
let wrapper;
beforeEach(() => {
wrapper = shallow( );
});
it("should render", () => {
assert.ok(wrapper.exists());
assert.equal(wrapper.type(), "li");
assert.equal(
wrapper
.children()
.at(0)
.type(),
SafeAnchor
);
});
});
================================================
FILE: test/unit/content-src/components/DiscoveryStreamComponents/SafeAnchor.test.jsx
================================================
import React from "react";
import { SafeAnchor } from "content-src/components/DiscoveryStreamComponents/SafeAnchor/SafeAnchor";
import { shallow } from "enzyme";
describe("Discovery Stream ", () => {
let warnStub;
let sandbox;
beforeEach(() => {
warnStub = sinon.stub(console, "warn");
sandbox = sinon.createSandbox();
});
afterEach(() => {
warnStub.restore();
sandbox.restore();
});
it("should render with anchor", () => {
const wrapper = shallow( );
assert.lengthOf(wrapper.find("a"), 1);
});
it("should render with anchor target for http", () => {
const wrapper = shallow( );
assert.equal(wrapper.find("a").prop("href"), "http://example.com");
});
it("should render with anchor target for https", () => {
const wrapper = shallow( );
assert.equal(wrapper.find("a").prop("href"), "https://example.com");
});
it("should not allow javascript: URIs", () => {
const wrapper = shallow( ); // eslint-disable-line no-script-url
assert.equal(wrapper.find("a").prop("href"), "");
assert.calledOnce(warnStub);
});
it("should not warn if the URL is falsey ", () => {
const wrapper = shallow( );
assert.equal(wrapper.find("a").prop("href"), "");
assert.notCalled(warnStub);
});
it("should dispatch an event on click", () => {
const dispatchStub = sandbox.stub();
const fakeEvent = { preventDefault: sandbox.stub(), currentTarget: {} };
const wrapper = shallow( );
wrapper.find("a").simulate("click", fakeEvent);
assert.calledOnce(dispatchStub);
assert.calledOnce(fakeEvent.preventDefault);
});
it("should call onLinkClick if provided", () => {
const onLinkClickStub = sandbox.stub();
const wrapper = shallow( );
wrapper.find("a").simulate("click");
assert.calledOnce(onLinkClickStub);
});
});
================================================
FILE: test/unit/content-src/components/DiscoveryStreamComponents/SectionTitle.test.jsx
================================================
import React from "react";
import { SectionTitle } from "content-src/components/DiscoveryStreamComponents/SectionTitle/SectionTitle";
import { shallow } from "enzyme";
describe("", () => {
let wrapper;
beforeEach(() => {
wrapper = shallow( );
});
it("should render", () => {
assert.ok(wrapper.exists());
assert.ok(wrapper.find(".ds-section-title").exists());
});
it("should render a subtitle", () => {
wrapper.setProps({ header: { title: "Foo", subtitle: "Bar" } });
assert.equal(wrapper.find(".subtitle").text(), "Bar");
});
});
================================================
FILE: test/unit/content-src/components/DiscoveryStreamComponents/TopSites.test.jsx
================================================
import { combineReducers, createStore } from "redux";
import {
INITIAL_STATE,
reducers,
TOP_SITES_DEFAULT_ROWS,
} from "common/Reducers.jsm";
import { mount } from "enzyme";
import { TopSites as OldTopSites } from "content-src/components/TopSites/TopSites";
import { Provider } from "react-redux";
import React from "react";
import {
TopSites as TopSitesContainer,
_TopSites as TopSites,
} from "content-src/components/DiscoveryStreamComponents/TopSites/TopSites";
describe("Discovery Stream ", () => {
let wrapper;
let store;
const defaultTopSiteRows = [
{ label: "facebook" },
{ label: "amazon" },
{ label: "google" },
{ label: "apple" },
];
const defaultTopSites = {
rows: defaultTopSiteRows,
};
beforeEach(() => {
INITIAL_STATE.Prefs.values.topSitesRows = TOP_SITES_DEFAULT_ROWS;
store = createStore(combineReducers(reducers), INITIAL_STATE);
wrapper = mount(
);
});
afterEach(() => {
wrapper.unmount();
});
it("should return a wrapper around old TopSites", () => {
const oldTopSites = wrapper.find(OldTopSites);
const dsTopSitesWrapper = wrapper.find(".ds-top-sites");
assert.ok(wrapper.exists());
assert.lengthOf(oldTopSites, 1);
assert.lengthOf(dsTopSitesWrapper, 1);
});
describe("TopSites header", () => {
it("should have header title undefined by default", () => {
const oldTopSites = wrapper.find(OldTopSites);
assert.isUndefined(oldTopSites.props().title);
});
it("should set header title on old TopSites", () => {
let DEFAULT_PROPS = {
header: { title: "test" },
};
wrapper = mount(
);
const oldTopSites = wrapper.find(OldTopSites);
assert.equal(oldTopSites.props().title, "test");
});
});
describe("insertSpocContent", () => {
let insertSpocContent;
const topSiteSpoc = {
url: "foo",
sponsor: "bar",
image_src: "foobar",
flight_id: "1234",
id: "5678",
shim: { impression: "1011" },
};
const data = { spocs: [topSiteSpoc] };
const resultSpocLeft = {
customScreenshotURL: "foobar",
type: "SPOC",
label: "bar",
title: "bar",
url: "foo",
flightId: "1234",
id: "5678",
guid: "5678",
shim: {
impression: "1011",
},
pos: 0,
};
const resultSpocRight = {
customScreenshotURL: "foobar",
type: "SPOC",
label: "bar",
title: "bar",
url: "foo",
flightId: "1234",
id: "5678",
guid: "5678",
shim: {
impression: "1011",
},
pos: 7,
};
const pinnedSite = {
label: "pinnedSite",
isPinned: true,
};
beforeEach(() => {
const instance = wrapper.find(TopSites).instance();
insertSpocContent = instance.insertSpocContent.bind(instance);
});
it("Should return null if no data or no TopSites", () => {
assert.isNull(insertSpocContent(defaultTopSites, {}, "right"));
assert.isNull(insertSpocContent({}, data, "right"));
});
it("Should return null if an organic SPOC topsite exists", () => {
const topSitesWithOrganicSpoc = {
rows: [...defaultTopSiteRows, topSiteSpoc],
};
assert.isNull(insertSpocContent(topSitesWithOrganicSpoc, data, "right"));
});
it("Should return next spoc if the first SPOC is an existing organic top site", () => {
const topSitesWithOrganicSpoc = {
rows: [...defaultTopSiteRows, topSiteSpoc],
};
const extraSpocData = {
spocs: [
topSiteSpoc,
{
url: "foo2",
sponsor: "bar2",
image_src: "foobar2",
flight_id: "1234",
id: "5678",
shim: { impression: "1011" },
},
],
};
const result = insertSpocContent(
topSitesWithOrganicSpoc,
extraSpocData,
"right"
);
const availableSpoc = {
customScreenshotURL: "foobar2",
type: "SPOC",
label: "bar2",
title: "bar2",
url: "foo2",
flightId: "1234",
id: "5678",
guid: "5678",
shim: {
impression: "1011",
},
pos: 7,
};
const expectedResult = {
rows: [...topSitesWithOrganicSpoc.rows, availableSpoc],
};
assert.deepEqual(result, expectedResult);
});
it("should add to end of row if the row is not full and alignment is right", () => {
const result = insertSpocContent(defaultTopSites, data, "right");
const expectedResult = {
rows: [...defaultTopSiteRows, resultSpocRight],
};
assert.deepEqual(result, expectedResult);
});
it("should add to front of row if the row is not full and alignment is left", () => {
const result = insertSpocContent(defaultTopSites, data, "left");
assert.deepEqual(result, {
rows: [resultSpocLeft, ...defaultTopSiteRows],
});
});
it("should add to first available in the front row if alignment is left and there are pins", () => {
const topSiteRowsWithPins = [
pinnedSite,
pinnedSite,
...defaultTopSiteRows,
];
const result = insertSpocContent(
{ rows: topSiteRowsWithPins },
data,
"left"
);
assert.deepEqual(result, {
rows: [pinnedSite, pinnedSite, resultSpocLeft, ...defaultTopSiteRows],
});
});
it("should add to first available in the next row if alignment is right and there are all pins in the front row", () => {
const pinnedArray = new Array(8).fill(pinnedSite);
const result = insertSpocContent({ rows: pinnedArray }, data, "right");
assert.deepEqual(result, {
rows: [...pinnedArray, resultSpocRight],
});
});
it("should add to first available in the current row if alignment is right and there are some pins in the front row", () => {
const pinnedArray = new Array(6).fill(pinnedSite);
const topSite = { label: "foo" };
const rowsWithPins = [topSite, topSite, ...pinnedArray];
const result = insertSpocContent({ rows: rowsWithPins }, data, "right");
assert.deepEqual(result, {
rows: [topSite, resultSpocRight, ...pinnedArray, topSite],
});
});
it("should preserve the indices of pinned items", () => {
const topSite = { label: "foo" };
const rowsWithPins = [pinnedSite, topSite, topSite, pinnedSite];
const result = insertSpocContent({ rows: rowsWithPins }, data, "left");
// Pinned items should retain in Index 0 and Index 3 like defined in rowsWithPins
assert.deepEqual(result, {
rows: [pinnedSite, resultSpocLeft, topSite, pinnedSite, topSite],
});
});
});
});
================================================
FILE: test/unit/content-src/components/ErrorBoundary.test.jsx
================================================
import { A11yLinkButton } from "content-src/components/A11yLinkButton/A11yLinkButton";
import {
ErrorBoundary,
ErrorBoundaryFallback,
} from "content-src/components/ErrorBoundary/ErrorBoundary";
import React from "react";
import { shallow } from "enzyme";
describe("", () => {
it("should render its children if componentDidCatch wasn't called", () => {
const wrapper = shallow(
);
assert.lengthOf(wrapper.find(".kids"), 1);
});
it("should render ErrorBoundaryFallback if componentDidCatch called", () => {
const wrapper = shallow( );
wrapper.instance().componentDidCatch();
// since shallow wrappers don't automatically manage lifecycle semantics:
wrapper.update();
assert.lengthOf(wrapper.find(ErrorBoundaryFallback), 1);
});
it("should render the given FallbackComponent if componentDidCatch called", () => {
class TestFallback extends React.PureComponent {
render() {
return doh!
;
}
}
const wrapper = shallow( );
wrapper.instance().componentDidCatch();
// since shallow wrappers don't automatically manage lifecycle semantics:
wrapper.update();
assert.lengthOf(wrapper.find(TestFallback), 1);
});
it("should pass the given className prop to the FallbackComponent", () => {
class TestFallback extends React.PureComponent {
render() {
return doh!
;
}
}
const wrapper = shallow(
);
wrapper.instance().componentDidCatch();
// since shallow wrappers don't automatically manage lifecycle semantics:
wrapper.update();
assert.lengthOf(wrapper.find(".sheep"), 1);
});
});
describe("ErrorBoundaryFallback", () => {
it("should render a with a class of as-error-fallback", () => {
const wrapper = shallow(
);
assert.lengthOf(wrapper.find("div.as-error-fallback"), 1);
});
it("should render a
with the props.className and .as-error-fallback", () => {
const wrapper = shallow(
);
assert.lengthOf(wrapper.find("div.monkeys.as-error-fallback"), 1);
});
it("should call window.location.reload(true) if .reload-button clicked", () => {
const fakeWindow = { location: { reload: sinon.spy() } };
const wrapper = shallow(
);
wrapper.find(".reload-button").simulate("click");
assert.calledOnce(fakeWindow.location.reload);
assert.calledWithExactly(fakeWindow.location.reload, true);
});
it("should render .reload-button as an
", () => {
const wrapper = shallow( );
assert.lengthOf(wrapper.find("A11yLinkButton.reload-button"), 1);
});
it("should render newtab-error-fallback-refresh-link node", () => {
const wrapper = shallow( );
const msgWrapper = wrapper.find(
'[data-l10n-id="newtab-error-fallback-refresh-link"]'
);
assert.lengthOf(msgWrapper, 1);
assert.isTrue(msgWrapper.is(A11yLinkButton));
});
it("should render newtab-error-fallback-info node", () => {
const wrapper = shallow( );
const msgWrapper = wrapper.find(
'[data-l10n-id="newtab-error-fallback-info"]'
);
assert.lengthOf(msgWrapper, 1);
});
});
================================================
FILE: test/unit/content-src/components/FluentOrText.test.jsx
================================================
import { FluentOrText } from "content-src/components/FluentOrText/FluentOrText";
import React from "react";
import { shallow } from "enzyme";
describe("", () => {
it("should create span with no children", () => {
const wrapper = shallow( );
assert.ok(wrapper.find("span"));
});
it("should set plain text", () => {
const wrapper = shallow( );
assert.equal(wrapper.text(), "hello");
});
it("should use fluent id on automatic span", () => {
const wrapper = shallow( );
assert.ok(wrapper.find("span[data-l10n-id='fluent']"));
});
it("should also allow string_id", () => {
const wrapper = shallow( );
assert.ok(wrapper.find("span[data-l10n-id='fluent']"));
});
it("should use fluent id on child", () => {
const wrapper = shallow(
);
assert.ok(wrapper.find("p[data-l10n-id='fluent']"));
});
it("should set args for fluent", () => {
const wrapper = shallow( );
assert.ok(wrapper.find("span[data-l10n-args='{num: 5}']"));
});
it("should also allow values", () => {
const wrapper = shallow( );
assert.ok(wrapper.find("span[data-l10n-args='{num: 5}']"));
});
it("should preserve original children with fluent", () => {
const wrapper = shallow(
);
assert.ok(wrapper.find("b[data-l10n-name='bold']"));
});
it("should only allow a single child", () => {
assert.throws(() =>
shallow(
)
);
});
});
================================================
FILE: test/unit/content-src/components/LinkMenu.test.jsx
================================================
import { ContextMenu } from "content-src/components/ContextMenu/ContextMenu";
import { _LinkMenu as LinkMenu } from "content-src/components/LinkMenu/LinkMenu";
import React from "react";
import { shallow } from "enzyme";
describe("", () => {
let wrapper;
beforeEach(() => {
wrapper = shallow(
{}}
/>
);
});
it("should render a ContextMenu element", () => {
assert.ok(wrapper.find(ContextMenu).exists());
});
it("should pass onUpdate, and options to ContextMenu", () => {
assert.ok(wrapper.find(ContextMenu).exists());
const contextMenuProps = wrapper.find(ContextMenu).props();
["onUpdate", "options"].forEach(prop =>
assert.property(contextMenuProps, prop)
);
});
it("should give ContextMenu the correct tabbable options length for a11y", () => {
const { options } = wrapper.find(ContextMenu).props();
const [firstItem] = options;
const lastItem = options[options.length - 1];
// first item should have {first: true}
assert.isTrue(firstItem.first);
assert.ok(!firstItem.last);
// last item should have {last: true}
assert.isTrue(lastItem.last);
assert.ok(!lastItem.first);
// middle items should have neither
for (let i = 1; i < options.length - 1; i++) {
assert.ok(!options[i].first && !options[i].last);
}
});
it("should show the correct options for default sites", () => {
wrapper = shallow(
{}}
/>
);
const { options } = wrapper.find(ContextMenu).props();
let i = 0;
assert.propertyVal(options[i++], "id", "newtab-menu-pin");
assert.propertyVal(options[i++], "id", "newtab-menu-edit-topsites");
assert.propertyVal(options[i++], "type", "separator");
assert.propertyVal(options[i++], "id", "newtab-menu-open-new-window");
assert.propertyVal(
options[i++],
"id",
"newtab-menu-open-new-private-window"
);
assert.propertyVal(options[i++], "type", "separator");
assert.propertyVal(options[i++], "id", "newtab-menu-dismiss");
assert.propertyVal(options, "length", i);
// Double check that delete options are not included for default top sites
options
.filter(o => o.type !== "separator")
.forEach(o => {
assert.notInclude(["newtab-menu-delete-history"], o.id);
});
});
it("should show Unpin option for a pinned site if CheckPinTopSite in options list", () => {
wrapper = shallow(
{}}
/>
);
const { options } = wrapper.find(ContextMenu).props();
assert.isDefined(options.find(o => o.id && o.id === "newtab-menu-unpin"));
});
it("should show Pin option for an unpinned site if CheckPinTopSite in options list", () => {
wrapper = shallow(
{}}
/>
);
const { options } = wrapper.find(ContextMenu).props();
assert.isDefined(options.find(o => o.id && o.id === "newtab-menu-pin"));
});
it("should show Unbookmark option for a bookmarked site if CheckBookmark in options list", () => {
wrapper = shallow(
{}}
/>
);
const { options } = wrapper.find(ContextMenu).props();
assert.isDefined(
options.find(o => o.id && o.id === "newtab-menu-remove-bookmark")
);
});
it("should show Bookmark option for an unbookmarked site if CheckBookmark in options list", () => {
wrapper = shallow(
{}}
/>
);
const { options } = wrapper.find(ContextMenu).props();
assert.isDefined(
options.find(o => o.id && o.id === "newtab-menu-bookmark")
);
});
it("should show Save to Pocket option for an unsaved Pocket item if CheckSavedToPocket in options list", () => {
wrapper = shallow(
{}}
/>
);
const { options } = wrapper.find(ContextMenu).props();
assert.isDefined(
options.find(o => o.id && o.id === "newtab-menu-save-to-pocket")
);
});
it("should show Delete from Pocket option for a saved Pocket item if CheckSavedToPocket in options list", () => {
wrapper = shallow(
{}}
/>
);
const { options } = wrapper.find(ContextMenu).props();
assert.isDefined(
options.find(o => o.id && o.id === "newtab-menu-delete-pocket")
);
});
it("should show Archive from Pocket option for a saved Pocket item if CheckBookmarkOrArchive", () => {
wrapper = shallow(
{}}
/>
);
const { options } = wrapper.find(ContextMenu).props();
assert.isDefined(
options.find(o => o.id && o.id === "newtab-menu-archive-pocket")
);
});
it("should show Bookmark option for an unbookmarked site if CheckBookmarkOrArchive in options list and no pocket_id", () => {
wrapper = shallow(
{}}
/>
);
const { options } = wrapper.find(ContextMenu).props();
assert.isDefined(
options.find(o => o.id && o.id === "newtab-menu-bookmark")
);
});
it("should show Unbookmark option for a bookmarked site if CheckBookmarkOrArchive in options list and no pocket_id", () => {
wrapper = shallow(
{}}
/>
);
const { options } = wrapper.find(ContextMenu).props();
assert.isDefined(
options.find(o => o.id && o.id === "newtab-menu-remove-bookmark")
);
});
it("should show Open File option for a downloaded item", () => {
wrapper = shallow(
{}}
/>
);
const { options } = wrapper.find(ContextMenu).props();
assert.isDefined(
options.find(o => o.id && o.id === "newtab-menu-open-file")
);
});
it("should show Show File option for a downloaded item on a default platform", () => {
wrapper = shallow(
{}}
/>
);
const { options } = wrapper.find(ContextMenu).props();
assert.isDefined(
options.find(o => o.id && o.id === "newtab-menu-show-file")
);
});
it("should show Copy Downlad Link option for a downloaded item when CopyDownloadLink", () => {
wrapper = shallow(
{}}
/>
);
const { options } = wrapper.find(ContextMenu).props();
assert.isDefined(
options.find(o => o.id && o.id === "newtab-menu-copy-download-link")
);
});
it("should show Go To Download Page option for a downloaded item when GoToDownloadPage", () => {
wrapper = shallow(
{}}
/>
);
const { options } = wrapper.find(ContextMenu).props();
assert.isDefined(
options.find(o => o.id && o.id === "newtab-menu-go-to-download-page")
);
assert.isFalse(options[0].disabled);
});
it("should show Go To Download Page option as disabled for a downloaded item when GoToDownloadPage if no referrer exists", () => {
wrapper = shallow(
{}}
/>
);
const { options } = wrapper.find(ContextMenu).props();
assert.isDefined(
options.find(o => o.id && o.id === "newtab-menu-go-to-download-page")
);
assert.isTrue(options[0].disabled);
});
it("should show Remove Download Link option for a downloaded item when RemoveDownload", () => {
wrapper = shallow(
{}}
/>
);
const { options } = wrapper.find(ContextMenu).props();
assert.isDefined(
options.find(o => o.id && o.id === "newtab-menu-remove-download")
);
});
it("should show Edit option", () => {
const props = { url: "foo", label: "label" };
const index = 5;
wrapper = shallow(
{}}
/>
);
const { options } = wrapper.find(ContextMenu).props();
const option = options.find(
o => o.id && o.id === "newtab-menu-edit-topsites"
);
assert.isDefined(option);
assert.equal(option.action.data.index, index);
});
describe(".onClick", () => {
const FAKE_INDEX = 3;
const FAKE_SOURCE = "TOP_SITES";
const FAKE_SITE = {
bookmarkGuid: 1234,
hostname: "foo",
path: "foo",
pocket_id: "1234",
referrer: "https://foo.com/ref",
title: "bar",
type: "bookmark",
typedBonus: true,
url: "https://foo.com",
};
const dispatch = sinon.stub();
const propOptions = [
"ShowFile",
"CopyDownloadLink",
"GoToDownloadPage",
"RemoveDownload",
"Separator",
"ShowPrivacyInfo",
"RemoveBookmark",
"AddBookmark",
"OpenInNewWindow",
"OpenInPrivateWindow",
"BlockUrl",
"DeleteUrl",
"PinTopSite",
"UnpinTopSite",
"SaveToPocket",
"DeleteFromPocket",
"ArchiveFromPocket",
"WebExtDismiss",
];
const expectedActionData = {
"newtab-menu-remove-bookmark": FAKE_SITE.bookmarkGuid,
"newtab-menu-bookmark": {
url: FAKE_SITE.url,
title: FAKE_SITE.title,
type: FAKE_SITE.type,
},
"newtab-menu-open-new-window": {
url: FAKE_SITE.url,
referrer: FAKE_SITE.referrer,
typedBonus: FAKE_SITE.typedBonus,
},
"newtab-menu-open-new-private-window": {
url: FAKE_SITE.url,
referrer: FAKE_SITE.referrer,
},
"newtab-menu-dismiss": {
url: FAKE_SITE.url,
pocket_id: FAKE_SITE.pocket_id,
},
menu_action_webext_dismiss: {
source: "TOP_SITES",
url: FAKE_SITE.url,
action_position: 3,
},
"newtab-menu-delete-history": {
url: FAKE_SITE.url,
pocket_id: FAKE_SITE.pocket_id,
forceBlock: FAKE_SITE.bookmarkGuid,
},
"newtab-menu-pin": { site: { url: FAKE_SITE.url }, index: FAKE_INDEX },
"newtab-menu-unpin": { site: { url: FAKE_SITE.url } },
"newtab-menu-save-to-pocket": {
site: { url: FAKE_SITE.url, title: FAKE_SITE.title },
},
"newtab-menu-delete-pocket": { pocket_id: "1234" },
"newtab-menu-archive-pocket": { pocket_id: "1234" },
"newtab-menu-show-file": { url: FAKE_SITE.url },
"newtab-menu-copy-download-link": { url: FAKE_SITE.url },
"newtab-menu-go-to-download-page": { url: FAKE_SITE.referrer },
"newtab-menu-remove-download": { url: FAKE_SITE.url },
};
const { options } = shallow(
)
.find(ContextMenu)
.props();
afterEach(() => dispatch.reset());
options
.filter(o => o.type !== "separator")
.forEach(option => {
it(`should fire a ${option.action.type} action for ${
option.id
} with the expected data`, () => {
option.onClick();
if (option.impression && option.userEvent) {
assert.calledThrice(dispatch);
} else if (option.impression || option.userEvent) {
assert.calledTwice(dispatch);
} else {
assert.calledOnce(dispatch);
}
// option.action is dispatched
assert.ok(dispatch.firstCall.calledWith(option.action));
// option.action has correct data
// (delete is a special case as it dispatches a nested DIALOG_OPEN-type action)
// in the case of this FAKE_SITE, we send a bookmarkGuid therefore we also want
// to block this if we delete it
if (option.id === "newtab-menu-delete-history") {
assert.deepEqual(
option.action.data.onConfirm[0].data,
expectedActionData[option.id]
);
// Test UserEvent send correct meta about item deleted
assert.propertyVal(
option.action.data.onConfirm[1].data,
"action_position",
FAKE_INDEX
);
assert.propertyVal(
option.action.data.onConfirm[1].data,
"source",
FAKE_SOURCE
);
} else {
assert.deepEqual(option.action.data, expectedActionData[option.id]);
}
});
it(`should fire a UserEvent action for ${
option.id
} if configured`, () => {
if (option.userEvent) {
option.onClick();
const [action] = dispatch.secondCall.args;
assert.isUserEventAction(action);
assert.propertyVal(action.data, "source", FAKE_SOURCE);
assert.propertyVal(action.data, "action_position", FAKE_INDEX);
assert.propertyVal(action.data.value, "card_type", FAKE_SITE.type);
}
});
it(`should send impression stats for ${option.id}`, () => {
if (option.impression) {
option.onClick();
const [action] = dispatch.thirdCall.args;
assert.deepEqual(action, option.impression);
}
});
});
it(`should not send impression stats if not configured`, () => {
const fakeOptions = shallow(
)
.find(ContextMenu)
.props().options;
fakeOptions
.filter(o => o.type !== "separator")
.forEach(option => {
if (option.impression) {
option.onClick();
assert.calledTwice(dispatch);
assert.notEqual(dispatch.firstCall.args[0], option.impression);
assert.notEqual(dispatch.secondCall.args[0], option.impression);
dispatch.reset();
}
});
});
it(`should pin a SPOC with all of the site details sent`, () => {
const pinSpocTopSite = "PinSpocTopSite";
const { options: spocOptions } = shallow(
)
.find(ContextMenu)
.props();
const [pinSpocOption] = spocOptions;
pinSpocOption.onClick();
if (pinSpocOption.impression && pinSpocOption.userEvent) {
assert.calledThrice(dispatch);
} else if (pinSpocOption.impression || pinSpocOption.userEvent) {
assert.calledTwice(dispatch);
} else {
assert.calledOnce(dispatch);
}
// option.action is dispatched
assert.ok(dispatch.firstCall.calledWith(pinSpocOption.action));
assert.deepEqual(pinSpocOption.action.data, {
site: FAKE_SITE,
index: FAKE_INDEX,
});
});
});
});
================================================
FILE: test/unit/content-src/components/MoreRecommendations.test.jsx
================================================
import { MoreRecommendations } from "content-src/components/MoreRecommendations/MoreRecommendations";
import React from "react";
import { shallow } from "enzyme";
describe("", () => {
it("should render a MoreRecommendations element", () => {
const wrapper = shallow( );
assert.ok(wrapper.exists());
});
it("should render a link when provided with read_more_endpoint prop", () => {
const wrapper = shallow(
);
const link = wrapper.find(".more-recommendations");
assert.lengthOf(link, 1);
});
it("should not render a link when provided with read_more_endpoint prop", () => {
const wrapper = shallow( );
const link = wrapper.find(".more-recommendations");
assert.lengthOf(link, 0);
});
});
================================================
FILE: test/unit/content-src/components/PocketLoggedInCta.test.jsx
================================================
import { combineReducers, createStore } from "redux";
import { INITIAL_STATE, reducers } from "common/Reducers.jsm";
import { mount, shallow } from "enzyme";
import {
PocketLoggedInCta,
_PocketLoggedInCta as PocketLoggedInCtaRaw,
} from "content-src/components/PocketLoggedInCta/PocketLoggedInCta";
import { Provider } from "react-redux";
import React from "react";
function mountSectionWithProps(props) {
const store = createStore(combineReducers(reducers), INITIAL_STATE);
return mount(
);
}
describe("", () => {
it("should render a PocketLoggedInCta element", () => {
const wrapper = mountSectionWithProps({});
assert.ok(wrapper.exists());
});
it("should render Fluent spans when rendered without props", () => {
const wrapper = mountSectionWithProps({});
const message = wrapper.find("span[data-l10n-id]");
assert.lengthOf(message, 2);
});
it("should not render Fluent spans when rendered with props", () => {
const wrapper = shallow(
);
const message = wrapper.find("span[data-l10n-id]");
assert.lengthOf(message, 0);
});
});
================================================
FILE: test/unit/content-src/components/ReturnToAMO.test.jsx
================================================
import { mount } from "enzyme";
import React from "react";
import { ReturnToAMO } from "content-src/asrouter/templates/ReturnToAMO/ReturnToAMO";
describe("", () => {
let dispatch;
let onReady;
let sandbox;
let wrapper;
let dummyNode;
let fakeDocument;
let sendUserActionTelemetryStub;
let content;
beforeEach(() => {
sandbox = sinon.createSandbox();
dispatch = sandbox.stub();
onReady = sandbox.stub();
sendUserActionTelemetryStub = sandbox.stub();
content = {
primary_button: {},
secondary_button: {},
};
dummyNode = document.createElement("body");
sandbox.stub(dummyNode, "querySelector").returns(dummyNode);
fakeDocument = {
get activeElement() {
return dummyNode;
},
get body() {
return dummyNode;
},
getElementById() {
return dummyNode;
},
};
});
afterEach(() => {
sandbox.restore();
});
describe("not mounted", () => {
it("should send an IMPRESSION on mount", () => {
assert.notCalled(sendUserActionTelemetryStub);
wrapper = mount(
);
assert.calledOnce(sendUserActionTelemetryStub);
assert.calledWithExactly(sendUserActionTelemetryStub, {
event: "IMPRESSION",
id: wrapper.instance().props.UISurface,
});
});
});
describe("mounted", () => {
beforeEach(() => {
wrapper = mount(
);
// Clear the IMPRESSION ping
sendUserActionTelemetryStub.reset();
});
it("should send telemetry on block", () => {
wrapper.instance().onBlockButton();
assert.calledOnce(sendUserActionTelemetryStub);
assert.calledWithExactly(sendUserActionTelemetryStub, {
event: "BLOCK",
id: wrapper.instance().props.UISurface,
});
});
it("should send telemetry on install", () => {
wrapper.instance().onClickAddExtension();
assert.calledWithExactly(sendUserActionTelemetryStub, {
event: "INSTALL",
id: wrapper.instance().props.UISurface,
});
});
});
});
================================================
FILE: test/unit/content-src/components/Search.test.jsx
================================================
import { GlobalOverrider } from "test/unit/utils";
import { mount, shallow } from "enzyme";
import React from "react";
import { _Search as Search } from "content-src/components/Search/Search";
const DEFAULT_PROPS = { dispatch() {} };
describe("", () => {
let globals;
let sandbox;
beforeEach(() => {
globals = new GlobalOverrider();
sandbox = globals.sandbox;
global.ContentSearchUIController.prototype = { search: sandbox.spy() };
});
afterEach(() => {
globals.restore();
});
it("should render a Search element", () => {
const wrapper = shallow( );
assert.ok(wrapper.exists());
});
it("should not use a