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 }