mirror of
https://github.com/mozilla/gecko-dev.git
synced 2025-11-12 22:28:59 +02:00
Differential Revision: https://phabricator.services.mozilla.com/D11375 --HG-- extra : moz-landing-system : lando
433 lines
14 KiB
JavaScript
433 lines
14 KiB
JavaScript
const DATABASE_NAME = "snippets_db";
|
|
const DATABASE_VERSION = 1;
|
|
const SNIPPETS_OBJECTSTORE_NAME = "snippets";
|
|
export const SNIPPETS_UPDATE_INTERVAL_MS = 14400000; // 4 hours.
|
|
|
|
const SNIPPETS_ENABLED_EVENT = "Snippets:Enabled";
|
|
const SNIPPETS_DISABLED_EVENT = "Snippets:Disabled";
|
|
|
|
import {actionCreators as ac, actionTypes as at} from "common/Actions.jsm";
|
|
|
|
/**
|
|
* SnippetsMap - A utility for cacheing values related to the snippet. It has
|
|
* the same interface as a Map, but is optionally backed by
|
|
* indexedDB for persistent storage.
|
|
* Call .connect() to open a database connection and restore any
|
|
* previously cached data, if necessary.
|
|
*
|
|
*/
|
|
export class SnippetsMap extends Map {
|
|
constructor(dispatch) {
|
|
super();
|
|
this._db = null;
|
|
this._dispatch = dispatch;
|
|
}
|
|
|
|
set(key, value) {
|
|
super.set(key, value);
|
|
return this._dbTransaction(db => db.put(value, key));
|
|
}
|
|
|
|
delete(key) {
|
|
super.delete(key);
|
|
return this._dbTransaction(db => db.delete(key));
|
|
}
|
|
|
|
clear() {
|
|
super.clear();
|
|
this._dispatch(ac.OnlyToMain({type: at.SNIPPETS_BLOCKLIST_CLEARED}));
|
|
return this._dbTransaction(db => db.clear());
|
|
}
|
|
|
|
get blockList() {
|
|
return this.get("blockList") || [];
|
|
}
|
|
|
|
/**
|
|
* blockSnippetById - Blocks a snippet given an id
|
|
*
|
|
* @param {str|int} id The id of the snippet
|
|
* @return {Promise} Resolves when the id has been written to indexedDB,
|
|
* or immediately if the snippetMap is not connected
|
|
*/
|
|
async blockSnippetById(id) {
|
|
if (!id) {
|
|
return;
|
|
}
|
|
const {blockList} = this;
|
|
if (!blockList.includes(id)) {
|
|
blockList.push(id);
|
|
this._dispatch(ac.AlsoToMain({type: at.SNIPPETS_BLOCKLIST_UPDATED, data: id}));
|
|
await this.set("blockList", blockList);
|
|
}
|
|
}
|
|
|
|
disableOnboarding() {}
|
|
|
|
showFirefoxAccounts() {
|
|
this._dispatch(ac.AlsoToMain({type: at.SHOW_FIREFOX_ACCOUNTS}));
|
|
}
|
|
|
|
getTotalBookmarksCount() {
|
|
return new Promise(resolve => {
|
|
this._dispatch(ac.OnlyToMain({type: at.TOTAL_BOOKMARKS_REQUEST}));
|
|
global.RPMAddMessageListener("ActivityStream:MainToContent", function onMessage({data: action}) {
|
|
if (action.type === at.TOTAL_BOOKMARKS_RESPONSE) {
|
|
resolve(action.data);
|
|
global.RPMRemoveMessageListener("ActivityStream:MainToContent", onMessage);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
getAddonsInfo() {
|
|
return new Promise(resolve => {
|
|
this._dispatch(ac.OnlyToMain({type: at.ADDONS_INFO_REQUEST}));
|
|
global.RPMAddMessageListener("ActivityStream:MainToContent", function onMessage({data: action}) {
|
|
if (action.type === at.ADDONS_INFO_RESPONSE) {
|
|
resolve(action.data);
|
|
global.RPMRemoveMessageListener("ActivityStream:MainToContent", onMessage);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* connect - Attaches an indexedDB back-end to the Map so that any set values
|
|
* are also cached in a store. It also restores any existing values
|
|
* that are already stored in the indexedDB store.
|
|
*
|
|
* @return {type} description
|
|
*/
|
|
async connect() {
|
|
// Open the connection
|
|
const db = await this._openDB();
|
|
|
|
// Restore any existing values
|
|
await this._restoreFromDb(db);
|
|
|
|
// Attach a reference to the db
|
|
this._db = db;
|
|
}
|
|
|
|
/**
|
|
* _dbTransaction - Returns a db transaction wrapped with the given modifier
|
|
* function as a Promise. If the db has not been connected,
|
|
* it resolves immediately.
|
|
*
|
|
* @param {func} modifier A function to call with the transaction
|
|
* @return {obj} A Promise that resolves when the transaction has
|
|
* completed or errored
|
|
*/
|
|
_dbTransaction(modifier) {
|
|
if (!this._db) {
|
|
return Promise.resolve();
|
|
}
|
|
return new Promise((resolve, reject) => {
|
|
const transaction = modifier(
|
|
this._db
|
|
.transaction(SNIPPETS_OBJECTSTORE_NAME, "readwrite")
|
|
.objectStore(SNIPPETS_OBJECTSTORE_NAME)
|
|
);
|
|
transaction.onsuccess = event => resolve();
|
|
|
|
/* istanbul ignore next */
|
|
transaction.onerror = event => reject(transaction.error);
|
|
});
|
|
}
|
|
|
|
_openDB() {
|
|
return new Promise((resolve, reject) => {
|
|
const openRequest = indexedDB.open(DATABASE_NAME, DATABASE_VERSION);
|
|
|
|
/* istanbul ignore next */
|
|
openRequest.onerror = event => {
|
|
// Try to delete the old database so that we can start this process over
|
|
// next time.
|
|
indexedDB.deleteDatabase(DATABASE_NAME);
|
|
reject(event);
|
|
};
|
|
|
|
openRequest.onupgradeneeded = event => {
|
|
const db = event.target.result;
|
|
if (!db.objectStoreNames.contains(SNIPPETS_OBJECTSTORE_NAME)) {
|
|
db.createObjectStore(SNIPPETS_OBJECTSTORE_NAME);
|
|
}
|
|
};
|
|
|
|
openRequest.onsuccess = event => {
|
|
let db = event.target.result;
|
|
|
|
/* istanbul ignore next */
|
|
db.onerror = err => console.error(err); // eslint-disable-line no-console
|
|
/* istanbul ignore next */
|
|
db.onversionchange = versionChangeEvent => versionChangeEvent.target.close();
|
|
|
|
resolve(db);
|
|
};
|
|
});
|
|
}
|
|
|
|
_restoreFromDb(db) {
|
|
return new Promise((resolve, reject) => {
|
|
let cursorRequest;
|
|
try {
|
|
cursorRequest = db.transaction(SNIPPETS_OBJECTSTORE_NAME)
|
|
.objectStore(SNIPPETS_OBJECTSTORE_NAME).openCursor();
|
|
} catch (err) {
|
|
// istanbul ignore next
|
|
reject(err);
|
|
// istanbul ignore next
|
|
return;
|
|
}
|
|
|
|
/* istanbul ignore next */
|
|
cursorRequest.onerror = event => reject(event);
|
|
|
|
cursorRequest.onsuccess = event => {
|
|
let cursor = event.target.result;
|
|
// Populate the cache from the persistent storage.
|
|
if (cursor) {
|
|
if (cursor.value !== "blockList") {
|
|
this.set(cursor.key, cursor.value);
|
|
}
|
|
cursor.continue();
|
|
} else {
|
|
// We are done.
|
|
resolve();
|
|
}
|
|
};
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* SnippetsProvider - Initializes a SnippetsMap and loads snippets from a
|
|
* remote location, or else default snippets if the remote
|
|
* snippets cannot be retrieved.
|
|
*/
|
|
export class SnippetsProvider {
|
|
constructor(dispatch) {
|
|
// Initialize the Snippets Map and attaches it to a global so that
|
|
// the snippet payload can interact with it.
|
|
global.gSnippetsMap = new SnippetsMap(dispatch);
|
|
this._onAction = this._onAction.bind(this);
|
|
}
|
|
|
|
get snippetsMap() {
|
|
return global.gSnippetsMap;
|
|
}
|
|
|
|
async _refreshSnippets() {
|
|
// Check if the cached version of of the snippets in snippetsMap. If it's too
|
|
// old, blow away the entire snippetsMap.
|
|
const cachedVersion = this.snippetsMap.get("snippets-cached-version");
|
|
|
|
if (cachedVersion !== this.appData.version) {
|
|
this.snippetsMap.clear();
|
|
}
|
|
|
|
// Has enough time passed for us to require an update?
|
|
const lastUpdate = this.snippetsMap.get("snippets-last-update");
|
|
const needsUpdate = !(lastUpdate >= 0) || Date.now() - lastUpdate > SNIPPETS_UPDATE_INTERVAL_MS;
|
|
|
|
if (needsUpdate && this.appData.snippetsURL) {
|
|
this.snippetsMap.set("snippets-last-update", Date.now());
|
|
try {
|
|
const response = await fetch(this.appData.snippetsURL);
|
|
if (response.status === 200) {
|
|
const payload = await response.text();
|
|
|
|
this.snippetsMap.set("snippets", payload);
|
|
this.snippetsMap.set("snippets-cached-version", this.appData.version);
|
|
}
|
|
} catch (e) {
|
|
console.error(e); // eslint-disable-line no-console
|
|
}
|
|
}
|
|
}
|
|
|
|
_showRemoteSnippets() {
|
|
const snippetsEl = document.getElementById(this.elementId);
|
|
const payload = this.snippetsMap.get("snippets");
|
|
|
|
if (!snippetsEl) {
|
|
throw new Error(`No element was found with id '${this.elementId}'.`);
|
|
}
|
|
|
|
// This could happen if fetching failed
|
|
if (!payload) {
|
|
throw new Error("No remote snippets were found in gSnippetsMap.");
|
|
}
|
|
|
|
if (typeof payload !== "string") {
|
|
throw new Error("Snippet payload was incorrectly formatted");
|
|
}
|
|
|
|
// Note that injecting snippets can throw if they're invalid XML.
|
|
// eslint-disable-next-line no-unsanitized/property
|
|
snippetsEl.innerHTML = payload;
|
|
|
|
this._logIfDevtools("Successfully added snippets.");
|
|
|
|
// Scripts injected by innerHTML are inactive, so we have to relocate them
|
|
// through DOM manipulation to activate their contents.
|
|
for (const scriptEl of snippetsEl.getElementsByTagName("script")) {
|
|
const relocatedScript = document.createElement("script");
|
|
relocatedScript.text = scriptEl.text;
|
|
scriptEl.parentNode.replaceChild(relocatedScript, scriptEl);
|
|
}
|
|
}
|
|
|
|
_onAction(msg) {
|
|
if (msg.data.type === at.SNIPPET_BLOCKED) {
|
|
if (!this.snippetsMap.blockList.includes(msg.data.data)) {
|
|
this.snippetsMap.set("blockList", this.snippetsMap.blockList.concat(msg.data.data));
|
|
document.getElementById("snippets-container").style.display = "none";
|
|
}
|
|
}
|
|
}
|
|
|
|
// istanbul ignore next
|
|
_logIfDevtools(text) {
|
|
if (this.devtoolsEnabled) {
|
|
console.log("Legacy snippets:", text); // eslint-disable-line no-console
|
|
}
|
|
}
|
|
|
|
/**
|
|
* init - Fetch the snippet payload and show snippets
|
|
*
|
|
* @param {obj} options
|
|
* @param {str} options.appData.snippetsURL The URL from which we fetch snippets
|
|
* @param {int} options.appData.version The current snippets version
|
|
* @param {str} options.elementId The id of the element in which to inject snippets
|
|
* @param {bool} options.connect Should gSnippetsMap connect to indexedDB?
|
|
*/
|
|
async init(options) {
|
|
Object.assign(this, {
|
|
appData: {},
|
|
elementId: "snippets",
|
|
connect: true,
|
|
devtoolsEnabled: false,
|
|
}, options);
|
|
|
|
this._logIfDevtools("Initializing...");
|
|
|
|
// Add listener so we know when snippets are blocked on other pages
|
|
if (global.RPMAddMessageListener) {
|
|
global.RPMAddMessageListener("ActivityStream:MainToContent", this._onAction);
|
|
}
|
|
|
|
// TODO: Requires enabling indexedDB on newtab
|
|
// Restore the snippets map from indexedDB
|
|
if (this.connect) {
|
|
try {
|
|
await this.snippetsMap.connect();
|
|
} catch (e) {
|
|
console.error(e); // eslint-disable-line no-console
|
|
}
|
|
}
|
|
|
|
// Cache app data values so they can be accessible from gSnippetsMap
|
|
for (const key of Object.keys(this.appData)) {
|
|
if (key === "blockList") {
|
|
this.snippetsMap.set("blockList", this.appData[key]);
|
|
} else {
|
|
this.snippetsMap.set(`appData.${key}`, this.appData[key]);
|
|
}
|
|
}
|
|
|
|
// Refresh snippets, if enough time has passed.
|
|
await this._refreshSnippets();
|
|
|
|
// Try showing remote snippets, falling back to defaults if necessary.
|
|
try {
|
|
this._showRemoteSnippets();
|
|
} catch (e) {
|
|
this._logIfDevtools("Problem inserting remote snippets!");
|
|
console.error(e); // eslint-disable-line no-console
|
|
}
|
|
|
|
window.dispatchEvent(new Event(SNIPPETS_ENABLED_EVENT));
|
|
|
|
this.initialized = true;
|
|
this._logIfDevtools("Finished initializing.");
|
|
}
|
|
|
|
uninit() {
|
|
window.dispatchEvent(new Event(SNIPPETS_DISABLED_EVENT));
|
|
if (global.RPMRemoveMessageListener) {
|
|
global.RPMRemoveMessageListener("ActivityStream:MainToContent", this._onAction);
|
|
}
|
|
this.initialized = false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* addSnippetsSubscriber - Creates a SnippetsProvider that Initializes
|
|
* when the store has received the appropriate
|
|
* Snippet data.
|
|
*
|
|
* @param {obj} store The redux store
|
|
* @return {obj} Returns the snippets instance, asrouterContent instance and unsubscribe function
|
|
*/
|
|
export function addSnippetsSubscriber(store) {
|
|
const snippets = new SnippetsProvider(store.dispatch);
|
|
|
|
let initializing = false;
|
|
|
|
store.subscribe(async () => {
|
|
const state = store.getState();
|
|
|
|
/**
|
|
* Sorry this code is so complicated. It will be removed soon.
|
|
* This is what the different values actually mean:
|
|
*
|
|
* ASRouter.initialized Is ASRouter.jsm initialised?
|
|
* ASRouter.allowLegacySnippets Are ASRouter snippets turned OFF (i.e. legacy snippets are allowed)
|
|
* state.Prefs.values["feeds.snippets"] User preference for snippets
|
|
* state.Snippets.initialized Is SnippetsFeed.jsm initialised?
|
|
* snippets.initialized Is in-content snippets currently initialised?
|
|
* state.Prefs.values.disableSnippets This pref is used to disable legacy snippets in an emergency
|
|
* in a way that is not user-editable (true = disabled)
|
|
*/
|
|
|
|
/** If we should initialize snippets... */
|
|
if (
|
|
state.Prefs.values["feeds.snippets"] &&
|
|
state.ASRouter.initialized &&
|
|
state.ASRouter.allowLegacySnippets &&
|
|
!state.Prefs.values.disableSnippets &&
|
|
state.Snippets.initialized &&
|
|
!snippets.initialized &&
|
|
// Don't call init multiple times
|
|
!initializing &&
|
|
location.href !== "about:welcome" &&
|
|
location.hash !== "#asrouter"
|
|
) {
|
|
initializing = true;
|
|
await snippets.init({appData: state.Snippets, devtoolsEnabled: state.Prefs.values["asrouter.devtoolsEnabled"]});
|
|
initializing = false;
|
|
|
|
/** If we should remove snippets... */
|
|
} else if (
|
|
(
|
|
state.Prefs.values["feeds.snippets"] === false ||
|
|
state.Prefs.values.disableSnippets === true ||
|
|
(state.ASRouter.initialized && !state.ASRouter.allowLegacySnippets)
|
|
) &&
|
|
snippets.initialized
|
|
) {
|
|
// Remove snippets
|
|
snippets.uninit();
|
|
// istanbul ignore if
|
|
if (state.Prefs.values["asrouter.devtoolsEnabled"]) {
|
|
console.log("Legacy snippets removed"); // eslint-disable-line no-console
|
|
}
|
|
}
|
|
});
|
|
|
|
// Returned for testing purposes
|
|
return {snippets};
|
|
}
|