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