summaryrefslogtreecommitdiffstats
path: root/browser/components/newtab/lib/HighlightsFeed.sys.mjs
blob: c603b886da3ed38c6976d894768f1315a865e05a (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

import { actionTypes as at } from "resource://activity-stream/common/Actions.sys.mjs";

import { shortURL } from "resource://activity-stream/lib/ShortURL.sys.mjs";
import {
  TOP_SITES_DEFAULT_ROWS,
  TOP_SITES_MAX_SITES_PER_ROW,
} from "resource://activity-stream/common/Reducers.sys.mjs";
import { Dedupe } from "resource://activity-stream/common/Dedupe.sys.mjs";

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  DownloadsManager: "resource://activity-stream/lib/DownloadsManager.sys.mjs",
  FilterAdult: "resource://activity-stream/lib/FilterAdult.sys.mjs",
  LinksCache: "resource://activity-stream/lib/LinksCache.sys.mjs",
  NewTabUtils: "resource://gre/modules/NewTabUtils.sys.mjs",
  PageThumbs: "resource://gre/modules/PageThumbs.sys.mjs",
  Screenshots: "resource://activity-stream/lib/Screenshots.sys.mjs",
  SectionsManager: "resource://activity-stream/lib/SectionsManager.sys.mjs",
});

const HIGHLIGHTS_MAX_LENGTH = 16;

export const MANY_EXTRA_LENGTH =
  HIGHLIGHTS_MAX_LENGTH * 5 +
  TOP_SITES_DEFAULT_ROWS * TOP_SITES_MAX_SITES_PER_ROW;

export const SECTION_ID = "highlights";
export const SYNC_BOOKMARKS_FINISHED_EVENT = "weave:engine:sync:applied";
export const BOOKMARKS_RESTORE_SUCCESS_EVENT = "bookmarks-restore-success";
export const BOOKMARKS_RESTORE_FAILED_EVENT = "bookmarks-restore-failed";
const RECENT_DOWNLOAD_THRESHOLD = 36 * 60 * 60 * 1000;

export class HighlightsFeed {
  constructor() {
    this.dedupe = new Dedupe(this._dedupeKey);
    this.linksCache = new lazy.LinksCache(
      lazy.NewTabUtils.activityStreamLinks,
      "getHighlights",
      ["image"]
    );
    lazy.PageThumbs.addExpirationFilter(this);
    this.downloadsManager = new lazy.DownloadsManager();
  }

  _dedupeKey(site) {
    // Treat bookmarks, pocket, and downloaded items as un-dedupable, otherwise show one of a url
    return (
      site &&
      (site.pocket_id || site.type === "bookmark" || site.type === "download"
        ? {}
        : site.url)
    );
  }

  init() {
    Services.obs.addObserver(this, SYNC_BOOKMARKS_FINISHED_EVENT);
    Services.obs.addObserver(this, BOOKMARKS_RESTORE_SUCCESS_EVENT);
    Services.obs.addObserver(this, BOOKMARKS_RESTORE_FAILED_EVENT);
    lazy.SectionsManager.onceInitialized(this.postInit.bind(this));
  }

  postInit() {
    lazy.SectionsManager.enableSection(SECTION_ID, true /* isStartup */);
    this.fetchHighlights({ broadcast: true, isStartup: true });
    this.downloadsManager.init(this.store);
  }

  uninit() {
    lazy.SectionsManager.disableSection(SECTION_ID);
    lazy.PageThumbs.removeExpirationFilter(this);
    Services.obs.removeObserver(this, SYNC_BOOKMARKS_FINISHED_EVENT);
    Services.obs.removeObserver(this, BOOKMARKS_RESTORE_SUCCESS_EVENT);
    Services.obs.removeObserver(this, BOOKMARKS_RESTORE_FAILED_EVENT);
  }

  observe(subject, topic, data) {
    // When we receive a notification that a sync has happened for bookmarks,
    // or Places finished importing or restoring bookmarks, refresh highlights
    const manyBookmarksChanged =
      (topic === SYNC_BOOKMARKS_FINISHED_EVENT && data === "bookmarks") ||
      topic === BOOKMARKS_RESTORE_SUCCESS_EVENT ||
      topic === BOOKMARKS_RESTORE_FAILED_EVENT;
    if (manyBookmarksChanged) {
      this.fetchHighlights({ broadcast: true });
    }
  }

  filterForThumbnailExpiration(callback) {
    const state = this.store
      .getState()
      .Sections.find(section => section.id === SECTION_ID);

    callback(
      state && state.initialized
        ? state.rows.reduce((acc, site) => {
            // Screenshots call in `fetchImage` will search for preview_image_url or
            // fallback to URL, so we prevent both from being expired.
            acc.push(site.url);
            if (site.preview_image_url) {
              acc.push(site.preview_image_url);
            }
            return acc;
          }, [])
        : []
    );
  }

  /**
   * Chronologically sort highlights of all types except 'visited'. Then just append
   * the rest at the end of highlights.
   * @param {Array} pages The full list of links to order.
   * @return {Array} A sorted array of highlights
   */
  _orderHighlights(pages) {
    const splitHighlights = { chronologicalCandidates: [], visited: [] };
    for (let page of pages) {
      if (page.type === "history") {
        splitHighlights.visited.push(page);
      } else {
        splitHighlights.chronologicalCandidates.push(page);
      }
    }

    return splitHighlights.chronologicalCandidates
      .sort((a, b) => a.date_added < b.date_added)
      .concat(splitHighlights.visited);
  }

