This repository has been archived by the owner on Feb 29, 2020. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 112
/
snippets.js
364 lines (311 loc) · 10.7 KB
/
snippets.js
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
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
const DATABASE_NAME = "snippets_db";
const DATABASE_VERSION = 1;
const SNIPPETS_OBJECTSTORE_NAME = "snippets";
const SNIPPETS_UPDATE_INTERVAL_MS = 14400000; // 4 hours.
const SNIPPETS_ENABLED_EVENT = "Snippets:Enabled";
const SNIPPETS_DISABLED_EVENT = "Snippets:Disabled";
const {actionTypes: at, actionCreators: ac} = require("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.
*
*/
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();
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;
}
let blockList = this.blockList;
if (!blockList.includes(id)) {
blockList.push(id);
}
await this.set("blockList", blockList);
}
disableOnboarding() {
this._dispatch(ac.SendToMain({type: at.DISABLE_ONBOARDING}));
}
showFirefoxAccounts() {
this._dispatch(ac.SendToMain({type: at.SHOW_FIREFOX_ACCOUNTS}));
}
/**
* 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) {
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.
*/
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);
}
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
}
}
}
_noSnippetFallback() {
// TODO
}
_forceOnboardingVisibility(shouldBeVisible) {
const onboardingEl = document.getElementById("onboarding-notification-bar");
if (onboardingEl) {
onboardingEl.style.display = shouldBeVisible ? "" : "none";
}
}
_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.
snippetsEl.innerHTML = payload;
// 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);
}
}
/**
* 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
}, options);
// 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)) {
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._noSnippetFallback(e);
}
window.dispatchEvent(new Event(SNIPPETS_ENABLED_EVENT));
this._forceOnboardingVisibility(true);
this.initialized = true;
}
uninit() {
window.dispatchEvent(new Event(SNIPPETS_DISABLED_EVENT));
this._forceOnboardingVisibility(false);
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 and unsubscribe function
*/
function addSnippetsSubscriber(store) {
const snippets = new SnippetsProvider(store.dispatch);
let initializing = false;
store.subscribe(async () => {
const state = store.getState();
// state.Prefs.values["feeds.snippets"]: Should snippets be shown?
// state.Snippets.initialized Is the snippets data initialized?
// snippets.initialized: Is SnippetsProvider currently initialised?
if (state.Prefs.values["feeds.snippets"] &&
!state.Prefs.values.disableSnippets &&
state.Snippets.initialized &&
!snippets.initialized &&
// Don't call init multiple times
!initializing
) {
initializing = true;
await snippets.init({appData: state.Snippets});
initializing = false;
} else if (
(state.Prefs.values["feeds.snippets"] === false ||
state.Prefs.values.disableSnippets === true) &&
snippets.initialized
) {
snippets.uninit();
}
});
// These values are returned for testing purposes
return snippets;
}
module.exports = {
addSnippetsSubscriber,
SnippetsMap,
SnippetsProvider,
SNIPPETS_UPDATE_INTERVAL_MS
};