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 6const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); 7 8const { actionTypes: at } = ChromeUtils.import( 9 "resource://activity-stream/common/Actions.jsm" 10); 11 12const { shortURL } = ChromeUtils.import( 13 "resource://activity-stream/lib/ShortURL.jsm" 14); 15const { SectionsManager } = ChromeUtils.import( 16 "resource://activity-stream/lib/SectionsManager.jsm" 17); 18const { 19 TOP_SITES_DEFAULT_ROWS, 20 TOP_SITES_MAX_SITES_PER_ROW, 21} = ChromeUtils.import("resource://activity-stream/common/Reducers.jsm"); 22const { Dedupe } = ChromeUtils.import( 23 "resource://activity-stream/common/Dedupe.jsm" 24); 25 26ChromeUtils.defineModuleGetter( 27 this, 28 "filterAdult", 29 "resource://activity-stream/lib/FilterAdult.jsm" 30); 31ChromeUtils.defineModuleGetter( 32 this, 33 "LinksCache", 34 "resource://activity-stream/lib/LinksCache.jsm" 35); 36ChromeUtils.defineModuleGetter( 37 this, 38 "NewTabUtils", 39 "resource://gre/modules/NewTabUtils.jsm" 40); 41ChromeUtils.defineModuleGetter( 42 this, 43 "Screenshots", 44 "resource://activity-stream/lib/Screenshots.jsm" 45); 46ChromeUtils.defineModuleGetter( 47 this, 48 "PageThumbs", 49 "resource://gre/modules/PageThumbs.jsm" 50); 51ChromeUtils.defineModuleGetter( 52 this, 53 "DownloadsManager", 54 "resource://activity-stream/lib/DownloadsManager.jsm" 55); 56 57const HIGHLIGHTS_MAX_LENGTH = 16; 58const MANY_EXTRA_LENGTH = 59 HIGHLIGHTS_MAX_LENGTH * 5 + 60 TOP_SITES_DEFAULT_ROWS * TOP_SITES_MAX_SITES_PER_ROW; 61const SECTION_ID = "highlights"; 62const SYNC_BOOKMARKS_FINISHED_EVENT = "weave:engine:sync:applied"; 63const BOOKMARKS_RESTORE_SUCCESS_EVENT = "bookmarks-restore-success"; 64const BOOKMARKS_RESTORE_FAILED_EVENT = "bookmarks-restore-failed"; 65const RECENT_DOWNLOAD_THRESHOLD = 36 * 60 * 60 * 1000; 66 67this.HighlightsFeed = class HighlightsFeed { 68 constructor() { 69 this.dedupe = new Dedupe(this._dedupeKey); 70 this.linksCache = new LinksCache( 71 NewTabUtils.activityStreamLinks, 72 "getHighlights", 73 ["image"] 74 ); 75 PageThumbs.addExpirationFilter(this); 76 this.downloadsManager = new DownloadsManager(); 77 } 78 79 _dedupeKey(site) { 80 // Treat bookmarks, pocket, and downloaded items as un-dedupable, otherwise show one of a url 81 return ( 82 site && 83 (site.pocket_id || site.type === "bookmark" || site.type === "download" 84 ? {} 85 : site.url) 86 ); 87 } 88 89 init() { 90 Services.obs.addObserver(this, SYNC_BOOKMARKS_FINISHED_EVENT); 91 Services.obs.addObserver(this, BOOKMARKS_RESTORE_SUCCESS_EVENT); 92 Services.obs.addObserver(this, BOOKMARKS_RESTORE_FAILED_EVENT); 93 SectionsManager.onceInitialized(this.postInit.bind(this)); 94 } 95 96 postInit() { 97 SectionsManager.enableSection(SECTION_ID, true /* isStartup */); 98 this.fetchHighlights({ broadcast: true, isStartup: true }); 99 this.downloadsManager.init(this.store); 100 } 101 102 uninit() { 103 SectionsManager.disableSection(SECTION_ID); 104 PageThumbs.removeExpirationFilter(this); 105 Services.obs.removeObserver(this, SYNC_BOOKMARKS_FINISHED_EVENT); 106 Services.obs.removeObserver(this, BOOKMARKS_RESTORE_SUCCESS_EVENT); 107 Services.obs.removeObserver(this, BOOKMARKS_RESTORE_FAILED_EVENT); 108 } 109 110 observe(subject, topic, data) { 111 // When we receive a notification that a sync has happened for bookmarks, 112 // or Places finished importing or restoring bookmarks, refresh highlights 113 const manyBookmarksChanged = 114 (topic === SYNC_BOOKMARKS_FINISHED_EVENT && data === "bookmarks") || 115 topic === BOOKMARKS_RESTORE_SUCCESS_EVENT || 116 topic === BOOKMARKS_RESTORE_FAILED_EVENT; 117 if (manyBookmarksChanged) { 118 this.fetchHighlights({ broadcast: true }); 119 } 120 } 121 122 filterForThumbnailExpiration(callback) { 123 const state = this.store 124 .getState() 125 .Sections.find(section => section.id === SECTION_ID); 126 127 callback( 128 state && state.initialized 129 ? state.rows.reduce((acc, site) => { 130 // Screenshots call in `fetchImage` will search for preview_image_url or 131 // fallback to URL, so we prevent both from being expired. 132 acc.push(site.url); 133 if (site.preview_image_url) { 134 acc.push(site.preview_image_url); 135 } 136 return acc; 137 }, []) 138 : [] 139 ); 140 } 141 142 /** 143 * Chronologically sort highlights of all types except 'visited'. Then just append 144 * the rest at the end of highlights. 145 * @param {Array} pages The full list of links to order. 146 * @return {Array} A sorted array of highlights 147 */ 148 _orderHighlights(pages) { 149 const splitHighlights = { chronologicalCandidates: [], visited: [] }; 150 for (let page of pages) { 151 if (page.type === "history") { 152 splitHighlights.visited.push(page); 153 } else { 154 splitHighlights.chronologicalCandidates.push(page); 155 } 156 } 157 158 return splitHighlights.chronologicalCandidates 159 .sort((a, b) => a.date_added < b.date_added) 160 .concat(splitHighlights.visited); 161 } 162 163 /** 164 * Refresh the highlights data for content. 165 * @param {bool} options.broadcast Should the update be broadcasted. 166 */ 167 async fetchHighlights(options = {}) { 168 // If TopSites are enabled we need them for deduping, so wait for 169 // TOP_SITES_UPDATED. We also need the section to be registered to update 170 // state, so wait for postInit triggered by SectionsManager initializing. 171 if ( 172 (!this.store.getState().TopSites.initialized && 173 this.store.getState().Prefs.values["feeds.system.topsites"] && 174 this.store.getState().Prefs.values["feeds.topsites"]) || 175 !this.store.getState().Sections.length 176 ) { 177 return; 178 } 179 180 // We broadcast when we want to force an update, so get fresh links 181 if (options.broadcast) { 182 this.linksCache.expire(); 183 } 184 185 // Request more than the expected length to allow for items being removed by 186 // deduping against Top Sites or multiple history from the same domain, etc. 187 const manyPages = await this.linksCache.request({ 188 numItems: MANY_EXTRA_LENGTH, 189 excludeBookmarks: !this.store.getState().Prefs.values[ 190 "section.highlights.includeBookmarks" 191 ], 192 excludeHistory: !this.store.getState().Prefs.values[ 193 "section.highlights.includeVisited" 194 ], 195 excludePocket: !this.store.getState().Prefs.values[ 196 "section.highlights.includePocket" 197 ], 198 }); 199 200 if ( 201 this.store.getState().Prefs.values["section.highlights.includeDownloads"] 202 ) { 203 // We only want 1 download that is less than 36 hours old, and the file currently exists 204 let results = await this.downloadsManager.getDownloads( 205 RECENT_DOWNLOAD_THRESHOLD, 206 { numItems: 1, onlySucceeded: true, onlyExists: true } 207 ); 208 if (results.length) { 209 // We only want 1 download, the most recent one 210 manyPages.push({ 211 ...results[0], 212 type: "download", 213 }); 214 } 215 } 216 217 const orderedPages = this._orderHighlights(manyPages); 218 219 // Remove adult highlights if we need to 220 const checkedAdult = this.store.getState().Prefs.values.filterAdult 221 ? filterAdult(orderedPages) 222 : orderedPages; 223 224 // Remove any Highlights that are in Top Sites already 225 const [, deduped] = this.dedupe.group( 226 this.store.getState().TopSites.rows, 227 checkedAdult 228 ); 229 230 // Keep all "bookmark"s and at most one (most recent) "history" per host 231 const highlights = []; 232 const hosts = new Set(); 233 for (const page of deduped) { 234 const hostname = shortURL(page); 235 // Skip this history page if we already something from the same host 236 if (page.type === "history" && hosts.has(hostname)) { 237 continue; 238 } 239 240 // If we already have the image for the card, use that immediately. Else 241 // asynchronously fetch the image. NEVER fetch a screenshot for downloads 242 if (!page.image && page.type !== "download") { 243 this.fetchImage(page, options.isStartup); 244 } 245 246 // Adjust the type for 'history' items that are also 'bookmarked' when we 247 // want to include bookmarks 248 if ( 249 page.type === "history" && 250 page.bookmarkGuid && 251 this.store.getState().Prefs.values[ 252 "section.highlights.includeBookmarks" 253 ] 254 ) { 255 page.type = "bookmark"; 256 } 257 258 // We want the page, so update various fields for UI 259 Object.assign(page, { 260 hasImage: page.type !== "download", // Downloads do not have an image - all else types fall back to a screenshot 261 hostname, 262 type: page.type, 263 pocket_id: page.pocket_id, 264 }); 265 266 // Add the "bookmark", "pocket", or not-skipped "history" 267 highlights.push(page); 268 hosts.add(hostname); 269 270 // Remove internal properties that might be updated after dispatch 271 delete page.__sharedCache; 272 273 // Skip the rest if we have enough items 274 if (highlights.length === HIGHLIGHTS_MAX_LENGTH) { 275 break; 276 } 277 } 278 279 const { initialized } = this.store 280 .getState() 281 .Sections.find(section => section.id === SECTION_ID); 282 // Broadcast when required or if it is the first update. 283 const shouldBroadcast = options.broadcast || !initialized; 284 285 SectionsManager.updateSection( 286 SECTION_ID, 287 { rows: highlights }, 288 shouldBroadcast, 289 options.isStartup 290 ); 291 } 292 293 /** 294 * Fetch an image for a given highlight and update the card with it. If no 295 * image is available then fallback to fetching a screenshot. 296 */ 297 fetchImage(page, isStartup = false) { 298 // Request a screenshot if we don't already have one pending 299 const { preview_image_url: imageUrl, url } = page; 300 return Screenshots.maybeCacheScreenshot( 301 page, 302 imageUrl || url, 303 "image", 304 image => { 305 SectionsManager.updateSectionCard( 306 SECTION_ID, 307 url, 308 { image }, 309 true, 310 isStartup 311 ); 312 } 313 ); 314 } 315 316 onAction(action) { 317 // Relay the downloads actions to DownloadsManager - it is a child of HighlightsFeed 318 this.downloadsManager.onAction(action); 319 switch (action.type) { 320 case at.INIT: 321 this.init(); 322 break; 323 case at.SYSTEM_TICK: 324 case at.TOP_SITES_UPDATED: 325 this.fetchHighlights({ 326 broadcast: false, 327 isStartup: !!action.meta?.isStartup, 328 }); 329 break; 330 case at.PREF_CHANGED: 331 // Update existing pages when the user changes what should be shown 332 if (action.data.name.startsWith("section.highlights.include")) { 333 this.fetchHighlights({ broadcast: true }); 334 } 335 break; 336 case at.PLACES_HISTORY_CLEARED: 337 case at.PLACES_LINK_BLOCKED: 338 case at.DOWNLOAD_CHANGED: 339 case at.POCKET_LINK_DELETED_OR_ARCHIVED: 340 this.fetchHighlights({ broadcast: true }); 341 break; 342 case at.PLACES_LINKS_CHANGED: 343 case at.PLACES_SAVED_TO_POCKET: 344 this.linksCache.expire(); 345 this.fetchHighlights({ broadcast: false }); 346 break; 347 case at.UNINIT: 348 this.uninit(); 349 break; 350 } 351 } 352}; 353 354const EXPORTED_SYMBOLS = [ 355 "HighlightsFeed", 356 "SECTION_ID", 357 "MANY_EXTRA_LENGTH", 358 "SYNC_BOOKMARKS_FINISHED_EVENT", 359 "BOOKMARKS_RESTORE_SUCCESS_EVENT", 360 "BOOKMARKS_RESTORE_FAILED_EVENT", 361]; 362