diff options
Diffstat (limited to '')
-rw-r--r-- | browser/components/newtab/lib/SectionsManager.sys.mjs | 715 |
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 + ); + } + } +} |