  /**
   * Refresh the highlights data for content.
   * @param {bool} options.broadcast Should the update be broadcasted.
   */
  async fetchHighlights(options = {}) {
    // If TopSites are enabled we need them for deduping, so wait for
    // TOP_SITES_UPDATED. We also need the section to be registered to update
    // state, so wait for postInit triggered by lazy.SectionsManager initializing.
    if (
      (!this.store.getState().TopSites.initialized &&
        this.store.getState().Prefs.values["feeds.system.topsites"] &&
        this.store.getState().Prefs.values["feeds.topsites"]) ||
      !this.store.getState().Sections.length
    ) {
      return;
    }

    // We broadcast when we want to force an update, so get fresh links
    if (options.broadcast) {
      this.linksCache.expire();
    }

    // Request more than the expected length to allow for items being removed by
    // deduping against Top Sites or multiple history from the same domain, etc.
    const manyPages = await this.linksCache.request({
      numItems: MANY_EXTRA_LENGTH,
      excludeBookmarks:
        !this.store.getState().Prefs.values[
          "section.highlights.includeBookmarks"
        ],
      excludeHistory:
        !this.store.getState().Prefs.values[
          "section.highlights.includeVisited"
        ],
      excludePocket:
        !this.store.getState().Prefs.values["section.highlights.includePocket"],
    });

    if (
      this.store.getState().Prefs.values["section.highlights.includeDownloads"]
    ) {
      // We only want 1 download that is less than 36 hours old, and the file currently exists
      let results = await this.downloadsManager.getDownloads(
        RECENT_DOWNLOAD_THRESHOLD,
        { numItems: 1, onlySucceeded: true, onlyExists: true }
      );
      if (results.length) {
        // We only want 1 download, the most recent one
        manyPages.push({
          ...results[0],
          type: "download",
        });
      }
    }

    const orderedPages = this._orderHighlights(manyPages);

    // Remove adult highlights if we need to
    const checkedAdult = lazy.FilterAdult.filter(orderedPages);

    // Remove any Highlights that are in Top Sites already
    const [, deduped] = this.dedupe.group(
      this.store.getState().TopSites.rows,
      checkedAdult
    );

    // Keep all "bookmark"s and at most one (most recent) "history" per host
    const highlights = [];
    const hosts = new Set();
    for (const page of deduped) {
      const hostname = shortURL(page);
      // Skip this history page if we already something from the same host
      if (page.type === "history" && hosts.has(hostname)) {
        continue;
      }

      // If we already have the image for the card, use that immediately. Else
      // asynchronously fetch the image. NEVER fetch a screenshot for downloads
      if (!page.image && page.type !== "download") {
        this.fetchImage(page, options.isStartup);
      }

      // Adjust the type for 'history' items that are also 'bookmarked' when we
      // want to include bookmarks
      if (
        page.type === "history" &&
        page.bookmarkGuid &&
        this.store.getState().Prefs.values[
          "section.highlights.includeBookmarks"
        ]
      ) {
        page.type = "bookmark";
      }

      // We want the page, so update various fields for UI
      Object.assign(page, {
        hasImage: page.type !== "download", // Downloads do not have an image - all else types fall back to a screenshot
        hostname,
        type: page.type,
        pocket_id: page.pocket_id,
      });

      // Add the "bookmark", "pocket", or not-skipped "history"
      highlights.push(page);
      hosts.add(hostname);

      // Remove internal properties that might be updated after dispatch
      delete page.__sharedCache;

      // Skip the rest if we have enough items
      if (highlights.length === HIGHLIGHTS_MAX_LENGTH) {
        break;
      }
    }

    const { initialized } = this.store
      .getState()
      .Sections.find(section => section.id === SECTION_ID);
    // Broadcast when required or if it is the first update.
    const shouldBroadcast = options.broadcast || !initialized;

    lazy.SectionsManager.updateSection(
      SECTION_ID,
      { rows: highlights },
      shouldBroadcast,
      options.isStartup
    );
  }

  /**
   * Fetch an image for a given highlight and update the card with it. If no
   * image is available then fallback to fetching a screenshot.
   */
  fetchImage(page, isStartup = false) {
    // Request a screenshot if we don't already have one pending
    const { preview_image_url: imageUrl, url } = page;
    return lazy.Screenshots.maybeCacheScreenshot(
      page,
      imageUrl || url,
      "image",
      image => {
        lazy.SectionsManager.updateSectionCard(
          SECTION_ID,
          url,
          { image },
          true,
          isStartup
        );
      }
    );
  }

  onAction(action) {
    // Relay the downloads actions to DownloadsManager - it is a child of HighlightsFeed
    this.downloadsManager.onAction(action);
    switch (action.type) {
      case at.INIT:
        this.init();
        break;
      case at.SYSTEM_TICK:
      case at.TOP_SITES_UPDATED:
        this.fetchHighlights({
          broadcast: false,
          isStartup: !!action.meta?.isStartup,
        });
        break;
      case at.PREF_CHANGED:
        // Update existing pages when the user changes what should be shown
        if (action.data.name.startsWith("section.highlights.include")) {
          this.fetchHighlights({ broadcast: true });
        }
        break;
      case at.PLACES_HISTORY_CLEARED:
      case at.PLACES_LINK_BLOCKED:
      case at.DOWNLOAD_CHANGED:
      case at.POCKET_LINK_DELETED_OR_ARCHIVED:
        this.fetchHighlights({ broadcast: true });
        break;
      case at.PLACES_LINKS_CHANGED:
      case at.PLACES_SAVED_TO_POCKET:
        this.linksCache.expire();
        this.fetchHighlights({ broadcast: false });
        break;
      case at.UNINIT:
        this.uninit();
        break;
    }
  }
}