summaryrefslogtreecommitdiffstats
path: root/browser/components/newtab/lib/SectionsManager.sys.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'browser/components/newtab/lib/SectionsManager.sys.mjs')
-rw-r--r--browser/components/newtab/lib/SectionsManager.sys.mjs715
1 files changed, 715 insertions, 0 deletions
diff --git a/browser/components/newtab/lib/SectionsManager.sys.mjs b/browser/components/newtab/lib/SectionsManager.sys.mjs
new file mode 100644
index 0000000000..96bba0c9ea
--- /dev/null
+++ b/browser/components/newtab/lib/SectionsManager.sys.mjs
@@ -0,0 +1,715 @@
+/* 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/. */
+
+// We use importESModule here instead of static import so that
+// the Karma test environment won't choke on this module. This
+// is because the Karma test environment already stubs out
+// EventEmitter, and overrides importESModule to be a no-op (which
+// can't be done for a static import statement).
+
+// eslint-disable-next-line mozilla/use-static-import
+const { EventEmitter } = ChromeUtils.importESModule(
+ "resource://gre/modules/EventEmitter.sys.mjs"
+);
+import {
+ actionCreators as ac,
+ actionTypes as at,
+} from "resource://activity-stream/common/Actions.sys.mjs";
+import { getDefaultOptions } from "resource://activity-stream/lib/ActivityStreamStorage.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs",
+ PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
+});
+
+/*
+ * 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 = ({ newtab, pocketNewtab }) => ({
+ "feeds.section.topstories": options => ({
+ id: "topstories",
+ pref: {
+ titleString: {
+ id: "home-prefs-recommended-by-header-generic",
+ },
+ descString: {
+ id: "home-prefs-recommended-by-description-generic",
+ },
+ nestedPrefs: [
+ ...(Services.prefs.getBoolPref(
+ "browser.newtabpage.activity-stream.system.showSponsored",
+ true
+ )
+ ? [
+ {
+ name: "showSponsored",
+ titleString:
+ "home-prefs-recommended-by-option-sponsored-stories",
+ icon: "icon-info",
+ eventSource: "POCKET_SPOCS",
+ },
+ ]
+ : []),
+ ...(pocketNewtab.recentSavesEnabled
+ ? [
+ {
+ name: "showRecentSaves",
+ titleString: "home-prefs-recommended-by-option-recent-saves",
+ icon: "icon-info",
+ eventSource: "POCKET_RECENT_SAVES",
+ },
+ ]
+ : []),
+ ],
+ 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-stories",
+ },
+ learnMore: {
+ link: {
+ href: "https://getpocket.com/firefox/new_tab_learn_more",
+ message: { id: "newtab-pocket-learn-more" },
+ },
+ },
+ compactCards: false,
+ rowsPref: "section.topstories.rows",
+ maxRows: 4,
+ availableLinkMenuOptions: [
+ "CheckBookmarkOrArchive",
+ "CheckSavedToPocket",
+ "Separator",
+ "OpenInNewWindow",
+ "OpenInPrivateWindow",
+ "Separator",
+ "BlockUrl",
+ ],
+ emptyState: {
+ message: {
+ id: "newtab-empty-section-topstories-generic",
+ },
+ icon: "check",
+ },
+ shouldSendImpressionStats: true,
+ dedupeFrom: ["highlights"],
+ }),
+ "feeds.section.highlights": options => ({
+ id: "highlights",
+ pref: {
+ titleString: {
+ id: "home-prefs-recent-activity-header",
+ },
+ descString: {
+ id: "home-prefs-recent-activity-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",
+ hidden: !Services.prefs.getBoolPref(
+ "extensions.pocket.enabled",
+ true
+ ),
+ },
+ ],
+ },
+ shouldHidePref: false,
+ eventSource: "HIGHLIGHTS",
+ icon: "chrome://global/skin/icons/highlights.svg",
+ title: {
+ id: "newtab-section-header-recent-activity",
+ },
+ compactCards: true,
+ rowsPref: "section.highlights.rows",
+ maxRows: 4,
+ emptyState: {
+ message: { id: "newtab-empty-section-highlights" },
+ icon: "chrome://global/skin/icons/highlights.svg",
+ },
+ shouldSendImpressionStats: false,
+ }),
+});
+
+export 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;
+ const featureConfig = {
+ newtab: lazy.NimbusFeatures.newtab.getAllVariables() || {},
+ pocketNewtab: lazy.NimbusFeatures.pocketNewtab.getAllVariables() || {},
+ };
+
+ for (const feedPrefName of Object.keys(BUILT_IN_SECTIONS(featureConfig))) {
+ 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;
+ const featureConfig = {
+ newtab: lazy.NimbusFeatures.newtab.getAllVariables() || {},
+ pocketNewtab: lazy.NimbusFeatures.pocketNewtab.getAllVariables() || {},
+ };
+ try {
+ options = JSON.parse(optionsPrefValue);
+ } catch (e) {
+ options = {};
+ console.error(`Problem parsing options pref for ${feedPrefName}`);
+ }
+ try {
+ storedPrefs = (await this._storage.get(feedPrefName)) || {};
+ } catch (e) {
+ storedPrefs = {};
+ console.error(`Problem getting stored prefs for ${feedPrefName}`);
+ }
+ const defaultSection =
+ BUILT_IN_SECTIONS(featureConfig)[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, isStartup = false) {
+ this.updateSection(id, { enabled: true }, true, isStartup);
+ 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, isStartup = false) {
+ 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,
+ isStartup
+ );
+ }
+ },
+
+ /**
+ * 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
+ ) {
+ lazy.PlacesUtils.history.update({
+ url: card.url,
+ title: card.title,
+ description: card.description,
+ previewImageURL: card.image,
+ });
+ // Highlights query skips bookmarks with no visits.
+ lazy.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]) {
+ console.error(
+ `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
+ * @param isStartup If this update is during startup.
+ */
+ updateSectionCard(id, url, options, shouldBroadcast, isStartup = false) {
+ 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,
+ isStartup
+ );
+ }
+ },
+ 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);
+
+export 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,
+ true /* isStartup */
+ )
+ );
+ }
+
+ 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, isStartup = false) {
+ if (options) {
+ this.store.dispatch(
+ ac.BroadcastToContent({
+ type: at.SECTION_REGISTER,
+ data: Object.assign({ id }, options),
+ meta: {
+ isStartup,
+ },
+ })
+ );
+
+ // 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,
+ isStartup = false
+ ) {
+ if (options) {
+ const action = {
+ type: at.SECTION_UPDATE,
+ data: Object.assign(options, { id }),
+ meta: {
+ isStartup,
+ },
+ };
+ this.store.dispatch(
+ shouldBroadcast
+ ? ac.BroadcastToContent(action)
+ : ac.AlsoToPreloaded(action)
+ );
+ }
+ }
+
+ onUpdateSectionCard(
+ event,
+ id,
+ url,
+ options,
+ shouldBroadcast = false,
+ isStartup = false
+ ) {
+ if (options) {
+ const action = {
+ type: at.SECTION_UPDATE_CARD,
+ data: { id, url, options },
+ meta: {
+ isStartup,
+ },
+ };
+ 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
+ );
+ }
+ }
+}