433 lines
15 KiB
JavaScript
433 lines
15 KiB
JavaScript
/* 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/. */
|
|
"use strict";
|
|
|
|
ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
|
|
|
|
const {actionCreators: ac, actionTypes: at} = ChromeUtils.import("resource://activity-stream/common/Actions.jsm", {});
|
|
const {TippyTopProvider} = ChromeUtils.import("resource://activity-stream/lib/TippyTopProvider.jsm", {});
|
|
const {insertPinned, TOP_SITES_MAX_SITES_PER_ROW} = ChromeUtils.import("resource://activity-stream/common/Reducers.jsm", {});
|
|
const {Dedupe} = ChromeUtils.import("resource://activity-stream/common/Dedupe.jsm", {});
|
|
const {shortURL} = ChromeUtils.import("resource://activity-stream/lib/ShortURL.jsm", {});
|
|
const {ActivityStreamStorage, getDefaultOptions} = ChromeUtils.import("resource://activity-stream/lib/ActivityStreamStorage.jsm", {});
|
|
|
|
ChromeUtils.defineModuleGetter(this, "filterAdult",
|
|
"resource://activity-stream/lib/FilterAdult.jsm");
|
|
ChromeUtils.defineModuleGetter(this, "LinksCache",
|
|
"resource://activity-stream/lib/LinksCache.jsm");
|
|
ChromeUtils.defineModuleGetter(this, "NewTabUtils",
|
|
"resource://gre/modules/NewTabUtils.jsm");
|
|
ChromeUtils.defineModuleGetter(this, "Screenshots",
|
|
"resource://activity-stream/lib/Screenshots.jsm");
|
|
ChromeUtils.defineModuleGetter(this, "PageThumbs",
|
|
"resource://gre/modules/PageThumbs.jsm");
|
|
|
|
const DEFAULT_SITES_PREF = "default.sites";
|
|
const DEFAULT_TOP_SITES = [];
|
|
const FRECENCY_THRESHOLD = 100 + 1; // 1 visit (skip first-run/one-time pages)
|
|
const MIN_FAVICON_SIZE = 96;
|
|
const CACHED_LINK_PROPS_TO_MIGRATE = ["screenshot", "customScreenshot"];
|
|
const PINNED_FAVICON_PROPS_TO_MIGRATE = ["favicon", "faviconRef", "faviconSize"];
|
|
const SECTION_ID = "topsites";
|
|
|
|
this.TopSitesFeed = class TopSitesFeed {
|
|
constructor() {
|
|
this._tippyTopProvider = new TippyTopProvider();
|
|
this.dedupe = new Dedupe(this._dedupeKey);
|
|
this.frecentCache = new LinksCache(NewTabUtils.activityStreamLinks,
|
|
"getTopSites", CACHED_LINK_PROPS_TO_MIGRATE, (oldOptions, newOptions) =>
|
|
// Refresh if no old options or requesting more items
|
|
!(oldOptions.numItems >= newOptions.numItems));
|
|
this.pinnedCache = new LinksCache(NewTabUtils.pinnedLinks, "links",
|
|
[...CACHED_LINK_PROPS_TO_MIGRATE, ...PINNED_FAVICON_PROPS_TO_MIGRATE]);
|
|
PageThumbs.addExpirationFilter(this);
|
|
this._storage = new ActivityStreamStorage("sectionPrefs");
|
|
}
|
|
|
|
uninit() {
|
|
PageThumbs.removeExpirationFilter(this);
|
|
}
|
|
|
|
_dedupeKey(site) {
|
|
return site && site.hostname;
|
|
}
|
|
|
|
refreshDefaults(sites) {
|
|
// Clear out the array of any previous defaults
|
|
DEFAULT_TOP_SITES.length = 0;
|
|
|
|
// Add default sites if any based on the pref
|
|
if (sites) {
|
|
for (const url of sites.split(",")) {
|
|
const site = {
|
|
isDefault: true,
|
|
url
|
|
};
|
|
site.hostname = shortURL(site);
|
|
DEFAULT_TOP_SITES.push(site);
|
|
}
|
|
}
|
|
}
|
|
|
|
filterForThumbnailExpiration(callback) {
|
|
const {rows} = this.store.getState().TopSites;
|
|
callback(rows.reduce((acc, site) => {
|
|
acc.push(site.url);
|
|
if (site.customScreenshotURL) {
|
|
acc.push(site.customScreenshotURL);
|
|
}
|
|
return acc;
|
|
}, []));
|
|
}
|
|
|
|
async getLinksWithDefaults() {
|
|
// Get at least 2 rows so toggling between 1 and 2 rows has sites
|
|
const numItems = Math.max(this.store.getState().Prefs.values.topSitesRows, 2) * TOP_SITES_MAX_SITES_PER_ROW;
|
|
const frecent = (await this.frecentCache.request({
|
|
numItems,
|
|
topsiteFrecency: FRECENCY_THRESHOLD
|
|
})).map(link => Object.assign({}, link, {hostname: shortURL(link)}));
|
|
|
|
// Remove any defaults that have been blocked
|
|
const notBlockedDefaultSites = DEFAULT_TOP_SITES.filter(link =>
|
|
!NewTabUtils.blockedLinks.isBlocked({url: link.url}));
|
|
|
|
// Get pinned links augmented with desired properties
|
|
const plainPinned = await this.pinnedCache.request();
|
|
const pinned = await Promise.all(plainPinned.map(async link => {
|
|
if (!link) {
|
|
return link;
|
|
}
|
|
|
|
// Copy all properties from a frecent link and add more
|
|
const finder = other => other.url === link.url;
|
|
|
|
// Remove frecent link's screenshot if pinned link has a custom one
|
|
const frecentSite = frecent.find(finder);
|
|
if (frecentSite && link.customScreenshotURL) {
|
|
delete frecentSite.screenshot;
|
|
}
|
|
// If the link is a frecent site, do not copy over 'isDefault', else check
|
|
// if the site is a default site
|
|
const copy = Object.assign({}, frecentSite ||
|
|
{isDefault: !!notBlockedDefaultSites.find(finder)}, link, {hostname: shortURL(link)});
|
|
|
|
// Add in favicons if we don't already have it
|
|
if (!copy.favicon) {
|
|
try {
|
|
NewTabUtils.activityStreamProvider._faviconBytesToDataURI(await
|
|
NewTabUtils.activityStreamProvider._addFavicons([copy]));
|
|
|
|
for (const prop of PINNED_FAVICON_PROPS_TO_MIGRATE) {
|
|
copy.__sharedCache.updateLink(prop, copy[prop]);
|
|
}
|
|
} catch (e) {
|
|
// Some issue with favicon, so just continue without one
|
|
}
|
|
}
|
|
|
|
return copy;
|
|
}));
|
|
|
|
// Remove any duplicates from frecent and default sites
|
|
const [, dedupedFrecent, dedupedDefaults] = this.dedupe.group(
|
|
pinned, frecent, notBlockedDefaultSites);
|
|
const dedupedUnpinned = [...dedupedFrecent, ...dedupedDefaults];
|
|
|
|
// Remove adult sites if we need to
|
|
const checkedAdult = this.store.getState().Prefs.values.filterAdult ?
|
|
filterAdult(dedupedUnpinned) : dedupedUnpinned;
|
|
|
|
// Insert the original pinned sites into the deduped frecent and defaults
|
|
const withPinned = insertPinned(checkedAdult, pinned).slice(0, numItems);
|
|
|
|
// Now, get a tippy top icon, a rich icon, or screenshot for every item
|
|
for (const link of withPinned) {
|
|
if (link) {
|
|
// If there is a custom screenshot this is the only image we display
|
|
if (link.customScreenshotURL) {
|
|
this._fetchScreenshot(link, link.customScreenshotURL);
|
|
} else {
|
|
this._fetchIcon(link);
|
|
}
|
|
|
|
// Remove internal properties that might be updated after dispatch
|
|
delete link.__sharedCache;
|
|
}
|
|
}
|
|
|
|
return withPinned;
|
|
}
|
|
|
|
/**
|
|
* Refresh the top sites data for content.
|
|
* @param {bool} options.broadcast Should the update be broadcasted.
|
|
*/
|
|
async refresh(options = {}) {
|
|
if (!this._tippyTopProvider.initialized) {
|
|
await this._tippyTopProvider.init();
|
|
}
|
|
|
|
const links = await this.getLinksWithDefaults();
|
|
const newAction = {type: at.TOP_SITES_UPDATED, data: {links}};
|
|
|
|
const storedPrefs = await this._storage.get(SECTION_ID) || {};
|
|
newAction.data.pref = getDefaultOptions(storedPrefs);
|
|
|
|
if (options.broadcast) {
|
|
// Broadcast an update to all open content pages
|
|
this.store.dispatch(ac.BroadcastToContent(newAction));
|
|
} else {
|
|
// Don't broadcast only update the state and update the preloaded tab.
|
|
this.store.dispatch(ac.AlsoToPreloaded(newAction));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get an image for the link preferring tippy top, rich favicon, screenshots.
|
|
*/
|
|
async _fetchIcon(link) {
|
|
// Nothing to do if we already have a rich icon from the page
|
|
if (link.favicon && link.faviconSize >= MIN_FAVICON_SIZE) {
|
|
return;
|
|
}
|
|
|
|
// Nothing more to do if we can use a default tippy top icon
|
|
this._tippyTopProvider.processSite(link);
|
|
if (link.tippyTopIcon) {
|
|
return;
|
|
}
|
|
|
|
// Make a request for a better icon
|
|
this._requestRichIcon(link.url);
|
|
|
|
// Also request a screenshot if we don't have one yet
|
|
await this._fetchScreenshot(link, link.url);
|
|
}
|
|
|
|
/**
|
|
* Fetch, cache and broadcast a screenshot for a specific topsite.
|
|
* @param link cached topsite object
|
|
* @param url where to fetch the image from
|
|
*/
|
|
async _fetchScreenshot(link, url) {
|
|
if (link.screenshot) {
|
|
return;
|
|
}
|
|
await Screenshots.maybeCacheScreenshot(link, url, "screenshot",
|
|
screenshot => this.store.dispatch(ac.BroadcastToContent({
|
|
data: {screenshot, url: link.url},
|
|
type: at.SCREENSHOT_UPDATED
|
|
})));
|
|
}
|
|
|
|
/**
|
|
* Dispatch screenshot preview to target or notify if request failed.
|
|
* @param customScreenshotURL {string} The URL used to capture the screenshot
|
|
* @param target {string} Id of content process where to dispatch the result
|
|
*/
|
|
async getScreenshotPreview(url, target) {
|
|
const preview = await Screenshots.getScreenshotForURL(url) || "";
|
|
this.store.dispatch(ac.OnlyToOneContent({
|
|
data: {url, preview},
|
|
type: at.PREVIEW_RESPONSE
|
|
}, target));
|
|
}
|
|
|
|
_requestRichIcon(url) {
|
|
this.store.dispatch({
|
|
type: at.RICH_ICON_MISSING,
|
|
data: {url}
|
|
});
|
|
}
|
|
|
|
updateSectionPrefs(collapsed) {
|
|
this.store.dispatch(ac.BroadcastToContent({type: at.TOP_SITES_PREFS_UPDATED, data: {pref: collapsed}}));
|
|
}
|
|
|
|
/**
|
|
* Inform others that top sites data has been updated due to pinned changes.
|
|
*/
|
|
_broadcastPinnedSitesUpdated() {
|
|
// Pinned data changed, so make sure we get latest
|
|
this.pinnedCache.expire();
|
|
|
|
// Refresh to update pinned sites with screenshots, trigger deduping, etc.
|
|
this.refresh({broadcast: true});
|
|
}
|
|
|
|
/**
|
|
* Pin a site at a specific position saving only the desired keys.
|
|
* @param customScreenshotURL {string} User set URL of preview image for site
|
|
* @param label {string} User set string of custom site name
|
|
*/
|
|
async _pinSiteAt({customScreenshotURL, label, url}, index) {
|
|
const toPin = {url};
|
|
if (label) {
|
|
toPin.label = label;
|
|
}
|
|
if (customScreenshotURL) {
|
|
toPin.customScreenshotURL = customScreenshotURL;
|
|
}
|
|
NewTabUtils.pinnedLinks.pin(toPin, index);
|
|
|
|
await this._clearLinkCustomScreenshot({customScreenshotURL, url});
|
|
}
|
|
|
|
async _clearLinkCustomScreenshot(site) {
|
|
// If screenshot url changed or was removed we need to update the cached link obj
|
|
if (site.customScreenshotURL !== undefined) {
|
|
const pinned = await this.pinnedCache.request();
|
|
const link = pinned.find(pin => pin && pin.url === site.url);
|
|
if (link && link.customScreenshotURL !== site.customScreenshotURL) {
|
|
link.__sharedCache.updateLink("screenshot", undefined);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle a pin action of a site to a position.
|
|
*/
|
|
async pin(action) {
|
|
const {site, index} = action.data;
|
|
// If valid index provided, pin at that position
|
|
if (index >= 0) {
|
|
await this._pinSiteAt(site, index);
|
|
this._broadcastPinnedSitesUpdated();
|
|
} else {
|
|
this.insert(action);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle an unpin action of a site.
|
|
*/
|
|
unpin(action) {
|
|
const {site} = action.data;
|
|
NewTabUtils.pinnedLinks.unpin(site);
|
|
this._broadcastPinnedSitesUpdated();
|
|
}
|
|
|
|
/**
|
|
* Insert a site to pin at a position shifting over any other pinned sites.
|
|
*/
|
|
_insertPin(site, index, draggedFromIndex) {
|
|
// Don't insert any pins past the end of the visible top sites. Otherwise,
|
|
// we can end up with a bunch of pinned sites that can never be unpinned again
|
|
// from the UI.
|
|
const topSitesCount = this.store.getState().Prefs.values.topSitesRows * TOP_SITES_MAX_SITES_PER_ROW;
|
|
if (index >= topSitesCount) {
|
|
return;
|
|
}
|
|
|
|
let pinned = NewTabUtils.pinnedLinks.links;
|
|
if (!pinned[index]) {
|
|
this._pinSiteAt(site, index);
|
|
} else {
|
|
pinned[draggedFromIndex] = null;
|
|
// Find the hole to shift the pinned site(s) towards. We shift towards the
|
|
// hole left by the site being dragged.
|
|
let holeIndex = index;
|
|
const indexStep = index > draggedFromIndex ? -1 : 1;
|
|
while (pinned[holeIndex]) {
|
|
holeIndex += indexStep;
|
|
}
|
|
if (holeIndex >= topSitesCount || holeIndex < 0) {
|
|
// There are no holes, so we will effectively unpin the last slot and shifting
|
|
// towards it. This only happens when adding a new top site to an already
|
|
// fully pinned grid.
|
|
holeIndex = topSitesCount - 1;
|
|
}
|
|
|
|
// Shift towards the hole.
|
|
const shiftingStep = holeIndex > index ? -1 : 1;
|
|
while (holeIndex !== index) {
|
|
const nextIndex = holeIndex + shiftingStep;
|
|
this._pinSiteAt(pinned[nextIndex], holeIndex);
|
|
holeIndex = nextIndex;
|
|
}
|
|
this._pinSiteAt(site, index);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle an insert (drop/add) action of a site.
|
|
*/
|
|
async insert(action) {
|
|
let {index} = action.data;
|
|
// Treat invalid pin index values (e.g., -1, undefined) as insert in the first position
|
|
if (!(index > 0)) {
|
|
index = 0;
|
|
}
|
|
|
|
// Inserting a top site pins it in the specified slot, pushing over any link already
|
|
// pinned in the slot (unless it's the last slot, then it replaces).
|
|
this._insertPin(
|
|
action.data.site, index,
|
|
action.data.draggedFromIndex !== undefined ? action.data.draggedFromIndex : this.store.getState().Prefs.values.topSitesRows * TOP_SITES_MAX_SITES_PER_ROW);
|
|
|
|
await this._clearLinkCustomScreenshot(action.data.site);
|
|
this._broadcastPinnedSitesUpdated();
|
|
}
|
|
|
|
onAction(action) {
|
|
switch (action.type) {
|
|
case at.INIT:
|
|
// If the feed was previously disabled PREFS_INITIAL_VALUES was never received
|
|
this.refreshDefaults(this.store.getState().Prefs.values[DEFAULT_SITES_PREF]);
|
|
this.refresh({broadcast: true});
|
|
break;
|
|
case at.SYSTEM_TICK:
|
|
this.refresh({broadcast: false});
|
|
break;
|
|
// All these actions mean we need new top sites
|
|
case at.MIGRATION_COMPLETED:
|
|
case at.PLACES_HISTORY_CLEARED:
|
|
this.frecentCache.expire();
|
|
this.refresh({broadcast: true});
|
|
break;
|
|
case at.PLACES_LINKS_CHANGED:
|
|
this.frecentCache.expire();
|
|
this.refresh({broadcast: false});
|
|
break;
|
|
case at.PLACES_LINK_BLOCKED:
|
|
this.frecentCache.expire();
|
|
this.pinnedCache.expire();
|
|
this.refresh({broadcast: true});
|
|
break;
|
|
case at.PREF_CHANGED:
|
|
if (action.data.name === DEFAULT_SITES_PREF) {
|
|
this.refreshDefaults(action.data.value);
|
|
}
|
|
break;
|
|
case at.UPDATE_SECTION_PREFS:
|
|
if (action.data.id === SECTION_ID) {
|
|
this.updateSectionPrefs(action.data.value);
|
|
}
|
|
break;
|
|
case at.PREFS_INITIAL_VALUES:
|
|
this.refreshDefaults(action.data[DEFAULT_SITES_PREF]);
|
|
break;
|
|
case at.TOP_SITES_PIN:
|
|
this.pin(action);
|
|
break;
|
|
case at.TOP_SITES_UNPIN:
|
|
this.unpin(action);
|
|
break;
|
|
case at.TOP_SITES_INSERT:
|
|
this.insert(action);
|
|
break;
|
|
case at.PREVIEW_REQUEST:
|
|
this.getScreenshotPreview(action.data.url, action.meta.fromTarget);
|
|
break;
|
|
case at.UNINIT:
|
|
this.uninit();
|
|
break;
|
|
}
|
|
}
|
|
};
|
|
|
|
this.DEFAULT_TOP_SITES = DEFAULT_TOP_SITES;
|
|
const EXPORTED_SYMBOLS = ["TopSitesFeed", "DEFAULT_TOP_SITES"];
|