Source code

Revision control

Copy as Markdown

Other Tools

/* 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.mjs";
import {
TOP_SITES_DEFAULT_ROWS,
TOP_SITES_MAX_SITES_PER_ROW,
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
});
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;
}
}
}