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 6ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm"); 7 8const {actionCreators: ac, actionTypes: at} = ChromeUtils.import("resource://activity-stream/common/Actions.jsm", {}); 9const {TippyTopProvider} = ChromeUtils.import("resource://activity-stream/lib/TippyTopProvider.jsm", {}); 10const {insertPinned, TOP_SITES_MAX_SITES_PER_ROW} = ChromeUtils.import("resource://activity-stream/common/Reducers.jsm", {}); 11const {Dedupe} = ChromeUtils.import("resource://activity-stream/common/Dedupe.jsm", {}); 12const {shortURL} = ChromeUtils.import("resource://activity-stream/lib/ShortURL.jsm", {}); 13 14ChromeUtils.defineModuleGetter(this, "filterAdult", 15 "resource://activity-stream/lib/FilterAdult.jsm"); 16ChromeUtils.defineModuleGetter(this, "LinksCache", 17 "resource://activity-stream/lib/LinksCache.jsm"); 18ChromeUtils.defineModuleGetter(this, "NewTabUtils", 19 "resource://gre/modules/NewTabUtils.jsm"); 20ChromeUtils.defineModuleGetter(this, "Screenshots", 21 "resource://activity-stream/lib/Screenshots.jsm"); 22ChromeUtils.defineModuleGetter(this, "PageThumbs", 23 "resource://gre/modules/PageThumbs.jsm"); 24 25const DEFAULT_SITES_PREF = "default.sites"; 26const DEFAULT_TOP_SITES = []; 27const FRECENCY_THRESHOLD = 100 + 1; // 1 visit (skip first-run/one-time pages) 28const MIN_FAVICON_SIZE = 96; 29const CACHED_LINK_PROPS_TO_MIGRATE = ["screenshot"]; 30const PINNED_FAVICON_PROPS_TO_MIGRATE = ["favicon", "faviconRef", "faviconSize"]; 31 32this.TopSitesFeed = class TopSitesFeed { 33 constructor() { 34 this._tippyTopProvider = new TippyTopProvider(); 35 this.dedupe = new Dedupe(this._dedupeKey); 36 this.frecentCache = new LinksCache(NewTabUtils.activityStreamLinks, 37 "getTopSites", CACHED_LINK_PROPS_TO_MIGRATE, (oldOptions, newOptions) => 38 // Refresh if no old options or requesting more items 39 !(oldOptions.numItems >= newOptions.numItems)); 40 this.pinnedCache = new LinksCache(NewTabUtils.pinnedLinks, "links", 41 [...CACHED_LINK_PROPS_TO_MIGRATE, ...PINNED_FAVICON_PROPS_TO_MIGRATE]); 42 PageThumbs.addExpirationFilter(this); 43 } 44 45 uninit() { 46 PageThumbs.removeExpirationFilter(this); 47 } 48 49 _dedupeKey(site) { 50 return site && site.hostname; 51 } 52 53 refreshDefaults(sites) { 54 // Clear out the array of any previous defaults 55 DEFAULT_TOP_SITES.length = 0; 56 57 // Add default sites if any based on the pref 58 if (sites) { 59 for (const url of sites.split(",")) { 60 const site = { 61 isDefault: true, 62 url 63 }; 64 site.hostname = shortURL(site); 65 DEFAULT_TOP_SITES.push(site); 66 } 67 } 68 } 69 70 filterForThumbnailExpiration(callback) { 71 const {rows} = this.store.getState().TopSites; 72 callback(rows.map(site => site.url)); 73 } 74 75 async getLinksWithDefaults(action) { 76 // Get at least 2 rows so toggling between 1 and 2 rows has sites 77 const numItems = Math.max(this.store.getState().Prefs.values.topSitesRows, 2) * TOP_SITES_MAX_SITES_PER_ROW; 78 const frecent = (await this.frecentCache.request({ 79 numItems, 80 topsiteFrecency: FRECENCY_THRESHOLD 81 })).map(link => Object.assign({}, link, {hostname: shortURL(link)})); 82 83 // Remove any defaults that have been blocked 84 const notBlockedDefaultSites = DEFAULT_TOP_SITES.filter(link => 85 !NewTabUtils.blockedLinks.isBlocked({url: link.url})); 86 87 // Get pinned links augmented with desired properties 88 const plainPinned = await this.pinnedCache.request(); 89 const pinned = await Promise.all(plainPinned.map(async link => { 90 if (!link) { 91 return link; 92 } 93 94 // Copy all properties from a frecent link and add more 95 const finder = other => other.url === link.url; 96 97 // If the link is a frecent site, do not copy over 'isDefault', else check 98 // if the site is a default site 99 const copy = Object.assign({}, frecent.find(finder) || 100 {isDefault: !!notBlockedDefaultSites.find(finder)}, link, {hostname: shortURL(link)}); 101 102 // Add in favicons if we don't already have it 103 if (!copy.favicon) { 104 try { 105 NewTabUtils.activityStreamProvider._faviconBytesToDataURI(await 106 NewTabUtils.activityStreamProvider._addFavicons([copy])); 107 108 for (const prop of PINNED_FAVICON_PROPS_TO_MIGRATE) { 109 copy.__sharedCache.updateLink(prop, copy[prop]); 110 } 111 } catch (e) { 112 // Some issue with favicon, so just continue without one 113 } 114 } 115 116 return copy; 117 })); 118 119 // Remove any duplicates from frecent and default sites 120 const [, dedupedFrecent, dedupedDefaults] = this.dedupe.group( 121 pinned, frecent, notBlockedDefaultSites); 122 const dedupedUnpinned = [...dedupedFrecent, ...dedupedDefaults]; 123 124 // Remove adult sites if we need to 125 const checkedAdult = this.store.getState().Prefs.values.filterAdult ? 126 filterAdult(dedupedUnpinned) : dedupedUnpinned; 127 128 // Insert the original pinned sites into the deduped frecent and defaults 129 const withPinned = insertPinned(checkedAdult, pinned).slice(0, numItems); 130 131 // Now, get a tippy top icon, a rich icon, or screenshot for every item 132 for (const link of withPinned) { 133 if (link) { 134 this._fetchIcon(link); 135 136 // Remove internal properties that might be updated after dispatch 137 delete link.__sharedCache; 138 } 139 } 140 141 return withPinned; 142 } 143 144 /** 145 * Refresh the top sites data for content. 146 * @param {bool} options.broadcast Should the update be broadcasted. 147 */ 148 async refresh(options = {}) { 149 if (!this._tippyTopProvider.initialized) { 150 await this._tippyTopProvider.init(); 151 } 152 153 const links = await this.getLinksWithDefaults(); 154 const newAction = {type: at.TOP_SITES_UPDATED, data: links}; 155 if (options.broadcast) { 156 // Broadcast an update to all open content pages 157 this.store.dispatch(ac.BroadcastToContent(newAction)); 158 } else { 159 // Don't broadcast only update the state and update the preloaded tab. 160 this.store.dispatch(ac.AlsoToPreloaded(newAction)); 161 } 162 } 163 164 /** 165 * Get an image for the link preferring tippy top, rich favicon, screenshots. 166 */ 167 async _fetchIcon(link) { 168 // Nothing to do if we already have a rich icon from the page 169 if (link.favicon && link.faviconSize >= MIN_FAVICON_SIZE) { 170 return; 171 } 172 173 // Nothing more to do if we can use a default tippy top icon 174 this._tippyTopProvider.processSite(link); 175 if (link.tippyTopIcon) { 176 return; 177 } 178 179 // Make a request for a better icon 180 this._requestRichIcon(link.url); 181 182 // Also request a screenshot if we don't have one yet 183 if (!link.screenshot) { 184 const {url} = link; 185 await Screenshots.maybeCacheScreenshot(link, url, "screenshot", 186 screenshot => this.store.dispatch(ac.BroadcastToContent({ 187 data: {screenshot, url}, 188 type: at.SCREENSHOT_UPDATED 189 }))); 190 } 191 } 192 193 _requestRichIcon(url) { 194 this.store.dispatch({ 195 type: at.RICH_ICON_MISSING, 196 data: {url} 197 }); 198 } 199 200 /** 201 * Inform others that top sites data has been updated due to pinned changes. 202 */ 203 _broadcastPinnedSitesUpdated() { 204 // Pinned data changed, so make sure we get latest 205 this.pinnedCache.expire(); 206 207 // Refresh to update pinned sites with screenshots, trigger deduping, etc. 208 this.refresh({broadcast: true}); 209 } 210 211 /** 212 * Pin a site at a specific position saving only the desired keys. 213 */ 214 _pinSiteAt({label, url}, index) { 215 const toPin = {url}; 216 if (label) { 217 toPin.label = label; 218 } 219 NewTabUtils.pinnedLinks.pin(toPin, index); 220 } 221 222 /** 223 * Handle a pin action of a site to a position. 224 */ 225 pin(action) { 226 const {site, index} = action.data; 227 // If valid index provided, pin at that position 228 if (index >= 0) { 229 this._pinSiteAt(site, index); 230 this._broadcastPinnedSitesUpdated(); 231 } else { 232 this.insert(action); 233 } 234 } 235 236 /** 237 * Handle an unpin action of a site. 238 */ 239 unpin(action) { 240 const {site} = action.data; 241 NewTabUtils.pinnedLinks.unpin(site); 242 this._broadcastPinnedSitesUpdated(); 243 } 244 245 /** 246 * Insert a site to pin at a position shifting over any other pinned sites. 247 */ 248 _insertPin(site, index, draggedFromIndex) { 249 // Don't insert any pins past the end of the visible top sites. Otherwise, 250 // we can end up with a bunch of pinned sites that can never be unpinned again 251 // from the UI. 252 const topSitesCount = this.store.getState().Prefs.values.topSitesRows * TOP_SITES_MAX_SITES_PER_ROW; 253 if (index >= topSitesCount) { 254 return; 255 } 256 257 let pinned = NewTabUtils.pinnedLinks.links; 258 if (!pinned[index]) { 259 this._pinSiteAt(site, index); 260 } else { 261 pinned[draggedFromIndex] = null; 262 // Find the hole to shift the pinned site(s) towards. We shift towards the 263 // hole left by the site being dragged. 264 let holeIndex = index; 265 const indexStep = index > draggedFromIndex ? -1 : 1; 266 while (pinned[holeIndex]) { 267 holeIndex += indexStep; 268 } 269 if (holeIndex >= topSitesCount || holeIndex < 0) { 270 // There are no holes, so we will effectively unpin the last slot and shifting 271 // towards it. This only happens when adding a new top site to an already 272 // fully pinned grid. 273 holeIndex = topSitesCount - 1; 274 } 275 276 // Shift towards the hole. 277 const shiftingStep = holeIndex > index ? -1 : 1; 278 while (holeIndex !== index) { 279 const nextIndex = holeIndex + shiftingStep; 280 this._pinSiteAt(pinned[nextIndex], holeIndex); 281 holeIndex = nextIndex; 282 } 283 this._pinSiteAt(site, index); 284 } 285 } 286 287 /** 288 * Handle an insert (drop/add) action of a site. 289 */ 290 insert(action) { 291 let {index} = action.data; 292 // Treat invalid pin index values (e.g., -1, undefined) as insert in the first position 293 if (!(index > 0)) { 294 index = 0; 295 } 296 297 // Inserting a top site pins it in the specified slot, pushing over any link already 298 // pinned in the slot (unless it's the last slot, then it replaces). 299 this._insertPin( 300 action.data.site, index, 301 action.data.draggedFromIndex !== undefined ? action.data.draggedFromIndex : this.store.getState().Prefs.values.topSitesRows * TOP_SITES_MAX_SITES_PER_ROW); 302 this._broadcastPinnedSitesUpdated(); 303 } 304 305 onAction(action) { 306 switch (action.type) { 307 case at.INIT: 308 this.refresh({broadcast: true}); 309 break; 310 case at.SYSTEM_TICK: 311 this.refresh({broadcast: false}); 312 break; 313 // All these actions mean we need new top sites 314 case at.MIGRATION_COMPLETED: 315 case at.PLACES_HISTORY_CLEARED: 316 case at.PLACES_LINKS_DELETED: 317 this.frecentCache.expire(); 318 this.refresh({broadcast: true}); 319 break; 320 case at.PLACES_LINK_BLOCKED: 321 this.frecentCache.expire(); 322 this.pinnedCache.expire(); 323 this.refresh({broadcast: true}); 324 break; 325 case at.PREF_CHANGED: 326 if (action.data.name === DEFAULT_SITES_PREF) { 327 this.refreshDefaults(action.data.value); 328 } 329 break; 330 case at.PREFS_INITIAL_VALUES: 331 this.refreshDefaults(action.data[DEFAULT_SITES_PREF]); 332 break; 333 case at.TOP_SITES_PIN: 334 this.pin(action); 335 break; 336 case at.TOP_SITES_UNPIN: 337 this.unpin(action); 338 break; 339 case at.TOP_SITES_INSERT: 340 this.insert(action); 341 break; 342 case at.UNINIT: 343 this.uninit(); 344 break; 345 } 346 } 347}; 348 349this.DEFAULT_TOP_SITES = DEFAULT_TOP_SITES; 350const EXPORTED_SYMBOLS = ["TopSitesFeed", "DEFAULT_TOP_SITES"]; 351