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