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