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/Services.jsm");
7
8const {actionTypes: at} = ChromeUtils.import("resource://activity-stream/common/Actions.jsm", {});
9
10const {shortURL} = ChromeUtils.import("resource://activity-stream/lib/ShortURL.jsm", {});
11const {SectionsManager} = ChromeUtils.import("resource://activity-stream/lib/SectionsManager.jsm", {});
12const {TOP_SITES_DEFAULT_ROWS, TOP_SITES_MAX_SITES_PER_ROW} = ChromeUtils.import("resource://activity-stream/common/Reducers.jsm", {});
13const {Dedupe} = ChromeUtils.import("resource://activity-stream/common/Dedupe.jsm", {});
14
15ChromeUtils.defineModuleGetter(this, "filterAdult",
16  "resource://activity-stream/lib/FilterAdult.jsm");
17ChromeUtils.defineModuleGetter(this, "LinksCache",
18  "resource://activity-stream/lib/LinksCache.jsm");
19ChromeUtils.defineModuleGetter(this, "NewTabUtils",
20  "resource://gre/modules/NewTabUtils.jsm");
21ChromeUtils.defineModuleGetter(this, "Screenshots",
22  "resource://activity-stream/lib/Screenshots.jsm");
23ChromeUtils.defineModuleGetter(this, "PageThumbs",
24  "resource://gre/modules/PageThumbs.jsm");
25
26const HIGHLIGHTS_MAX_LENGTH = 9;
27const MANY_EXTRA_LENGTH = HIGHLIGHTS_MAX_LENGTH * 5 + TOP_SITES_DEFAULT_ROWS * TOP_SITES_MAX_SITES_PER_ROW;
28const SECTION_ID = "highlights";
29const SYNC_BOOKMARKS_FINISHED_EVENT = "weave:engine:sync:applied";
30const BOOKMARKS_RESTORE_SUCCESS_EVENT = "bookmarks-restore-success";
31const BOOKMARKS_RESTORE_FAILED_EVENT = "bookmarks-restore-failed";
32
33this.HighlightsFeed = class HighlightsFeed {
34  constructor() {
35    this.dedupe = new Dedupe(this._dedupeKey);
36    this.linksCache = new LinksCache(NewTabUtils.activityStreamLinks,
37      "getHighlights", ["image"]);
38    PageThumbs.addExpirationFilter(this);
39  }
40
41  _dedupeKey(site) {
42    // Treat bookmarks and pocket items as un-dedupable, otherwise show one of a url
43    return site && ((site.pocket_id || site.type === "bookmark") ? {} : site.url);
44  }
45
46  init() {
47    Services.obs.addObserver(this, SYNC_BOOKMARKS_FINISHED_EVENT);
48    Services.obs.addObserver(this, BOOKMARKS_RESTORE_SUCCESS_EVENT);
49    Services.obs.addObserver(this, BOOKMARKS_RESTORE_FAILED_EVENT);
50    SectionsManager.onceInitialized(this.postInit.bind(this));
51  }
52
53  postInit() {
54    SectionsManager.enableSection(SECTION_ID);
55    this.fetchHighlights({broadcast: true});
56  }
57
58  uninit() {
59    SectionsManager.disableSection(SECTION_ID);
60    PageThumbs.removeExpirationFilter(this);
61    Services.obs.removeObserver(this, SYNC_BOOKMARKS_FINISHED_EVENT);
62    Services.obs.removeObserver(this, BOOKMARKS_RESTORE_SUCCESS_EVENT);
63    Services.obs.removeObserver(this, BOOKMARKS_RESTORE_FAILED_EVENT);
64  }
65
66  observe(subject, topic, data) {
67    // When we receive a notification that a sync has happened for bookmarks,
68    // or Places finished importing or restoring bookmarks, refresh highlights
69    const manyBookmarksChanged =
70      (topic === SYNC_BOOKMARKS_FINISHED_EVENT && data === "bookmarks") ||
71      topic === BOOKMARKS_RESTORE_SUCCESS_EVENT ||
72      topic === BOOKMARKS_RESTORE_FAILED_EVENT;
73    if (manyBookmarksChanged) {
74      this.fetchHighlights({broadcast: true});
75    }
76  }
77
78  filterForThumbnailExpiration(callback) {
79    const state = this.store.getState().Sections.find(section => section.id === SECTION_ID);
80
81    callback(state && state.initialized ? state.rows.reduce((acc, site) => {
82      // Screenshots call in `fetchImage` will search for preview_image_url or
83      // fallback to URL, so we prevent both from being expired.
84      acc.push(site.url);
85      if (site.preview_image_url) {
86        acc.push(site.preview_image_url);
87      }
88      return acc;
89    }, []) : []);
90  }
91
92  /**
93   * Refresh the highlights data for content.
94   * @param {bool} options.broadcast Should the update be broadcasted.
95   */
96  async fetchHighlights(options = {}) {
97    // We need TopSites for deduping, so wait for TOP_SITES_UPDATED.
98    if (!this.store.getState().TopSites.initialized) {
99      return;
100    }
101
102    // We broadcast when we want to force an update, so get fresh links
103    if (options.broadcast) {
104      this.linksCache.expire();
105    }
106
107    // Request more than the expected length to allow for items being removed by
108    // deduping against Top Sites or multiple history from the same domain, etc.
109    const manyPages = await this.linksCache.request({
110      numItems: MANY_EXTRA_LENGTH,
111      excludePocket: !this.store.getState().Prefs.values["section.highlights.includePocket"]
112    });
113
114    // Remove adult highlights if we need to
115    const checkedAdult = this.store.getState().Prefs.values.filterAdult ?
116      filterAdult(manyPages) : manyPages;
117
118    // Remove any Highlights that are in Top Sites already
119    const [, deduped] = this.dedupe.group(this.store.getState().TopSites.rows, checkedAdult);
120
121    // Keep all "bookmark"s and at most one (most recent) "history" per host
122    const highlights = [];
123    const hosts = new Set();
124    for (const page of deduped) {
125      const hostname = shortURL(page);
126      // Skip this history page if we already something from the same host
127      if (page.type === "history" && hosts.has(hostname)) {
128        continue;
129      }
130
131      // If we already have the image for the card, use that immediately. Else
132      // asynchronously fetch the image.
133      if (!page.image) {
134        this.fetchImage(page);
135      }
136
137      // Adjust the type for 'history' items that are also 'bookmarked'
138      if (page.type === "history" && page.bookmarkGuid) {
139        page.type = "bookmark";
140      }
141
142      // We want the page, so update various fields for UI
143      Object.assign(page, {
144        hasImage: true, // We always have an image - fall back to a screenshot
145        hostname,
146        type: page.type,
147        pocket_id: page.pocket_id
148      });
149
150      // Add the "bookmark", "pocket", or not-skipped "history"
151      highlights.push(page);
152      hosts.add(hostname);
153
154      // Remove internal properties that might be updated after dispatch
155      delete page.__sharedCache;
156
157      // Skip the rest if we have enough items
158      if (highlights.length === HIGHLIGHTS_MAX_LENGTH) {
159        break;
160      }
161    }
162
163    const {initialized} = this.store.getState().Sections.find(section => section.id === SECTION_ID);
164    // Broadcast when required or if it is the first update.
165    const shouldBroadcast = options.broadcast || !initialized;
166
167    SectionsManager.updateSection(SECTION_ID, {rows: highlights}, shouldBroadcast);
168  }
169
170  /**
171   * Fetch an image for a given highlight and update the card with it. If no
172   * image is available then fallback to fetching a screenshot.
173   */
174  fetchImage(page) {
175    // Request a screenshot if we don't already have one pending
176    const {preview_image_url: imageUrl, url} = page;
177    Screenshots.maybeCacheScreenshot(page, imageUrl || url, "image", image => {
178      SectionsManager.updateSectionCard(SECTION_ID, url, {image}, true);
179    });
180  }
181
182  /**
183   * Deletes an item from a user's saved to Pocket feed and then refreshes highlights
184   * @param {int} itemID
185   *  The unique ID given by Pocket for that item; used to look the item up when deleting
186   */
187  async deleteFromPocket(itemID) {
188    try {
189      await NewTabUtils.activityStreamLinks.deletePocketEntry(itemID);
190      this.fetchHighlights({broadcast: true});
191    } catch (err) {
192      Cu.reportError(err);
193    }
194  }
195
196  /**
197   * Archives an item from a user's saved to Pocket feed and then refreshes highlights
198   * @param {int} itemID
199   *  The unique ID given by Pocket for that item; used to look the item up when archiving
200   */
201  async archiveFromPocket(itemID) {
202    try {
203      await NewTabUtils.activityStreamLinks.archivePocketEntry(itemID);
204      this.fetchHighlights({broadcast: true});
205    } catch (err) {
206      Cu.reportError(err);
207    }
208  }
209
210  onAction(action) {
211    switch (action.type) {
212      case at.INIT:
213        this.init();
214        break;
215      case at.SYSTEM_TICK:
216        this.fetchHighlights({broadcast: false});
217        break;
218      case at.MIGRATION_COMPLETED:
219      case at.PLACES_HISTORY_CLEARED:
220      case at.PLACES_LINKS_DELETED:
221      case at.PLACES_LINK_BLOCKED:
222        this.fetchHighlights({broadcast: true});
223        break;
224      case at.DELETE_FROM_POCKET:
225        this.deleteFromPocket(action.data.pocket_id);
226        break;
227      case at.ARCHIVE_FROM_POCKET:
228        this.archiveFromPocket(action.data.pocket_id);
229        break;
230      case at.PLACES_BOOKMARK_ADDED:
231      case at.PLACES_BOOKMARK_REMOVED:
232      case at.PLACES_SAVED_TO_POCKET:
233        this.linksCache.expire();
234        this.fetchHighlights({broadcast: false});
235        break;
236      case at.TOP_SITES_UPDATED:
237        this.fetchHighlights({broadcast: false});
238        break;
239      case at.UNINIT:
240        this.uninit();
241        break;
242    }
243  }
244};
245
246const EXPORTED_SYMBOLS = ["HighlightsFeed", "SECTION_ID", "MANY_EXTRA_LENGTH", "SYNC_BOOKMARKS_FINISHED_EVENT", "BOOKMARKS_RESTORE_SUCCESS_EVENT", "BOOKMARKS_RESTORE_FAILED_EVENT"];
247