123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474 |
- /* 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: "header_recommended_by", values: {provider: options.provider_name}},
- descString: {id: "prefs_topstories_description2"},
- nestedPrefs: options.show_spocs ? [{
- name: "showSponsored",
- titleString: "prefs_topstories_options_sponsored_label",
- icon: "icon-info",
- }] : [],
- },
- shouldHidePref: options.hidden,
- eventSource: "TOP_STORIES",
- icon: options.provider_icon,
- title: {id: "header_recommended_by", values: {provider: options.provider_name}},
- learnMore: {
- link: {
- href: "https://getpocket.com/firefox/new_tab_learn_more",
- id: "pocket_how_it_works",
- },
- },
- 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: "topstories_empty_state", values: {provider: options.provider_name}},
- icon: "check",
- },
- shouldSendImpressionStats: true,
- dedupeFrom: ["highlights"],
- }),
- "feeds.section.highlights": options => ({
- id: "highlights",
- pref: {
- titleString: {id: "settings_pane_highlights_header"},
- descString: {id: "prefs_highlights_description"},
- nestedPrefs: [{
- name: "section.highlights.includeVisited",
- titleString: "prefs_highlights_options_visited_label",
- }, {
- name: "section.highlights.includeBookmarks",
- titleString: "settings_pane_highlights_options_bookmarks",
- }, {
- name: "section.highlights.includeDownloads",
- titleString: "prefs_highlights_options_download_label",
- }, {
- name: "section.highlights.includePocket",
- titleString: "prefs_highlights_options_pocket_label",
- }],
- },
- shouldHidePref: false,
- eventSource: "HIGHLIGHTS",
- icon: "highlights",
- title: {id: "header_highlights"},
- compactCards: true,
- rowsPref: "section.highlights.rows",
- maxRows: 4,
- emptyState: {
- message: {id: "highlights_empty_state"},
- 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"];
|