SectionsManager.jsm 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474
  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. "use strict";
  5. const {EventEmitter} = ChromeUtils.import("resource://gre/modules/EventEmitter.jsm");
  6. const {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm");
  7. const {actionCreators: ac, actionTypes: at} = ChromeUtils.import("resource://activity-stream/common/Actions.jsm");
  8. const {getDefaultOptions} = ChromeUtils.import("resource://activity-stream/lib/ActivityStreamStorage.jsm");
  9. ChromeUtils.defineModuleGetter(this, "PlacesUtils", "resource://gre/modules/PlacesUtils.jsm");
  10. /*
  11. * Generators for built in sections, keyed by the pref name for their feed.
  12. * Built in sections may depend on options stored as serialised JSON in the pref
  13. * `${feed_pref_name}.options`.
  14. */
  15. const BUILT_IN_SECTIONS = {
  16. "feeds.section.topstories": options => ({
  17. id: "topstories",
  18. pref: {
  19. titleString: {id: "header_recommended_by", values: {provider: options.provider_name}},
  20. descString: {id: "prefs_topstories_description2"},
  21. nestedPrefs: options.show_spocs ? [{
  22. name: "showSponsored",
  23. titleString: "prefs_topstories_options_sponsored_label",
  24. icon: "icon-info",
  25. }] : [],
  26. },
  27. shouldHidePref: options.hidden,
  28. eventSource: "TOP_STORIES",
  29. icon: options.provider_icon,
  30. title: {id: "header_recommended_by", values: {provider: options.provider_name}},
  31. learnMore: {
  32. link: {
  33. href: "https://getpocket.com/firefox/new_tab_learn_more",
  34. id: "pocket_how_it_works",
  35. },
  36. },
  37. privacyNoticeURL: "https://www.mozilla.org/privacy/firefox/#suggest-relevant-content",
  38. compactCards: false,
  39. rowsPref: "section.topstories.rows",
  40. maxRows: 4,
  41. availableLinkMenuOptions: ["CheckBookmarkOrArchive", "CheckSavedToPocket", "Separator", "OpenInNewWindow", "OpenInPrivateWindow", "Separator", "BlockUrl"],
  42. emptyState: {
  43. message: {id: "topstories_empty_state", values: {provider: options.provider_name}},
  44. icon: "check",
  45. },
  46. shouldSendImpressionStats: true,
  47. dedupeFrom: ["highlights"],
  48. }),
  49. "feeds.section.highlights": options => ({
  50. id: "highlights",
  51. pref: {
  52. titleString: {id: "settings_pane_highlights_header"},
  53. descString: {id: "prefs_highlights_description"},
  54. nestedPrefs: [{
  55. name: "section.highlights.includeVisited",
  56. titleString: "prefs_highlights_options_visited_label",
  57. }, {
  58. name: "section.highlights.includeBookmarks",
  59. titleString: "settings_pane_highlights_options_bookmarks",
  60. }, {
  61. name: "section.highlights.includeDownloads",
  62. titleString: "prefs_highlights_options_download_label",
  63. }, {
  64. name: "section.highlights.includePocket",
  65. titleString: "prefs_highlights_options_pocket_label",
  66. }],
  67. },
  68. shouldHidePref: false,
  69. eventSource: "HIGHLIGHTS",
  70. icon: "highlights",
  71. title: {id: "header_highlights"},
  72. compactCards: true,
  73. rowsPref: "section.highlights.rows",
  74. maxRows: 4,
  75. emptyState: {
  76. message: {id: "highlights_empty_state"},
  77. icon: "highlights",
  78. },
  79. shouldSendImpressionStats: false,
  80. }),
  81. };
  82. const SectionsManager = {
  83. ACTIONS_TO_PROXY: ["WEBEXT_CLICK", "WEBEXT_DISMISS"],
  84. CONTEXT_MENU_PREFS: {"CheckSavedToPocket": "extensions.pocket.enabled"},
  85. CONTEXT_MENU_OPTIONS_FOR_HIGHLIGHT_TYPES: {
  86. history: ["CheckBookmark", "CheckSavedToPocket", "Separator", "OpenInNewWindow", "OpenInPrivateWindow", "Separator", "BlockUrl", "DeleteUrl"],
  87. bookmark: ["CheckBookmark", "CheckSavedToPocket", "Separator", "OpenInNewWindow", "OpenInPrivateWindow", "Separator", "BlockUrl", "DeleteUrl"],
  88. pocket: ["ArchiveFromPocket", "CheckSavedToPocket", "Separator", "OpenInNewWindow", "OpenInPrivateWindow", "Separator", "BlockUrl"],
  89. download: ["OpenFile", "ShowFile", "Separator", "GoToDownloadPage", "CopyDownloadLink", "Separator", "RemoveDownload", "BlockUrl"],
  90. },
  91. initialized: false,
  92. sections: new Map(),
  93. async init(prefs = {}, storage) {
  94. this._storage = storage;
  95. for (const feedPrefName of Object.keys(BUILT_IN_SECTIONS)) {
  96. const optionsPrefName = `${feedPrefName}.options`;
  97. await this.addBuiltInSection(feedPrefName, prefs[optionsPrefName]);
  98. this._dedupeConfiguration = [];
  99. this.sections.forEach(section => {
  100. if (section.dedupeFrom) {
  101. this._dedupeConfiguration.push({
  102. id: section.id,
  103. dedupeFrom: section.dedupeFrom,
  104. });
  105. }
  106. });
  107. }
  108. Object.keys(this.CONTEXT_MENU_PREFS).forEach(k =>
  109. Services.prefs.addObserver(this.CONTEXT_MENU_PREFS[k], this));
  110. this.initialized = true;
  111. this.emit(this.INIT);
  112. },
  113. observe(subject, topic, data) {
  114. switch (topic) {
  115. case "nsPref:changed":
  116. for (const pref of Object.keys(this.CONTEXT_MENU_PREFS)) {
  117. if (data === this.CONTEXT_MENU_PREFS[pref]) {
  118. this.updateSections();
  119. }
  120. }
  121. break;
  122. }
  123. },
  124. updateSectionPrefs(id, collapsed) {
  125. const section = this.sections.get(id);
  126. if (!section) {
  127. return;
  128. }
  129. const updatedSection = Object.assign({}, section, {pref: Object.assign({}, section.pref, collapsed)});
  130. this.updateSection(id, updatedSection, true);
  131. },
  132. async addBuiltInSection(feedPrefName, optionsPrefValue = "{}") {
  133. let options;
  134. let storedPrefs;
  135. try {
  136. options = JSON.parse(optionsPrefValue);
  137. } catch (e) {
  138. options = {};
  139. Cu.reportError(`Problem parsing options pref for ${feedPrefName}`);
  140. }
  141. try {
  142. storedPrefs = await this._storage.get(feedPrefName) || {};
  143. } catch (e) {
  144. storedPrefs = {};
  145. Cu.reportError(`Problem getting stored prefs for ${feedPrefName}`);
  146. }
  147. const defaultSection = BUILT_IN_SECTIONS[feedPrefName](options);
  148. const section = Object.assign({}, defaultSection, {pref: Object.assign({}, defaultSection.pref, getDefaultOptions(storedPrefs))});
  149. section.pref.feed = feedPrefName;
  150. this.addSection(section.id, Object.assign(section, {options}));
  151. },
  152. addSection(id, options) {
  153. this.updateLinkMenuOptions(options, id);
  154. this.sections.set(id, options);
  155. this.emit(this.ADD_SECTION, id, options);
  156. },
  157. removeSection(id) {
  158. this.emit(this.REMOVE_SECTION, id);
  159. this.sections.delete(id);
  160. },
  161. enableSection(id) {
  162. this.updateSection(id, {enabled: true}, true);
  163. this.emit(this.ENABLE_SECTION, id);
  164. },
  165. disableSection(id) {
  166. this.updateSection(id, {enabled: false, rows: [], initialized: false}, true);
  167. this.emit(this.DISABLE_SECTION, id);
  168. },
  169. updateSections() {
  170. this.sections.forEach((section, id) => this.updateSection(id, section, true));
  171. },
  172. updateSection(id, options, shouldBroadcast) {
  173. this.updateLinkMenuOptions(options, id);
  174. if (this.sections.has(id)) {
  175. const optionsWithDedupe = Object.assign({}, options, {dedupeConfigurations: this._dedupeConfiguration});
  176. this.sections.set(id, Object.assign(this.sections.get(id), options));
  177. this.emit(this.UPDATE_SECTION, id, optionsWithDedupe, shouldBroadcast);
  178. }
  179. },
  180. /**
  181. * Save metadata to places db and add a visit for that URL.
  182. */
  183. updateBookmarkMetadata({url}) {
  184. this.sections.forEach((section, id) => {
  185. if (id === "highlights") {
  186. // Skip Highlights cards, we already have that metadata.
  187. return;
  188. }
  189. if (section.rows) {
  190. section.rows.forEach(card => {
  191. if (card.url === url && card.description && card.title && card.image) {
  192. PlacesUtils.history.update({
  193. url: card.url,
  194. title: card.title,
  195. description: card.description,
  196. previewImageURL: card.image,
  197. });
  198. // Highlights query skips bookmarks with no visits.
  199. PlacesUtils.history.insert({
  200. url,
  201. title: card.title,
  202. visits: [{}],
  203. });
  204. }
  205. });
  206. }
  207. });
  208. },
  209. /**
  210. * Sets the section's context menu options. These are all available context menu
  211. * options minus the ones that are tied to a pref (see CONTEXT_MENU_PREFS) set
  212. * to false.
  213. *
  214. * @param options section options
  215. * @param id section ID
  216. */
  217. updateLinkMenuOptions(options, id) {
  218. if (options.availableLinkMenuOptions) {
  219. options.contextMenuOptions = options.availableLinkMenuOptions.filter(
  220. o => !this.CONTEXT_MENU_PREFS[o] || Services.prefs.getBoolPref(this.CONTEXT_MENU_PREFS[o]));
  221. }
  222. // Once we have rows, we can give each card it's own context menu based on it's type.
  223. // We only want to do this for highlights because those have different data types.
  224. // All other sections (built by the web extension API) will have the same context menu per section
  225. if (options.rows && id === "highlights") {
  226. this._addCardTypeLinkMenuOptions(options.rows);
  227. }
  228. },
  229. /**
  230. * Sets each card in highlights' context menu options based on the card's type.
  231. * (See types.js for a list of types)
  232. *
  233. * @param rows section rows containing a type for each card
  234. */
  235. _addCardTypeLinkMenuOptions(rows) {
  236. for (let card of rows) {
  237. if (!this.CONTEXT_MENU_OPTIONS_FOR_HIGHLIGHT_TYPES[card.type]) {
  238. Cu.reportError(`No context menu for highlight type ${card.type} is configured`);
  239. } else {
  240. card.contextMenuOptions = this.CONTEXT_MENU_OPTIONS_FOR_HIGHLIGHT_TYPES[card.type];
  241. // Remove any options that shouldn't be there based on CONTEXT_MENU_PREFS.
  242. // For example: If the Pocket extension is disabled, we should remove the CheckSavedToPocket option
  243. // for each card that has it
  244. card.contextMenuOptions = card.contextMenuOptions.filter(
  245. o => !this.CONTEXT_MENU_PREFS[o] || Services.prefs.getBoolPref(this.CONTEXT_MENU_PREFS[o]));
  246. }
  247. }
  248. },
  249. /**
  250. * Update a specific section card by its url. This allows an action to be
  251. * broadcast to all existing pages to update a specific card without having to
  252. * also force-update the rest of the section's cards and state on those pages.
  253. *
  254. * @param id The id of the section with the card to be updated
  255. * @param url The url of the card to update
  256. * @param options The options to update for the card
  257. * @param shouldBroadcast Whether or not to broadcast the update
  258. */
  259. updateSectionCard(id, url, options, shouldBroadcast) {
  260. if (this.sections.has(id)) {
  261. const card = this.sections.get(id).rows.find(elem => elem.url === url);
  262. if (card) {
  263. Object.assign(card, options);
  264. }
  265. this.emit(this.UPDATE_SECTION_CARD, id, url, options, shouldBroadcast);
  266. }
  267. },
  268. removeSectionCard(sectionId, url) {
  269. if (!this.sections.has(sectionId)) {
  270. return;
  271. }
  272. const rows = this.sections.get(sectionId).rows.filter(row => row.url !== url);
  273. this.updateSection(sectionId, {rows}, true);
  274. },
  275. onceInitialized(callback) {
  276. if (this.initialized) {
  277. callback();
  278. } else {
  279. this.once(this.INIT, callback);
  280. }
  281. },
  282. uninit() {
  283. Object.keys(this.CONTEXT_MENU_PREFS).forEach(k =>
  284. Services.prefs.removeObserver(this.CONTEXT_MENU_PREFS[k], this));
  285. SectionsManager.initialized = false;
  286. },
  287. };
  288. for (const action of [
  289. "ACTION_DISPATCHED",
  290. "ADD_SECTION",
  291. "REMOVE_SECTION",
  292. "ENABLE_SECTION",
  293. "DISABLE_SECTION",
  294. "UPDATE_SECTION",
  295. "UPDATE_SECTION_CARD",
  296. "INIT",
  297. "UNINIT",
  298. ]) {
  299. SectionsManager[action] = action;
  300. }
  301. EventEmitter.decorate(SectionsManager);
  302. class SectionsFeed {
  303. constructor() {
  304. this.init = this.init.bind(this);
  305. this.onAddSection = this.onAddSection.bind(this);
  306. this.onRemoveSection = this.onRemoveSection.bind(this);
  307. this.onUpdateSection = this.onUpdateSection.bind(this);
  308. this.onUpdateSectionCard = this.onUpdateSectionCard.bind(this);
  309. }
  310. init() {
  311. SectionsManager.on(SectionsManager.ADD_SECTION, this.onAddSection);
  312. SectionsManager.on(SectionsManager.REMOVE_SECTION, this.onRemoveSection);
  313. SectionsManager.on(SectionsManager.UPDATE_SECTION, this.onUpdateSection);
  314. SectionsManager.on(SectionsManager.UPDATE_SECTION_CARD, this.onUpdateSectionCard);
  315. // Catch any sections that have already been added
  316. SectionsManager.sections.forEach((section, id) =>
  317. this.onAddSection(SectionsManager.ADD_SECTION, id, section));
  318. }
  319. uninit() {
  320. SectionsManager.uninit();
  321. SectionsManager.emit(SectionsManager.UNINIT);
  322. SectionsManager.off(SectionsManager.ADD_SECTION, this.onAddSection);
  323. SectionsManager.off(SectionsManager.REMOVE_SECTION, this.onRemoveSection);
  324. SectionsManager.off(SectionsManager.UPDATE_SECTION, this.onUpdateSection);
  325. SectionsManager.off(SectionsManager.UPDATE_SECTION_CARD, this.onUpdateSectionCard);
  326. }
  327. onAddSection(event, id, options) {
  328. if (options) {
  329. this.store.dispatch(ac.BroadcastToContent({type: at.SECTION_REGISTER, data: Object.assign({id}, options)}));
  330. // Make sure the section is in sectionOrder pref. Otherwise, prepend it.
  331. const orderedSections = this.orderedSectionIds;
  332. if (!orderedSections.includes(id)) {
  333. orderedSections.unshift(id);
  334. this.store.dispatch(ac.SetPref("sectionOrder", orderedSections.join(",")));
  335. }
  336. }
  337. }
  338. onRemoveSection(event, id) {
  339. this.store.dispatch(ac.BroadcastToContent({type: at.SECTION_DEREGISTER, data: id}));
  340. }
  341. onUpdateSection(event, id, options, shouldBroadcast = false) {
  342. if (options) {
  343. const action = {type: at.SECTION_UPDATE, data: Object.assign(options, {id})};
  344. this.store.dispatch(shouldBroadcast ? ac.BroadcastToContent(action) : ac.AlsoToPreloaded(action));
  345. }
  346. }
  347. onUpdateSectionCard(event, id, url, options, shouldBroadcast = false) {
  348. if (options) {
  349. const action = {type: at.SECTION_UPDATE_CARD, data: {id, url, options}};
  350. this.store.dispatch(shouldBroadcast ? ac.BroadcastToContent(action) : ac.AlsoToPreloaded(action));
  351. }
  352. }
  353. get orderedSectionIds() {
  354. return this.store.getState().Prefs.values.sectionOrder.split(",");
  355. }
  356. get enabledSectionIds() {
  357. let sections = this.store.getState().Sections.filter(section => section.enabled).map(s => s.id);
  358. // Top Sites is a special case. Append if the feed is enabled.
  359. if (this.store.getState().Prefs.values["feeds.topsites"]) {
  360. sections.push("topsites");
  361. }
  362. return sections;
  363. }
  364. moveSection(id, direction) {
  365. const orderedSections = this.orderedSectionIds;
  366. const enabledSections = this.enabledSectionIds;
  367. let index = orderedSections.indexOf(id);
  368. orderedSections.splice(index, 1);
  369. if (direction > 0) {
  370. // "Move Down"
  371. while (index < orderedSections.length) {
  372. // If the section at the index is enabled/visible, insert moved section after.
  373. // Otherwise, move on to the next spot and check it.
  374. if (enabledSections.includes(orderedSections[index++])) {
  375. break;
  376. }
  377. }
  378. } else {
  379. // "Move Up"
  380. while (index > 0) {
  381. // If the section at the previous index is enabled/visible, insert moved section there.
  382. // Otherwise, move on to the previous spot and check it.
  383. index--;
  384. if (enabledSections.includes(orderedSections[index])) {
  385. break;
  386. }
  387. }
  388. }
  389. orderedSections.splice(index, 0, id);
  390. this.store.dispatch(ac.SetPref("sectionOrder", orderedSections.join(",")));
  391. }
  392. async onAction(action) {
  393. switch (action.type) {
  394. case at.INIT:
  395. SectionsManager.onceInitialized(this.init);
  396. break;
  397. // Wait for pref values, as some sections have options stored in prefs
  398. case at.PREFS_INITIAL_VALUES:
  399. SectionsManager.init(action.data, this.store.dbStorage.getDbTable("sectionPrefs"));
  400. break;
  401. case at.PREF_CHANGED: {
  402. if (action.data) {
  403. const matched = action.data.name.match(/^(feeds.section.(\S+)).options$/i);
  404. if (matched) {
  405. await SectionsManager.addBuiltInSection(matched[1], action.data.value);
  406. this.store.dispatch({type: at.SECTION_OPTIONS_CHANGED, data: matched[2]});
  407. }
  408. }
  409. break;
  410. }
  411. case at.UPDATE_SECTION_PREFS:
  412. SectionsManager.updateSectionPrefs(action.data.id, action.data.value);
  413. break;
  414. case at.PLACES_BOOKMARK_ADDED:
  415. SectionsManager.updateBookmarkMetadata(action.data);
  416. break;
  417. case at.WEBEXT_DISMISS:
  418. if (action.data) {
  419. SectionsManager.removeSectionCard(action.data.source, action.data.url);
  420. }
  421. break;
  422. case at.SECTION_DISABLE:
  423. SectionsManager.disableSection(action.data);
  424. break;
  425. case at.SECTION_ENABLE:
  426. SectionsManager.enableSection(action.data);
  427. break;
  428. case at.SECTION_MOVE:
  429. this.moveSection(action.data.id, action.data.direction);
  430. break;
  431. case at.UNINIT:
  432. this.uninit();
  433. break;
  434. }
  435. if (SectionsManager.ACTIONS_TO_PROXY.includes(action.type) && SectionsManager.sections.size > 0) {
  436. SectionsManager.emit(SectionsManager.ACTION_DISPATCHED, action.type, action.data);
  437. }
  438. }
  439. }
  440. this.SectionsFeed = SectionsFeed;
  441. this.SectionsManager = SectionsManager;
  442. const EXPORTED_SYMBOLS = ["SectionsFeed", "SectionsManager"];