Files
tubestation/browser/extensions/activity-stream/lib/FaviconFeed.jsm
2018-05-25 17:02:29 -07:00

272 lines
8.5 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");
XPCOMUtils.defineLazyGlobalGetters(this, ["fetch"]);
const {actionTypes: at} = ChromeUtils.import("resource://activity-stream/common/Actions.jsm", {});
const {PersistentCache} = ChromeUtils.import("resource://activity-stream/lib/PersistentCache.jsm", {});
const {getDomain} = ChromeUtils.import("resource://activity-stream/lib/TippyTopProvider.jsm", {});
ChromeUtils.defineModuleGetter(this, "PlacesUtils",
"resource://gre/modules/PlacesUtils.jsm");
ChromeUtils.defineModuleGetter(this, "Services",
"resource://gre/modules/Services.jsm");
ChromeUtils.defineModuleGetter(this, "NewTabUtils",
"resource://gre/modules/NewTabUtils.jsm");
const FIVE_MINUTES = 5 * 60 * 1000;
const ONE_DAY = 24 * 60 * 60 * 1000;
const TIPPYTOP_UPDATE_TIME = ONE_DAY;
const TIPPYTOP_RETRY_DELAY = FIVE_MINUTES;
const MIN_FAVICON_SIZE = 96;
/**
* Get favicon info (uri and size) for a uri from Places.
*
* @param uri {nsIURI} Page to check for favicon data
* @returns A promise of an object (possibly null) containing the data
*/
function getFaviconInfo(uri) {
// Use 0 to get the biggest width available
const preferredWidth = 0;
return new Promise(resolve => PlacesUtils.favicons.getFaviconDataForPage(
uri,
// Package up the icon data in an object if we have it; otherwise null
(iconUri, faviconLength, favicon, mimeType, faviconSize) =>
resolve(iconUri ? {iconUri, faviconSize} : null),
preferredWidth));
}
/**
* Fetches visit paths for a given URL from its most recent visit in Places.
*
* Note that this includes the URL itself as well as all the following
* permenent&temporary redirected URLs if any.
*
* @param {String} a URL string
*
* @returns {Array} Returns an array containing objects as
* {int} visit_id: ID of the visit in moz_historyvisits.
* {String} url: URL of the redirected URL.
*/
async function fetchVisitPaths(url) {
const query = `
WITH RECURSIVE path(visit_id)
AS (
SELECT v.id
FROM moz_places h
JOIN moz_historyvisits v
ON v.place_id = h.id
WHERE h.url_hash = hash(:url) AND h.url = :url
AND v.visit_date = h.last_visit_date
UNION
SELECT id
FROM moz_historyvisits
JOIN path
ON visit_id = from_visit
WHERE visit_type IN
(${PlacesUtils.history.TRANSITIONS.REDIRECT_PERMANENT},
${PlacesUtils.history.TRANSITIONS.REDIRECT_TEMPORARY})
)
SELECT visit_id, (
SELECT (
SELECT url
FROM moz_places
WHERE id = place_id)
FROM moz_historyvisits
WHERE id = visit_id) AS url
FROM path
`;
const visits = await NewTabUtils.activityStreamProvider.executePlacesQuery(query, {
columns: ["visit_id", "url"],
params: {url}
});
return visits;
}
/**
* Fetch favicon for a url by following its redirects in Places.
*
* This can improve the rich icon coverage for Top Sites since Places only
* associates the favicon to the final url if the original one gets redirected.
* Note this is not an urgent request, hence it is dispatched to the main
* thread idle handler to avoid any possible performance impact.
*/
async function fetchIconFromRedirects(url) {
const visitPaths = await fetchVisitPaths(url);
if (visitPaths.length > 1) {
const lastVisit = visitPaths.pop();
const redirectedUri = Services.io.newURI(lastVisit.url);
const iconInfo = await getFaviconInfo(redirectedUri);
if (iconInfo && iconInfo.faviconSize >= MIN_FAVICON_SIZE) {
PlacesUtils.favicons.setAndFetchFaviconForPage(
Services.io.newURI(url),
iconInfo.iconUri,
false,
PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE,
null,
Services.scriptSecurityManager.getSystemPrincipal()
);
}
}
}
this.FaviconFeed = class FaviconFeed {
constructor() {
this.tippyTopNextUpdate = 0;
this.cache = new PersistentCache("tippytop", true);
this._sitesByDomain = null;
this.numRetries = 0;
this._queryForRedirects = new Set();
}
get endpoint() {
return this.store.getState().Prefs.values["tippyTop.service.endpoint"];
}
async loadCachedData() {
const data = await this.cache.get("sites");
if (data && "_timestamp" in data) {
this._sitesByDomain = data;
this.tippyTopNextUpdate = data._timestamp + TIPPYTOP_UPDATE_TIME;
}
}
async maybeRefresh() {
if (Date.now() >= this.tippyTopNextUpdate) {
await this.refresh();
}
}
async refresh() {
let headers = new Headers();
if (this._sitesByDomain && this._sitesByDomain._etag) {
headers.set("If-None-Match", this._sitesByDomain._etag);
}
let {data, etag, status} = await this.loadFromURL(this.endpoint, headers);
let failedUpdate = false;
if (status === 200) {
this._sitesByDomain = this._sitesArrayToObjectByDomain(data);
this._sitesByDomain._etag = etag;
} else if (status !== 304) {
failedUpdate = true;
}
let delay = TIPPYTOP_UPDATE_TIME;
if (failedUpdate) {
delay = Math.min(TIPPYTOP_UPDATE_TIME, TIPPYTOP_RETRY_DELAY * Math.pow(2, this.numRetries++));
} else {
this._sitesByDomain._timestamp = Date.now();
this.cache.set("sites", this._sitesByDomain);
this.numRetries = 0;
}
this.tippyTopNextUpdate = Date.now() + delay;
}
async loadFromURL(url, headers) {
let data = [];
let etag;
let status;
try {
let response = await fetch(url, {headers});
status = response.status;
if (status === 200) {
data = await response.json();
etag = response.headers.get("ETag");
}
} catch (error) {
Cu.reportError(`Failed to load tippy top manifest from ${url}`);
}
return {data, etag, status};
}
_sitesArrayToObjectByDomain(sites) {
let sitesByDomain = {};
for (const site of sites) {
// The tippy top manifest can have a url property (string) or a
// urls property (array of strings)
for (const domain of site.domains || []) {
sitesByDomain[domain] = {image_url: site.image_url};
}
}
return sitesByDomain;
}
getSitesByDomain() {
// return an already loaded object or a promise for that object
return this._sitesByDomain || (this._sitesByDomain = new Promise(async resolve => {
await this.loadCachedData();
await this.maybeRefresh();
if (this._sitesByDomain instanceof Promise) {
// If _sitesByDomain is still a Promise, no data was loaded from cache or fetch.
this._sitesByDomain = {};
}
resolve(this._sitesByDomain);
}));
}
/**
* fetchIcon attempts to fetch a rich icon for the given url from two sources.
* First, it looks up the tippy top feed, if it's still missing, then it queries
* the places for rich icon with its most recent visit in order to deal with
* the redirected visit. See Bug 1421428 for more details.
*/
async fetchIcon(url) {
// Avoid initializing and fetching icons if prefs are turned off
if (!this.shouldFetchIcons) {
return;
}
const sitesByDomain = await this.getSitesByDomain();
const domain = getDomain(url);
if (domain in sitesByDomain) {
let iconUri = Services.io.newURI(sitesByDomain[domain].image_url);
// The #tippytop is to be able to identify them for telemetry.
iconUri = iconUri.mutate().setRef("tippytop").finalize();
PlacesUtils.favicons.setAndFetchFaviconForPage(
Services.io.newURI(url),
iconUri,
false,
PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE,
null,
Services.scriptSecurityManager.getSystemPrincipal()
);
return;
}
if (!this._queryForRedirects.has(url)) {
this._queryForRedirects.add(url);
Services.tm.idleDispatchToMainThread(() => fetchIconFromRedirects(url));
}
}
/**
* Determine if we should be fetching and saving icons.
*/
get shouldFetchIcons() {
return this.endpoint && Services.prefs.getBoolPref("browser.chrome.site_icons");
}
onAction(action) {
switch (action.type) {
case at.SYSTEM_TICK:
if (this._sitesByDomain) {
// No need to refresh if we haven't been initialized.
this.maybeRefresh();
}
break;
case at.RICH_ICON_MISSING:
this.fetchIcon(action.data.url);
break;
}
}
};
const EXPORTED_SYMBOLS = ["FaviconFeed", "fetchIconFromRedirects"];