tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

SectionsManager.sys.mjs (17557B)


      1 /* This Source Code Form is subject to the terms of the Mozilla Public
      2 * License, v. 2.0. If a copy of the MPL was not distributed with this
      3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
      4 
      5 // We use importESModule here instead of static import so that
      6 // the Karma test environment won't choke on this module. This
      7 // is because the Karma test environment already stubs out
      8 // EventEmitter, and overrides importESModule to be a no-op (which
      9 // can't be done for a static import statement).
     10 
     11 // eslint-disable-next-line mozilla/use-static-import
     12 const { EventEmitter } = ChromeUtils.importESModule(
     13  "resource://gre/modules/EventEmitter.sys.mjs"
     14 );
     15 import {
     16  actionCreators as ac,
     17  actionTypes as at,
     18 } from "resource://newtab/common/Actions.mjs";
     19 
     20 const lazy = {};
     21 
     22 ChromeUtils.defineESModuleGetters(lazy, {
     23  NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs",
     24  PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
     25 });
     26 
     27 /*
     28 * Generators for built in sections, keyed by the pref name for their feed.
     29 * Built in sections may depend on options stored as serialised JSON in the pref
     30 * `${feed_pref_name}.options`.
     31 */
     32 
     33 const BUILT_IN_SECTIONS = () => ({
     34  "feeds.section.topstories": options => ({
     35    id: "topstories",
     36    pref: {
     37      titleString: {
     38        id: "home-prefs-recommended-by-header-generic",
     39      },
     40      descString: {
     41        id: "home-prefs-recommended-by-description-generic",
     42      },
     43      nestedPrefs: [
     44        ...(Services.prefs.getBoolPref(
     45          "browser.newtabpage.activity-stream.system.showSponsored",
     46          true
     47        )
     48          ? [
     49              {
     50                name: "showSponsored",
     51                titleString:
     52                  "home-prefs-recommended-by-option-sponsored-stories",
     53                icon: "icon-info",
     54                eventSource: "POCKET_SPOCS",
     55              },
     56            ]
     57          : []),
     58      ],
     59      learnMore: {
     60        link: {
     61          href: "https://getpocket.com/firefox/new_tab_learn_more",
     62          id: "home-prefs-recommended-by-learn-more",
     63        },
     64      },
     65    },
     66    shouldHidePref: options.hidden,
     67    eventSource: "TOP_STORIES",
     68    icon: options.provider_icon,
     69    title: {
     70      id: "newtab-section-header-stories",
     71    },
     72    learnMore: {
     73      link: {
     74        href: "https://getpocket.com/firefox/new_tab_learn_more",
     75        message: { id: "newtab-pocket-learn-more" },
     76      },
     77    },
     78    compactCards: false,
     79    rowsPref: "section.topstories.rows",
     80    maxRows: 4,
     81    availableLinkMenuOptions: [
     82      "CheckBookmark",
     83      "Separator",
     84      "OpenInNewWindow",
     85      "OpenInPrivateWindow",
     86      "Separator",
     87      "BlockUrl",
     88    ],
     89    emptyState: {
     90      message: {
     91        id: "newtab-empty-section-topstories-generic",
     92      },
     93      icon: "check",
     94    },
     95    shouldSendImpressionStats: true,
     96    dedupeFrom: ["highlights"],
     97  }),
     98  "feeds.section.highlights": () => ({
     99    id: "highlights",
    100    pref: {
    101      titleString: {
    102        id: "home-prefs-recent-activity-header",
    103      },
    104      descString: {
    105        id: "home-prefs-recent-activity-description",
    106      },
    107      nestedPrefs: [
    108        {
    109          name: "section.highlights.includeVisited",
    110          titleString: "home-prefs-highlights-option-visited-pages",
    111        },
    112        {
    113          name: "section.highlights.includeBookmarks",
    114          titleString: "home-prefs-highlights-options-bookmarks",
    115        },
    116        {
    117          name: "section.highlights.includeDownloads",
    118          titleString: "home-prefs-highlights-option-most-recent-download",
    119        },
    120      ],
    121    },
    122    shouldHidePref: false,
    123    eventSource: "HIGHLIGHTS",
    124    icon: "chrome://global/skin/icons/highlights.svg",
    125    title: {
    126      id: "newtab-section-header-recent-activity",
    127    },
    128    compactCards: true,
    129    rowsPref: "section.highlights.rows",
    130    maxRows: 4,
    131    emptyState: {
    132      message: { id: "newtab-empty-section-highlights" },
    133      icon: "chrome://global/skin/icons/highlights.svg",
    134    },
    135    shouldSendImpressionStats: false,
    136  }),
    137 });
    138 
    139 export const SectionsManager = {
    140  ACTIONS_TO_PROXY: ["WEBEXT_CLICK", "WEBEXT_DISMISS"],
    141  CONTEXT_MENU_PREFS: {},
    142  CONTEXT_MENU_OPTIONS_FOR_HIGHLIGHT_TYPES: {
    143    history: [
    144      "CheckBookmark",
    145      "Separator",
    146      "OpenInNewWindow",
    147      "OpenInPrivateWindow",
    148      "Separator",
    149      "BlockUrl",
    150      "DeleteUrl",
    151    ],
    152    bookmark: [
    153      "CheckBookmark",
    154      "Separator",
    155      "OpenInNewWindow",
    156      "OpenInPrivateWindow",
    157      "Separator",
    158      "BlockUrl",
    159      "DeleteUrl",
    160    ],
    161    pocket: [
    162      "Separator",
    163      "OpenInNewWindow",
    164      "OpenInPrivateWindow",
    165      "Separator",
    166      "BlockUrl",
    167    ],
    168    download: [
    169      "OpenFile",
    170      "ShowFile",
    171      "Separator",
    172      "GoToDownloadPage",
    173      "CopyDownloadLink",
    174      "Separator",
    175      "RemoveDownload",
    176      "BlockUrl",
    177    ],
    178  },
    179  initialized: false,
    180  sections: new Map(),
    181  async init(prefs = {}) {
    182    const featureConfig = {
    183      newtab: lazy.NimbusFeatures.newtab.getAllVariables() || {},
    184      pocketNewtab: lazy.NimbusFeatures.pocketNewtab.getAllVariables() || {},
    185    };
    186 
    187    for (const feedPrefName of Object.keys(BUILT_IN_SECTIONS(featureConfig))) {
    188      const optionsPrefName = `${feedPrefName}.options`;
    189      await this.addBuiltInSection(feedPrefName, prefs[optionsPrefName]);
    190 
    191      this._dedupeConfiguration = [];
    192      this.sections.forEach(section => {
    193        if (section.dedupeFrom) {
    194          this._dedupeConfiguration.push({
    195            id: section.id,
    196            dedupeFrom: section.dedupeFrom,
    197          });
    198        }
    199      });
    200    }
    201 
    202    Object.keys(this.CONTEXT_MENU_PREFS).forEach(k =>
    203      Services.prefs.addObserver(this.CONTEXT_MENU_PREFS[k], this)
    204    );
    205 
    206    this.initialized = true;
    207    this.emit(this.INIT);
    208  },
    209  observe(subject, topic, data) {
    210    switch (topic) {
    211      case "nsPref:changed":
    212        for (const pref of Object.keys(this.CONTEXT_MENU_PREFS)) {
    213          if (data === this.CONTEXT_MENU_PREFS[pref]) {
    214            this.updateSections();
    215          }
    216        }
    217        break;
    218    }
    219  },
    220  async addBuiltInSection(feedPrefName, optionsPrefValue = "{}") {
    221    let options;
    222    const featureConfig = {
    223      newtab: lazy.NimbusFeatures.newtab.getAllVariables() || {},
    224      pocketNewtab: lazy.NimbusFeatures.pocketNewtab.getAllVariables() || {},
    225    };
    226    try {
    227      options = JSON.parse(optionsPrefValue);
    228    } catch (e) {
    229      options = {};
    230      console.error(`Problem parsing options pref for ${feedPrefName}`);
    231    }
    232 
    233    const defaultSection =
    234      BUILT_IN_SECTIONS(featureConfig)[feedPrefName](options);
    235    const section = Object.assign({}, defaultSection, {
    236      pref: Object.assign({}, defaultSection.pref),
    237    });
    238    section.pref.feed = feedPrefName;
    239    this.addSection(section.id, Object.assign(section, { options }));
    240  },
    241  addSection(id, options) {
    242    this.updateLinkMenuOptions(options, id);
    243    this.sections.set(id, options);
    244    this.emit(this.ADD_SECTION, id, options);
    245  },
    246  removeSection(id) {
    247    this.emit(this.REMOVE_SECTION, id);
    248    this.sections.delete(id);
    249  },
    250  enableSection(id, isStartup = false) {
    251    this.updateSection(id, { enabled: true }, true, isStartup);
    252    this.emit(this.ENABLE_SECTION, id);
    253  },
    254  disableSection(id) {
    255    this.updateSection(
    256      id,
    257      { enabled: false, rows: [], initialized: false },
    258      true
    259    );
    260    this.emit(this.DISABLE_SECTION, id);
    261  },
    262  updateSections() {
    263    this.sections.forEach((section, id) =>
    264      this.updateSection(id, section, true)
    265    );
    266  },
    267  updateSection(id, options, shouldBroadcast, isStartup = false) {
    268    this.updateLinkMenuOptions(options, id);
    269    if (this.sections.has(id)) {
    270      const optionsWithDedupe = Object.assign({}, options, {
    271        dedupeConfigurations: this._dedupeConfiguration,
    272      });
    273      this.sections.set(id, Object.assign(this.sections.get(id), options));
    274      this.emit(
    275        this.UPDATE_SECTION,
    276        id,
    277        optionsWithDedupe,
    278        shouldBroadcast,
    279        isStartup
    280      );
    281    }
    282  },
    283 
    284  /**
    285   * Save metadata to places db and add a visit for that URL.
    286   */
    287  updateBookmarkMetadata({ url }) {
    288    this.sections.forEach((section, id) => {
    289      if (id === "highlights") {
    290        // Skip Highlights cards, we already have that metadata.
    291        return;
    292      }
    293      if (section.rows) {
    294        section.rows.forEach(card => {
    295          if (
    296            card.url === url &&
    297            card.description &&
    298            card.title &&
    299            card.image
    300          ) {
    301            lazy.PlacesUtils.history.update({
    302              url: card.url,
    303              title: card.title,
    304              description: card.description,
    305              previewImageURL: card.image,
    306            });
    307            // Highlights query skips bookmarks with no visits.
    308            lazy.PlacesUtils.history.insert({
    309              url,
    310              title: card.title,
    311              visits: [{}],
    312            });
    313          }
    314        });
    315      }
    316    });
    317  },
    318 
    319  /**
    320   * Sets the section's context menu options. These are all available context menu
    321   * options minus the ones that are tied to a pref (see CONTEXT_MENU_PREFS) set
    322   * to false.
    323   *
    324   * @param options section options
    325   * @param id      section ID
    326   */
    327  updateLinkMenuOptions(options, id) {
    328    if (options.availableLinkMenuOptions) {
    329      options.contextMenuOptions = options.availableLinkMenuOptions.filter(
    330        o =>
    331          !this.CONTEXT_MENU_PREFS[o] ||
    332          Services.prefs.getBoolPref(this.CONTEXT_MENU_PREFS[o])
    333      );
    334    }
    335 
    336    // Once we have rows, we can give each card it's own context menu based on it's type.
    337    // We only want to do this for highlights because those have different data types.
    338    // All other sections (built by the web extension API) will have the same context menu per section
    339    if (options.rows && id === "highlights") {
    340      this._addCardTypeLinkMenuOptions(options.rows);
    341    }
    342  },
    343 
    344  /**
    345   * Sets each card in highlights' context menu options based on the card's type.
    346   * (See types.mjs for a list of types)
    347   *
    348   * @param rows section rows containing a type for each card
    349   */
    350  _addCardTypeLinkMenuOptions(rows) {
    351    for (let card of rows) {
    352      if (!this.CONTEXT_MENU_OPTIONS_FOR_HIGHLIGHT_TYPES[card.type]) {
    353        console.error(
    354          `No context menu for highlight type ${card.type} is configured`
    355        );
    356      } else {
    357        card.contextMenuOptions =
    358          this.CONTEXT_MENU_OPTIONS_FOR_HIGHLIGHT_TYPES[card.type];
    359 
    360        // Remove any options that shouldn't be there based on CONTEXT_MENU_PREFS.
    361        // For example: If the Pocket extension is disabled, we should remove the CheckSavedToPocket option
    362        // for each card that has it
    363        card.contextMenuOptions = card.contextMenuOptions.filter(
    364          o =>
    365            !this.CONTEXT_MENU_PREFS[o] ||
    366            Services.prefs.getBoolPref(this.CONTEXT_MENU_PREFS[o])
    367        );
    368      }
    369    }
    370  },
    371 
    372  /**
    373   * Update a specific section card by its url. This allows an action to be
    374   * broadcast to all existing pages to update a specific card without having to
    375   * also force-update the rest of the section's cards and state on those pages.
    376   *
    377   * @param id              The id of the section with the card to be updated
    378   * @param url             The url of the card to update
    379   * @param options         The options to update for the card
    380   * @param shouldBroadcast Whether or not to broadcast the update
    381   * @param isStartup       If this update is during startup.
    382   */
    383  updateSectionCard(id, url, options, shouldBroadcast, isStartup = false) {
    384    if (this.sections.has(id)) {
    385      const card = this.sections.get(id).rows.find(elem => elem.url === url);
    386      if (card) {
    387        Object.assign(card, options);
    388      }
    389      this.emit(
    390        this.UPDATE_SECTION_CARD,
    391        id,
    392        url,
    393        options,
    394        shouldBroadcast,
    395        isStartup
    396      );
    397    }
    398  },
    399  removeSectionCard(sectionId, url) {
    400    if (!this.sections.has(sectionId)) {
    401      return;
    402    }
    403    const rows = this.sections
    404      .get(sectionId)
    405      .rows.filter(row => row.url !== url);
    406    this.updateSection(sectionId, { rows }, true);
    407  },
    408  onceInitialized(callback) {
    409    if (this.initialized) {
    410      callback();
    411    } else {
    412      this.once(this.INIT, callback);
    413    }
    414  },
    415  uninit() {
    416    Object.keys(this.CONTEXT_MENU_PREFS).forEach(k =>
    417      Services.prefs.removeObserver(this.CONTEXT_MENU_PREFS[k], this)
    418    );
    419    SectionsManager.initialized = false;
    420  },
    421 };
    422 
    423 for (const action of [
    424  "ACTION_DISPATCHED",
    425  "ADD_SECTION",
    426  "REMOVE_SECTION",
    427  "ENABLE_SECTION",
    428  "DISABLE_SECTION",
    429  "UPDATE_SECTION",
    430  "UPDATE_SECTION_CARD",
    431  "INIT",
    432  "UNINIT",
    433 ]) {
    434  SectionsManager[action] = action;
    435 }
    436 
    437 EventEmitter.decorate(SectionsManager);
    438 
    439 export class SectionsFeed {
    440  constructor() {
    441    this.init = this.init.bind(this);
    442    this.onAddSection = this.onAddSection.bind(this);
    443    this.onRemoveSection = this.onRemoveSection.bind(this);
    444    this.onUpdateSection = this.onUpdateSection.bind(this);
    445    this.onUpdateSectionCard = this.onUpdateSectionCard.bind(this);
    446  }
    447 
    448  init() {
    449    SectionsManager.on(SectionsManager.ADD_SECTION, this.onAddSection);
    450    SectionsManager.on(SectionsManager.REMOVE_SECTION, this.onRemoveSection);
    451    SectionsManager.on(SectionsManager.UPDATE_SECTION, this.onUpdateSection);
    452    SectionsManager.on(
    453      SectionsManager.UPDATE_SECTION_CARD,
    454      this.onUpdateSectionCard
    455    );
    456    // Catch any sections that have already been added
    457    SectionsManager.sections.forEach((section, id) =>
    458      this.onAddSection(
    459        SectionsManager.ADD_SECTION,
    460        id,
    461        section,
    462        true /* isStartup */
    463      )
    464    );
    465  }
    466 
    467  uninit() {
    468    SectionsManager.uninit();
    469    SectionsManager.emit(SectionsManager.UNINIT);
    470    SectionsManager.off(SectionsManager.ADD_SECTION, this.onAddSection);
    471    SectionsManager.off(SectionsManager.REMOVE_SECTION, this.onRemoveSection);
    472    SectionsManager.off(SectionsManager.UPDATE_SECTION, this.onUpdateSection);
    473    SectionsManager.off(
    474      SectionsManager.UPDATE_SECTION_CARD,
    475      this.onUpdateSectionCard
    476    );
    477  }
    478 
    479  onAddSection(event, id, options, isStartup = false) {
    480    if (options) {
    481      this.store.dispatch(
    482        ac.BroadcastToContent({
    483          type: at.SECTION_REGISTER,
    484          data: Object.assign({ id }, options),
    485          meta: {
    486            isStartup,
    487          },
    488        })
    489      );
    490 
    491      // Make sure the section is in sectionOrder pref. Otherwise, prepend it.
    492      const orderedSections = this.orderedSectionIds;
    493      if (!orderedSections.includes(id)) {
    494        orderedSections.unshift(id);
    495        this.store.dispatch(
    496          ac.SetPref("sectionOrder", orderedSections.join(","))
    497        );
    498      }
    499    }
    500  }
    501 
    502  onRemoveSection(event, id) {
    503    this.store.dispatch(
    504      ac.BroadcastToContent({ type: at.SECTION_DEREGISTER, data: id })
    505    );
    506  }
    507 
    508  onUpdateSection(
    509    event,
    510    id,
    511    options,
    512    shouldBroadcast = false,
    513    isStartup = false
    514  ) {
    515    if (options) {
    516      const action = {
    517        type: at.SECTION_UPDATE,
    518        data: Object.assign(options, { id }),
    519        meta: {
    520          isStartup,
    521        },
    522      };
    523      this.store.dispatch(
    524        shouldBroadcast
    525          ? ac.BroadcastToContent(action)
    526          : ac.AlsoToPreloaded(action)
    527      );
    528    }
    529  }
    530 
    531  onUpdateSectionCard(
    532    event,
    533    id,
    534    url,
    535    options,
    536    shouldBroadcast = false,
    537    isStartup = false
    538  ) {
    539    if (options) {
    540      const action = {
    541        type: at.SECTION_UPDATE_CARD,
    542        data: { id, url, options },
    543        meta: {
    544          isStartup,
    545        },
    546      };
    547      this.store.dispatch(
    548        shouldBroadcast
    549          ? ac.BroadcastToContent(action)
    550          : ac.AlsoToPreloaded(action)
    551      );
    552    }
    553  }
    554 
    555  get orderedSectionIds() {
    556    return this.store.getState().Prefs.values.sectionOrder.split(",");
    557  }
    558 
    559  async onAction(action) {
    560    switch (action.type) {
    561      case at.INIT:
    562        SectionsManager.onceInitialized(this.init);
    563        break;
    564      // Wait for pref values, as some sections have options stored in prefs
    565      case at.PREFS_INITIAL_VALUES:
    566        SectionsManager.init(action.data);
    567        break;
    568      case at.PREF_CHANGED: {
    569        if (action.data) {
    570          const matched = action.data.name.match(
    571            /^(feeds.section.(\S+)).options$/i
    572          );
    573          if (matched) {
    574            await SectionsManager.addBuiltInSection(
    575              matched[1],
    576              action.data.value
    577            );
    578            this.store.dispatch({
    579              type: at.SECTION_OPTIONS_CHANGED,
    580              data: matched[2],
    581            });
    582          }
    583        }
    584        break;
    585      }
    586      case at.PLACES_BOOKMARK_ADDED:
    587        SectionsManager.updateBookmarkMetadata(action.data);
    588        break;
    589      case at.WEBEXT_DISMISS:
    590        if (action.data) {
    591          SectionsManager.removeSectionCard(
    592            action.data.source,
    593            action.data.url
    594          );
    595        }
    596        break;
    597      case at.SECTION_DISABLE:
    598        SectionsManager.disableSection(action.data);
    599        break;
    600      case at.SECTION_ENABLE:
    601        SectionsManager.enableSection(action.data);
    602        break;
    603      case at.UNINIT:
    604        this.uninit();
    605        break;
    606    }
    607    if (
    608      SectionsManager.ACTIONS_TO_PROXY.includes(action.type) &&
    609      SectionsManager.sections.size > 0
    610    ) {
    611      SectionsManager.emit(
    612        SectionsManager.ACTION_DISPATCHED,
    613        action.type,
    614        action.data
    615      );
    616    }
    617  }
    618 }