--- a/browser/extensions/activity-stream/common/Actions.jsm
+++ b/browser/extensions/activity-stream/common/Actions.jsm
@@ -26,16 +26,17 @@ const actionTypes = {};
for (const type of [
"BLOCK_URL",
"BOOKMARK_URL",
"DELETE_BOOKMARK_BY_ID",
"DELETE_HISTORY_URL",
"DELETE_HISTORY_URL_CONFIRM",
"DIALOG_CANCEL",
"DIALOG_OPEN",
+ "FEED_INIT",
"INIT",
"LOCALE_UPDATED",
"NEW_TAB_INITIAL_STATE",
"NEW_TAB_LOAD",
"NEW_TAB_UNLOAD",
"NEW_TAB_VISIBLE",
"OPEN_NEW_WINDOW",
"OPEN_PRIVATE_WINDOW",
@@ -45,17 +46,23 @@ for (const type of [
"PLACES_BOOKMARK_REMOVED",
"PLACES_HISTORY_CLEARED",
"PLACES_LINK_BLOCKED",
"PLACES_LINK_DELETED",
"PREFS_INITIAL_VALUES",
"PREF_CHANGED",
"SAVE_TO_POCKET",
"SCREENSHOT_UPDATED",
+ "SECTION_DEREGISTER",
+ "SECTION_REGISTER",
+ "SECTION_ROWS_UPDATE",
"SET_PREF",
+ "SNIPPETS_DATA",
+ "SNIPPETS_RESET",
+ "SYSTEM_TICK",
"TELEMETRY_PERFORMANCE_EVENT",
"TELEMETRY_UNDESIRED_EVENT",
"TELEMETRY_USER_EVENT",
"TOP_SITES_PIN",
"TOP_SITES_UNPIN",
"TOP_SITES_UPDATED",
"UNINIT"
]) {
--- a/browser/extensions/activity-stream/common/Reducers.jsm
+++ b/browser/extensions/activity-stream/common/Reducers.jsm
@@ -11,30 +11,32 @@ const INITIAL_STATE = {
initialized: false,
// The locale of the browser
locale: "",
// Localized strings with defaults
strings: {},
// The version of the system-addon
version: null
},
+ Snippets: {initialized: false},
TopSites: {
// Have we received real data from history yet?
initialized: false,
// The history (and possibly default) links
rows: []
},
Prefs: {
initialized: false,
values: {}
},
Dialog: {
visible: false,
data: {}
- }
+ },
+ Sections: []
};
function App(prevState = INITIAL_STATE.App, action) {
switch (action.type) {
case at.INIT:
return Object.assign({}, action.data || {}, {initialized: true});
case at.LOCALE_UPDATED: {
if (!action.data) {
@@ -100,25 +102,31 @@ function TopSites(prevState = INITIAL_ST
if (row && row.url === action.data.url) {
hasMatch = true;
return Object.assign({}, row, {screenshot: action.data.screenshot});
}
return row;
});
return hasMatch ? Object.assign({}, prevState, {rows: newRows}) : prevState;
case at.PLACES_BOOKMARK_ADDED:
+ if (!action.data) {
+ return prevState;
+ }
newRows = prevState.rows.map(site => {
if (site && site.url === action.data.url) {
const {bookmarkGuid, bookmarkTitle, lastModified} = action.data;
return Object.assign({}, site, {bookmarkGuid, bookmarkTitle, bookmarkDateCreated: lastModified});
}
return site;
});
return Object.assign({}, prevState, {rows: newRows});
case at.PLACES_BOOKMARK_REMOVED:
+ if (!action.data) {
+ return prevState;
+ }
newRows = prevState.rows.map(site => {
if (site && site.url === action.data.url) {
const newSite = Object.assign({}, site);
delete newSite.bookmarkGuid;
delete newSite.bookmarkTitle;
delete newSite.bookmarkDateCreated;
return newSite;
}
@@ -160,13 +168,63 @@ function Prefs(prevState = INITIAL_STATE
newValues = Object.assign({}, prevState.values);
newValues[action.data.name] = action.data.value;
return Object.assign({}, prevState, {values: newValues});
default:
return prevState;
}
}
+function Sections(prevState = INITIAL_STATE.Sections, action) {
+ let hasMatch;
+ let newState;
+ switch (action.type) {
+ case at.SECTION_DEREGISTER:
+ return prevState.filter(section => section.id !== action.data);
+ case at.SECTION_REGISTER:
+ // If section exists in prevState, update it
+ newState = prevState.map(section => {
+ if (section && section.id === action.data.id) {
+ hasMatch = true;
+ return Object.assign({}, section, action.data);
+ }
+ return section;
+ });
+ // If section doesn't exist in prevState, create a new section object and
+ // append it to the sections state
+ if (!hasMatch) {
+ const initialized = action.data.rows && action.data.rows.length > 0;
+ newState.push(Object.assign({title: "", initialized, rows: []}, action.data));
+ }
+ return newState;
+ case at.SECTION_ROWS_UPDATE:
+ return prevState.map(section => {
+ if (section && section.id === action.data.id) {
+ return Object.assign({}, section, action.data);
+ }
+ return section;
+ });
+ case at.PLACES_LINK_DELETED:
+ case at.PLACES_LINK_BLOCKED:
+ return prevState.map(section =>
+ Object.assign({}, section, {rows: section.rows.filter(site => site.url !== action.data.url)}));
+ default:
+ return prevState;
+ }
+}
+
+function Snippets(prevState = INITIAL_STATE.Snippets, action) {
+ switch (action.type) {
+ case at.SNIPPETS_DATA:
+ return Object.assign({}, prevState, {initialized: true}, action.data);
+ case at.SNIPPETS_RESET:
+ return INITIAL_STATE.Snippets;
+ default:
+ return prevState;
+ }
+}
+
this.INITIAL_STATE = INITIAL_STATE;
-this.reducers = {TopSites, App, Prefs, Dialog};
+
+this.reducers = {TopSites, App, Snippets, Prefs, Dialog, Sections};
this.insertPinned = insertPinned;
this.EXPORTED_SYMBOLS = ["reducers", "INITIAL_STATE", "insertPinned"];
--- a/browser/extensions/activity-stream/data/content/activity-stream.bundle.js
+++ b/browser/extensions/activity-stream/data/content/activity-stream.bundle.js
@@ -1,78 +1,84 @@
/******/ (function(modules) { // webpackBootstrap
/******/ // The module cache
/******/ var installedModules = {};
-/******/
+
/******/ // The require function
/******/ function __webpack_require__(moduleId) {
-/******/
+
/******/ // Check if module is in cache
/******/ if(installedModules[moduleId])
/******/ return installedModules[moduleId].exports;
-/******/
+
/******/ // Create a new module (and put it into the cache)
/******/ var module = installedModules[moduleId] = {
/******/ i: moduleId,
/******/ l: false,
/******/ exports: {}
/******/ };
-/******/
+
/******/ // Execute the module function
/******/ modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
-/******/
+
/******/ // Flag the module as loaded
/******/ module.l = true;
-/******/
+
/******/ // Return the exports of the module
/******/ return module.exports;
/******/ }
-/******/
-/******/
+
+
/******/ // expose the modules object (__webpack_modules__)
/******/ __webpack_require__.m = modules;
-/******/
+
/******/ // expose the module cache
/******/ __webpack_require__.c = installedModules;
-/******/
+
/******/ // identity function for calling harmony imports with the correct context
/******/ __webpack_require__.i = function(value) { return value; };
-/******/
+
/******/ // define getter function for harmony exports
/******/ __webpack_require__.d = function(exports, name, getter) {
/******/ if(!__webpack_require__.o(exports, name)) {
/******/ Object.defineProperty(exports, name, {
/******/ configurable: false,
/******/ enumerable: true,
/******/ get: getter
/******/ });
/******/ }
/******/ };
-/******/
+
/******/ // getDefaultExport function for compatibility with non-harmony modules
/******/ __webpack_require__.n = function(module) {
/******/ var getter = module && module.__esModule ?
/******/ function getDefault() { return module['default']; } :
/******/ function getModuleExports() { return module; };
/******/ __webpack_require__.d(getter, 'a', getter);
/******/ return getter;
/******/ };
-/******/
+
/******/ // Object.prototype.hasOwnProperty.call
/******/ __webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); };
-/******/
+
/******/ // __webpack_public_path__
/******/ __webpack_require__.p = "";
-/******/
+
/******/ // Load entry module and return exports
-/******/ return __webpack_require__(__webpack_require__.s = 19);
+/******/ return __webpack_require__(__webpack_require__.s = 25);
/******/ })
/************************************************************************/
/******/ ([
/* 0 */
+/***/ (function(module, exports) {
+
+module.exports = React;
+
+/***/ }),
+/* 1 */
/***/ (function(module, exports, __webpack_require__) {
"use strict";
/* 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/. */
@@ -92,17 +98,17 @@ const globalImportContext = typeof Windo
// Create an object that avoids accidental differing key/value pairs:
// {
// INIT: "INIT",
// UNINIT: "UNINIT"
// }
const actionTypes = {};
-for (const type of ["BLOCK_URL", "BOOKMARK_URL", "DELETE_BOOKMARK_BY_ID", "DELETE_HISTORY_URL", "DELETE_HISTORY_URL_CONFIRM", "DIALOG_CANCEL", "DIALOG_OPEN", "INIT", "LOCALE_UPDATED", "NEW_TAB_INITIAL_STATE", "NEW_TAB_LOAD", "NEW_TAB_UNLOAD", "NEW_TAB_VISIBLE", "OPEN_NEW_WINDOW", "OPEN_PRIVATE_WINDOW", "PINNED_SITES_UPDATED", "PLACES_BOOKMARK_ADDED", "PLACES_BOOKMARK_CHANGED", "PLACES_BOOKMARK_REMOVED", "PLACES_HISTORY_CLEARED", "PLACES_LINK_BLOCKED", "PLACES_LINK_DELETED", "PREFS_INITIAL_VALUES", "PREF_CHANGED", "SAVE_TO_POCKET", "SCREENSHOT_UPDATED", "SET_PREF", "TELEMETRY_PERFORMANCE_EVENT", "TELEMETRY_UNDESIRED_EVENT", "TELEMETRY_USER_EVENT", "TOP_SITES_PIN", "TOP_SITES_UNPIN", "TOP_SITES_UPDATED", "UNINIT"]) {
+for (const type of ["BLOCK_URL", "BOOKMARK_URL", "DELETE_BOOKMARK_BY_ID", "DELETE_HISTORY_URL", "DELETE_HISTORY_URL_CONFIRM", "DIALOG_CANCEL", "DIALOG_OPEN", "FEED_INIT", "INIT", "LOCALE_UPDATED", "NEW_TAB_INITIAL_STATE", "NEW_TAB_LOAD", "NEW_TAB_UNLOAD", "NEW_TAB_VISIBLE", "OPEN_NEW_WINDOW", "OPEN_PRIVATE_WINDOW", "PINNED_SITES_UPDATED", "PLACES_BOOKMARK_ADDED", "PLACES_BOOKMARK_CHANGED", "PLACES_BOOKMARK_REMOVED", "PLACES_HISTORY_CLEARED", "PLACES_LINK_BLOCKED", "PLACES_LINK_DELETED", "PREFS_INITIAL_VALUES", "PREF_CHANGED", "SAVE_TO_POCKET", "SCREENSHOT_UPDATED", "SECTION_DEREGISTER", "SECTION_REGISTER", "SECTION_ROWS_UPDATE", "SET_PREF", "SNIPPETS_DATA", "SNIPPETS_RESET", "SYSTEM_TICK", "TELEMETRY_PERFORMANCE_EVENT", "TELEMETRY_UNDESIRED_EVENT", "TELEMETRY_USER_EVENT", "TOP_SITES_PIN", "TOP_SITES_UNPIN", "TOP_SITES_UPDATED", "UNINIT"]) {
actionTypes[type] = type;
}
// Helper function for creating routed actions between content and main
// Not intended to be used by consumers
function _RouteMessage(action, options) {
const meta = action.meta ? Object.assign({}, action.meta) : {};
if (!options || !options.from || !options.to) {
@@ -271,32 +277,26 @@ module.exports = {
globalImportContext,
UI_CODE,
BACKGROUND_PROCESS,
MAIN_MESSAGE_TYPE,
CONTENT_MESSAGE_TYPE
};
/***/ }),
-/* 1 */
-/***/ (function(module, exports) {
-
-module.exports = React;
-
-/***/ }),
/* 2 */
/***/ (function(module, exports) {
-module.exports = ReactRedux;
+module.exports = ReactIntl;
/***/ }),
/* 3 */
/***/ (function(module, exports) {
-module.exports = ReactIntl;
+module.exports = ReactRedux;
/***/ }),
/* 4 */
/***/ (function(module, exports, __webpack_require__) {
"use strict";
@@ -320,41 +320,116 @@ module.exports = function shortURL(link)
}
const eTLD = link.eTLD;
const hostname = (link.hostname || new URL(link.url).hostname).replace(/^www\./i, "");
// Remove the eTLD (e.g., com, net) and the preceding period from the hostname
const eTLDLength = (eTLD || "").length || hostname.match(/\.com$/) && 3;
const eTLDExtra = eTLDLength > 0 ? -(eTLDLength + 1) : Infinity;
- return hostname.slice(0, eTLDExtra).toLowerCase() || hostname;
+ // If URL and hostname are not present fallback to page title.
+ return hostname.slice(0, eTLDExtra).toLowerCase() || hostname || link.title;
};
/***/ }),
/* 5 */
/***/ (function(module, exports, __webpack_require__) {
"use strict";
-const React = __webpack_require__(1);
+const React = __webpack_require__(0);
var _require = __webpack_require__(2);
+const injectIntl = _require.injectIntl;
+
+const ContextMenu = __webpack_require__(15);
+
+var _require2 = __webpack_require__(1);
+
+const ac = _require2.actionCreators;
+
+const linkMenuOptions = __webpack_require__(21);
+const DEFAULT_SITE_MENU_OPTIONS = ["CheckPinTopSite", "Separator", "OpenInNewWindow", "OpenInPrivateWindow"];
+
+class LinkMenu extends React.Component {
+ getOptions() {
+ const props = this.props;
+ const site = props.site,
+ index = props.index,
+ source = props.source;
+
+ // Handle special case of default site
+
+ const propOptions = !site.isDefault ? props.options : DEFAULT_SITE_MENU_OPTIONS;
+
+ const options = propOptions.map(o => linkMenuOptions[o](site, index)).map(option => {
+ const action = option.action,
+ id = option.id,
+ type = option.type,
+ userEvent = option.userEvent;
+
+ if (!type && id) {
+ option.label = props.intl.formatMessage(option);
+ option.onClick = () => {
+ props.dispatch(action);
+ if (userEvent) {
+ props.dispatch(ac.UserEvent({
+ event: userEvent,
+ source,
+ action_position: index
+ }));
+ }
+ };
+ }
+ return option;
+ });
+
+ // This is for accessibility to support making each item tabbable.
+ // We want to know which item is the first and which item
+ // is the last, so we can close the context menu accordingly.
+ options[0].first = true;
+ options[options.length - 1].last = true;
+ return options;
+ }
+ render() {
+ return React.createElement(ContextMenu, {
+ visible: this.props.visible,
+ onUpdate: this.props.onUpdate,
+ options: this.getOptions() });
+ }
+}
+
+module.exports = injectIntl(LinkMenu);
+module.exports._unconnected = LinkMenu;
+
+/***/ }),
+/* 6 */
+/***/ (function(module, exports, __webpack_require__) {
+
+"use strict";
+
+
+const React = __webpack_require__(0);
+
+var _require = __webpack_require__(3);
+
const connect = _require.connect;
-var _require2 = __webpack_require__(3);
+var _require2 = __webpack_require__(2);
const addLocaleData = _require2.addLocaleData,
IntlProvider = _require2.IntlProvider;
-const TopSites = __webpack_require__(15);
-const Search = __webpack_require__(14);
-const ConfirmDialog = __webpack_require__(10);
-const PreferencesPane = __webpack_require__(13);
+const TopSites = __webpack_require__(19);
+const Search = __webpack_require__(17);
+const ConfirmDialog = __webpack_require__(14);
+const PreferencesPane = __webpack_require__(16);
+const Sections = __webpack_require__(18);
// Locales that should be displayed RTL
const RTL_LIST = ["ar", "he", "fa", "ur"];
// Add the locale data for pluralization and relative-time formatting for now,
// this just uses english locale data. We can make this more sophisticated if
// more features are needed.
function addLocaleDataForReactIntl(_ref) {
@@ -404,38 +479,39 @@ class Base extends React.Component {
React.createElement(
"div",
{ className: "outer-wrapper" },
React.createElement(
"main",
null,
prefs.showSearch && React.createElement(Search, null),
prefs.showTopSites && React.createElement(TopSites, null),
+ React.createElement(Sections, null),
React.createElement(ConfirmDialog, null)
),
React.createElement(PreferencesPane, null)
)
);
}
}
module.exports = connect(state => ({ App: state.App, Prefs: state.Prefs }))(Base);
/***/ }),
-/* 6 */
+/* 7 */
/***/ (function(module, exports, __webpack_require__) {
"use strict";
-var _require = __webpack_require__(0);
+var _require = __webpack_require__(1);
const at = _require.actionTypes;
-var _require2 = __webpack_require__(17);
+var _require2 = __webpack_require__(22);
const perfSvc = _require2.perfService;
const VISIBLE = "visible";
const VISIBILITY_CHANGE_EVENT = "visibilitychange";
module.exports = class DetectUserSessionStart {
@@ -490,31 +566,31 @@ module.exports = class DetectUserSession
if (this.document.visibilityState === VISIBLE) {
this._sendEvent();
this.document.removeEventListener(VISIBILITY_CHANGE_EVENT, this._onVisibilityChange);
}
}
};
/***/ }),
-/* 7 */
+/* 8 */
/***/ (function(module, exports, __webpack_require__) {
"use strict";
/* eslint-env mozilla/frame-script */
-var _require = __webpack_require__(18);
+var _require = __webpack_require__(24);
const createStore = _require.createStore,
combineReducers = _require.combineReducers,
applyMiddleware = _require.applyMiddleware;
-var _require2 = __webpack_require__(0);
+var _require2 = __webpack_require__(1);
const au = _require2.actionUtils;
const MERGE_STORE_ACTION = "NEW_TAB_INITIAL_STATE";
const OUTGOING_MESSAGE_NAME = "ActivityStream:ContentToMain";
const INCOMING_MESSAGE_NAME = "ActivityStream:MainToContent";
@@ -559,66 +635,340 @@ const messageMiddleware = store => next
*
* @param {object} reducers An object containing Redux reducers
* @return {object} A redux store
*/
module.exports = function initStore(reducers) {
const store = createStore(mergeStateReducer(combineReducers(reducers)), applyMiddleware(messageMiddleware));
addMessageListener(INCOMING_MESSAGE_NAME, msg => {
- store.dispatch(msg.data);
+ try {
+ store.dispatch(msg.data);
+ } catch (ex) {
+ console.error("Content msg:", msg, "Dispatch error: ", ex); // eslint-disable-line no-console
+ dump(`Content msg: ${ JSON.stringify(msg) }\nDispatch error: ${ ex }\n${ ex.stack }`);
+ }
});
return store;
};
module.exports.MERGE_STORE_ACTION = MERGE_STORE_ACTION;
module.exports.OUTGOING_MESSAGE_NAME = OUTGOING_MESSAGE_NAME;
module.exports.INCOMING_MESSAGE_NAME = INCOMING_MESSAGE_NAME;
/***/ }),
-/* 8 */
+/* 9 */
+/***/ (function(module, exports, __webpack_require__) {
+
+"use strict";
+/* WEBPACK VAR INJECTION */(function(global) {
+
+const DATABASE_NAME = "snippets_db";
+const DATABASE_VERSION = 1;
+const SNIPPETS_OBJECTSTORE_NAME = "snippets";
+const SNIPPETS_UPDATE_INTERVAL_MS = 14400000; // 4 hours.
+
+/**
+ * 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() {
+ super(...arguments);
+ this._db = null;
+ }
+
+ set(key, value) {
+ super.set(key, value);
+ return this._dbTransaction(db => db.put(value, key));
+ }
+
+ delete(key, value) {
+ super.delete(key);
+ return this._dbTransaction(db => db.delete(key));
+ }
+
+ clear() {
+ super.clear();
+ return this._dbTransaction(db => db.clear());
+ }
+
+ /**
+ * 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() {
+ // Initialize the Snippets Map and attaches it to a global so that
+ // the snippet payload can interact with it.
+ global.gSnippetsMap = new SnippetsMap();
+ }
+
+ 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.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.snippetsURL) {
+ this.snippetsMap.set("snippets-last-update", Date.now());
+ try {
+ // TODO: timeout?
+ const response = await fetch(this.snippetsURL);
+ if (response.status === 200) {
+ const payload = await response.text();
+
+ this.snippetsMap.set("snippets", payload);
+ this.snippetsMap.set("snippets-cached-version", this.version);
+ }
+ } catch (e) {
+ console.error(e); // eslint-disable-line no-console
+ }
+ }
+ }
+
+ _showDefaultSnippets() {
+ // TODO
+ }
+
+ _showRemoteSnippets() {
+ const snippetsEl = document.getElementById(this.elementId);
+ const containerEl = document.getElementById(this.containerElementId);
+ 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.");
+ }
+
+ // 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);
+ }
+
+ // Unhide the container if everything went OK
+ if (containerEl) {
+ containerEl.style.display = "block";
+ }
+ }
+
+ /**
+ * init - Fetch the snippet payload and show snippets
+ *
+ * @param {obj} options
+ * @param {str} options.snippetsURL The URL from which we fetch snippets
+ * @param {int} options.version The current snippets version
+ * @param {str} options.elementId The id of the element of the snippets container
+ */
+ async init(options) {
+ Object.assign(this, {
+ snippetsURL: "",
+ version: 0,
+ elementId: "snippets",
+ containerElementId: "snippets-container",
+ 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
+ }
+ }
+
+ // 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._showDefaultSnippets(e);
+ }
+ }
+}
+
+module.exports.SnippetsMap = SnippetsMap;
+module.exports.SnippetsProvider = SnippetsProvider;
+module.exports.SNIPPETS_UPDATE_INTERVAL_MS = SNIPPETS_UPDATE_INTERVAL_MS;
+/* WEBPACK VAR INJECTION */}.call(exports, __webpack_require__(23)))
+
+/***/ }),
+/* 10 */
/***/ (function(module, exports, __webpack_require__) {
"use strict";
/* 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/. */
-var _require = __webpack_require__(0);
+var _require = __webpack_require__(1);
const at = _require.actionTypes;
const INITIAL_STATE = {
App: {
// Have we received real data from the app yet?
initialized: false,
// The locale of the browser
locale: "",
// Localized strings with defaults
strings: {},
// The version of the system-addon
version: null
},
+ Snippets: { initialized: false },
TopSites: {
// Have we received real data from history yet?
initialized: false,
// The history (and possibly default) links
rows: []
},
Prefs: {
initialized: false,
values: {}
},
Dialog: {
visible: false,
data: {}
- }
+ },
+ Sections: []
};
function App() {
let prevState = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : INITIAL_STATE.App;
let action = arguments[1];
switch (action.type) {
case at.INIT:
@@ -696,29 +1046,35 @@ function TopSites() {
if (row && row.url === action.data.url) {
hasMatch = true;
return Object.assign({}, row, { screenshot: action.data.screenshot });
}
return row;
});
return hasMatch ? Object.assign({}, prevState, { rows: newRows }) : prevState;
case at.PLACES_BOOKMARK_ADDED:
+ if (!action.data) {
+ return prevState;
+ }
newRows = prevState.rows.map(site => {
if (site && site.url === action.data.url) {
var _action$data2 = action.data;
const bookmarkGuid = _action$data2.bookmarkGuid,
bookmarkTitle = _action$data2.bookmarkTitle,
lastModified = _action$data2.lastModified;
return Object.assign({}, site, { bookmarkGuid, bookmarkTitle, bookmarkDateCreated: lastModified });
}
return site;
});
return Object.assign({}, prevState, { rows: newRows });
case at.PLACES_BOOKMARK_REMOVED:
+ if (!action.data) {
+ return prevState;
+ }
newRows = prevState.rows.map(site => {
if (site && site.url === action.data.url) {
const newSite = Object.assign({}, site);
delete newSite.bookmarkGuid;
delete newSite.bookmarkTitle;
delete newSite.bookmarkDateCreated;
return newSite;
}
@@ -766,47 +1122,247 @@ function Prefs() {
newValues = Object.assign({}, prevState.values);
newValues[action.data.name] = action.data.value;
return Object.assign({}, prevState, { values: newValues });
default:
return prevState;
}
}
-var reducers = { TopSites, App, Prefs, Dialog };
+function Sections() {
+ let prevState = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : INITIAL_STATE.Sections;
+ let action = arguments[1];
+
+ let hasMatch;
+ let newState;
+ switch (action.type) {
+ case at.SECTION_DEREGISTER:
+ return prevState.filter(section => section.id !== action.data);
+ case at.SECTION_REGISTER:
+ // If section exists in prevState, update it
+ newState = prevState.map(section => {
+ if (section && section.id === action.data.id) {
+ hasMatch = true;
+ return Object.assign({}, section, action.data);
+ }
+ return section;
+ });
+ // If section doesn't exist in prevState, create a new section object and
+ // append it to the sections state
+ if (!hasMatch) {
+ const initialized = action.data.rows && action.data.rows.length > 0;
+ newState.push(Object.assign({ title: "", initialized, rows: [] }, action.data));
+ }
+ return newState;
+ case at.SECTION_ROWS_UPDATE:
+ return prevState.map(section => {
+ if (section && section.id === action.data.id) {
+ return Object.assign({}, section, action.data);
+ }
+ return section;
+ });
+ case at.PLACES_LINK_DELETED:
+ case at.PLACES_LINK_BLOCKED:
+ return prevState.map(section => Object.assign({}, section, { rows: section.rows.filter(site => site.url !== action.data.url) }));
+ default:
+ return prevState;
+ }
+}
+
+function Snippets() {
+ let prevState = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : INITIAL_STATE.Snippets;
+ let action = arguments[1];
+
+ switch (action.type) {
+ case at.SNIPPETS_DATA:
+ return Object.assign({}, prevState, { initialized: true }, action.data);
+ case at.SNIPPETS_RESET:
+ return INITIAL_STATE.Snippets;
+ default:
+ return prevState;
+ }
+}
+
+var reducers = { TopSites, App, Snippets, Prefs, Dialog, Sections };
module.exports = {
reducers,
INITIAL_STATE,
insertPinned
};
/***/ }),
-/* 9 */
+/* 11 */
/***/ (function(module, exports) {
module.exports = ReactDOM;
/***/ }),
-/* 10 */
+/* 12 */
/***/ (function(module, exports, __webpack_require__) {
"use strict";
-const React = __webpack_require__(1);
+const React = __webpack_require__(0);
+const LinkMenu = __webpack_require__(5);
+const shortURL = __webpack_require__(4);
var _require = __webpack_require__(2);
+const FormattedMessage = _require.FormattedMessage;
+
+const cardContextTypes = __webpack_require__(13);
+
+/**
+ * Card component.
+ * Cards are found within a Section component and contain information about a link such
+ * as preview image, page title, page description, and some context about if the page
+ * was visited, bookmarked, trending etc...
+ * Each Section can make an unordered list of Cards which will create one instane of
+ * this class. Each card will then get a context menu which reflects the actions that
+ * can be done on this Card.
+ */
+class Card extends React.Component {
+ constructor(props) {
+ super(props);
+ this.state = { showContextMenu: false, activeCard: null };
+ }
+ toggleContextMenu(event, index) {
+ this.setState({ showContextMenu: true, activeCard: index });
+ }
+ render() {
+ var _props = this.props;
+ const index = _props.index,
+ link = _props.link,
+ dispatch = _props.dispatch,
+ contextMenuOptions = _props.contextMenuOptions;
+
+ const isContextMenuOpen = this.state.showContextMenu && this.state.activeCard === index;
+ const hostname = shortURL(link);
+ var _cardContextTypes$lin = cardContextTypes[link.type];
+ const icon = _cardContextTypes$lin.icon,
+ intlID = _cardContextTypes$lin.intlID;
+
+
+ return React.createElement(
+ "li",
+ { className: `card-outer${ isContextMenuOpen ? " active" : "" }` },
+ React.createElement(
+ "a",
+ { href: link.url },
+ React.createElement(
+ "div",
+ { className: "card" },
+ link.image && React.createElement("div", { className: "card-preview-image", style: { backgroundImage: `url(${ link.image })` } }),
+ React.createElement(
+ "div",
+ { className: "card-details" },
+ React.createElement(
+ "div",
+ { className: "card-host-name" },
+ " ",
+ hostname,
+ " "
+ ),
+ React.createElement(
+ "div",
+ { className: `card-text${ link.image ? "" : " full-height" }` },
+ React.createElement(
+ "h4",
+ { className: "card-title" },
+ " ",
+ link.title,
+ " "
+ ),
+ React.createElement(
+ "p",
+ { className: "card-description" },
+ " ",
+ link.description,
+ " "
+ )
+ ),
+ React.createElement(
+ "div",
+ { className: "card-context" },
+ React.createElement("span", { className: `card-context-icon icon icon-${ icon }` }),
+ React.createElement(
+ "div",
+ { className: "card-context-label" },
+ React.createElement(FormattedMessage, { id: intlID, defaultMessage: "Visited" })
+ )
+ )
+ )
+ )
+ ),
+ React.createElement(
+ "button",
+ { className: "context-menu-button",
+ onClick: e => {
+ e.preventDefault();
+ this.toggleContextMenu(e, index);
+ } },
+ React.createElement(
+ "span",
+ { className: "sr-only" },
+ `Open context menu for ${ link.title }`
+ )
+ ),
+ React.createElement(LinkMenu, {
+ dispatch: dispatch,
+ visible: isContextMenuOpen,
+ onUpdate: val => this.setState({ showContextMenu: val }),
+ index: index,
+ site: link,
+ options: link.context_menu_options || contextMenuOptions })
+ );
+ }
+}
+module.exports = Card;
+
+/***/ }),
+/* 13 */
+/***/ (function(module, exports, __webpack_require__) {
+
+"use strict";
+
+
+module.exports = {
+ history: {
+ intlID: "type_label_visited",
+ icon: "historyItem"
+ },
+ bookmark: {
+ intlID: "type_label_bookmarked",
+ icon: "bookmark"
+ },
+ trending: {
+ intlID: "type_label_recommended",
+ icon: "trending"
+ }
+};
+
+/***/ }),
+/* 14 */
+/***/ (function(module, exports, __webpack_require__) {
+
+"use strict";
+
+
+const React = __webpack_require__(0);
+
+var _require = __webpack_require__(3);
+
const connect = _require.connect;
-var _require2 = __webpack_require__(3);
+var _require2 = __webpack_require__(2);
const FormattedMessage = _require2.FormattedMessage;
-var _require3 = __webpack_require__(0);
+var _require3 = __webpack_require__(1);
const actionTypes = _require3.actionTypes,
ac = _require3.actionCreators;
/**
* ConfirmDialog component.
* One primary action button, one cancel button.
*
@@ -899,23 +1455,23 @@ const ConfirmDialog = React.createClass(
}
});
module.exports = connect(state => state.Dialog)(ConfirmDialog);
module.exports._unconnected = ConfirmDialog;
module.exports.Dialog = ConfirmDialog;
/***/ }),
-/* 11 */
+/* 15 */
/***/ (function(module, exports, __webpack_require__) {
"use strict";
-const React = __webpack_require__(1);
+const React = __webpack_require__(0);
class ContextMenu extends React.Component {
constructor(props) {
super(props);
this.hideContext = this.hideContext.bind(this);
}
hideContext() {
this.props.onUpdate(false);
@@ -969,120 +1525,47 @@ class ContextMenu extends React.Componen
React.createElement(
"a",
{ tabIndex: "0",
onKeyDown: e => this.onKeyDown(e, option),
onClick: () => {
this.hideContext();
option.onClick();
} },
- option.icon && React.createElement("span", { className: `icon icon-spacer icon-${option.icon}` }),
+ option.icon && React.createElement("span", { className: `icon icon-spacer icon-${ option.icon }` }),
option.label
)
);
})
)
);
}
}
module.exports = ContextMenu;
/***/ }),
-/* 12 */
+/* 16 */
/***/ (function(module, exports, __webpack_require__) {
"use strict";
-const React = __webpack_require__(1);
+const React = __webpack_require__(0);
var _require = __webpack_require__(3);
-const injectIntl = _require.injectIntl;
-
-const ContextMenu = __webpack_require__(11);
-
-var _require2 = __webpack_require__(0);
-
-const ac = _require2.actionCreators;
-
-const linkMenuOptions = __webpack_require__(16);
-const DEFAULT_SITE_MENU_OPTIONS = ["CheckPinTopSite", "Separator", "OpenInNewWindow", "OpenInPrivateWindow"];
-
-class LinkMenu extends React.Component {
- getOptions() {
- const props = this.props;
- const site = props.site,
- index = props.index,
- source = props.source;
-
- // Handle special case of default site
-
- const propOptions = !site.isDefault ? props.options : DEFAULT_SITE_MENU_OPTIONS;
-
- const options = propOptions.map(o => linkMenuOptions[o](site, index)).map(option => {
- const action = option.action,
- id = option.id,
- type = option.type,
- userEvent = option.userEvent;
-
- if (!type && id) {
- option.label = props.intl.formatMessage(option);
- option.onClick = () => {
- props.dispatch(action);
- if (userEvent) {
- props.dispatch(ac.UserEvent({
- event: userEvent,
- source,
- action_position: index
- }));
- }
- };
- }
- return option;
- });
-
- // This is for accessibility to support making each item tabbable.
- // We want to know which item is the first and which item
- // is the last, so we can close the context menu accordingly.
- options[0].first = true;
- options[options.length - 1].last = true;
- return options;
- }
- render() {
- return React.createElement(ContextMenu, {
- visible: this.props.visible,
- onUpdate: this.props.onUpdate,
- options: this.getOptions() });
- }
-}
-
-module.exports = injectIntl(LinkMenu);
-module.exports._unconnected = LinkMenu;
-
-/***/ }),
-/* 13 */
-/***/ (function(module, exports, __webpack_require__) {
-
-"use strict";
-
-
-const React = __webpack_require__(1);
-
-var _require = __webpack_require__(2);
-
const connect = _require.connect;
-var _require2 = __webpack_require__(3);
+var _require2 = __webpack_require__(2);
const injectIntl = _require2.injectIntl,
FormattedMessage = _require2.FormattedMessage;
-var _require3 = __webpack_require__(0);
+var _require3 = __webpack_require__(1);
const ac = _require3.actionCreators;
const PreferencesInput = props => React.createElement(
"section",
null,
React.createElement("input", { type: "checkbox", id: props.prefName, name: props.prefName, checked: props.value, onChange: props.onChange, className: props.className }),
@@ -1133,43 +1616,45 @@ class PreferencesPane extends React.Comp
const isVisible = this.state.visible;
return React.createElement(
"div",
{ className: "prefs-pane-wrapper", ref: "wrapper" },
React.createElement(
"div",
{ className: "prefs-pane-button" },
React.createElement("button", {
- className: `prefs-button icon ${isVisible ? "icon-dismiss" : "icon-settings"}`,
+ className: `prefs-button icon ${ isVisible ? "icon-dismiss" : "icon-settings" }`,
title: props.intl.formatMessage({ id: isVisible ? "settings_pane_done_button" : "settings_pane_button_label" }),
onClick: this.togglePane })
),
React.createElement(
"div",
{ className: "prefs-pane" },
React.createElement(
"div",
- { className: `sidebar ${isVisible ? "" : "hidden"}` },
+ { className: `sidebar ${ isVisible ? "" : "hidden" }` },
React.createElement(
"div",
{ className: "prefs-modal-inner-wrapper" },
React.createElement(
"h1",
null,
React.createElement(FormattedMessage, { id: "settings_pane_header" })
),
React.createElement(
"p",
null,
React.createElement(FormattedMessage, { id: "settings_pane_body" })
),
React.createElement(PreferencesInput, { className: "showSearch", prefName: "showSearch", value: prefs.showSearch, onChange: this.handleChange,
titleStringId: "settings_pane_search_header", descStringId: "settings_pane_search_body" }),
React.createElement(PreferencesInput, { className: "showTopSites", prefName: "showTopSites", value: prefs.showTopSites, onChange: this.handleChange,
- titleStringId: "settings_pane_topsites_header", descStringId: "settings_pane_topsites_body" })
+ titleStringId: "settings_pane_topsites_header", descStringId: "settings_pane_topsites_body" }),
+ React.createElement(PreferencesInput, { className: "showTopStories", prefName: "feeds.section.topstories", value: prefs["feeds.section.topstories"], onChange: this.handleChange,
+ titleStringId: "settings_pane_pocketstories_header", descStringId: "settings_pane_pocketstories_body" })
),
React.createElement(
"section",
{ className: "actions" },
React.createElement(
"button",
{ className: "done", onClick: this.togglePane },
React.createElement(FormattedMessage, { id: "settings_pane_done_button" })
@@ -1181,35 +1666,35 @@ class PreferencesPane extends React.Comp
}
}
module.exports = connect(state => ({ Prefs: state.Prefs }))(injectIntl(PreferencesPane));
module.exports.PreferencesPane = PreferencesPane;
module.exports.PreferencesInput = PreferencesInput;
/***/ }),
-/* 14 */
+/* 17 */
/***/ (function(module, exports, __webpack_require__) {
"use strict";
/* globals ContentSearchUIController */
-const React = __webpack_require__(1);
+const React = __webpack_require__(0);
-var _require = __webpack_require__(2);
+var _require = __webpack_require__(3);
const connect = _require.connect;
-var _require2 = __webpack_require__(3);
+var _require2 = __webpack_require__(2);
const FormattedMessage = _require2.FormattedMessage,
injectIntl = _require2.injectIntl;
-var _require3 = __webpack_require__(0);
+var _require3 = __webpack_require__(1);
const ac = _require3.actionCreators;
class Search extends React.Component {
constructor(props) {
super(props);
this.onClick = this.onClick.bind(this);
@@ -1280,36 +1765,156 @@ class Search extends React.Component {
);
}
}
module.exports = connect()(injectIntl(Search));
module.exports._unconnected = Search;
/***/ }),
-/* 15 */
+/* 18 */
/***/ (function(module, exports, __webpack_require__) {
"use strict";
-const React = __webpack_require__(1);
+var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; };
-var _require = __webpack_require__(2);
+const React = __webpack_require__(0);
+
+var _require = __webpack_require__(3);
const connect = _require.connect;
-var _require2 = __webpack_require__(3);
+var _require2 = __webpack_require__(2);
+
+const FormattedMessage = _require2.FormattedMessage;
+
+const Card = __webpack_require__(12);
+const Topics = __webpack_require__(20);
+
+class Section extends React.Component {
+ render() {
+ var _props = this.props;
+ const id = _props.id,
+ title = _props.title,
+ icon = _props.icon,
+ rows = _props.rows,
+ infoOption = _props.infoOption,
+ emptyState = _props.emptyState,
+ dispatch = _props.dispatch,
+ maxCards = _props.maxCards,
+ contextMenuOptions = _props.contextMenuOptions;
+
+ const initialized = rows && rows.length > 0;
+ const shouldShowTopics = id === "TopStories" && this.props.topics && this.props.read_more_endpoint;
+ // <Section> <-- React component
+ // <section> <-- HTML5 element
+ return React.createElement(
+ "section",
+ null,
+ React.createElement(
+ "div",
+ { className: "section-top-bar" },
+ React.createElement(
+ "h3",
+ { className: "section-title" },
+ React.createElement("span", { className: `icon icon-small-spacer icon-${ icon }` }),
+ React.createElement(FormattedMessage, title)
+ ),
+ infoOption && React.createElement(
+ "span",
+ { className: "section-info-option" },
+ React.createElement(
+ "span",
+ { className: "sr-only" },
+ React.createElement(FormattedMessage, { id: "section_info_option" })
+ ),
+ React.createElement("img", { className: "info-option-icon" }),
+ React.createElement(
+ "div",
+ { className: "info-option" },
+ infoOption.header && React.createElement(
+ "div",
+ { className: "info-option-header" },
+ React.createElement(FormattedMessage, infoOption.header)
+ ),
+ infoOption.body && React.createElement(
+ "p",
+ { className: "info-option-body" },
+ React.createElement(FormattedMessage, infoOption.body)
+ ),
+ infoOption.link && React.createElement(
+ "a",
+ { href: infoOption.link.href, target: "_blank", rel: "noopener noreferrer", className: "info-option-link" },
+ React.createElement(FormattedMessage, infoOption.link)
+ )
+ )
+ )
+ ),
+ React.createElement(
+ "ul",
+ { className: "section-list", style: { padding: 0 } },
+ rows.slice(0, maxCards).map((link, index) => link && React.createElement(Card, { index: index, dispatch: dispatch, link: link, contextMenuOptions: contextMenuOptions }))
+ ),
+ !initialized && React.createElement(
+ "div",
+ { className: "section-empty-state" },
+ React.createElement(
+ "div",
+ { className: "empty-state" },
+ React.createElement("img", { className: `empty-state-icon icon icon-${ emptyState.icon }` }),
+ React.createElement(
+ "p",
+ { className: "empty-state-message" },
+ React.createElement(FormattedMessage, emptyState.message)
+ )
+ )
+ ),
+ shouldShowTopics && React.createElement(Topics, { topics: this.props.topics, read_more_endpoint: this.props.read_more_endpoint })
+ );
+ }
+}
+
+class Sections extends React.Component {
+ render() {
+ const sections = this.props.Sections;
+ return React.createElement(
+ "div",
+ { className: "sections-list" },
+ sections.map(section => React.createElement(Section, _extends({ key: section.id }, section, { dispatch: this.props.dispatch })))
+ );
+ }
+}
+
+module.exports = connect(state => ({ Sections: state.Sections }))(Sections);
+module.exports._unconnected = Sections;
+module.exports.Section = Section;
+
+/***/ }),
+/* 19 */
+/***/ (function(module, exports, __webpack_require__) {
+
+"use strict";
+
+
+const React = __webpack_require__(0);
+
+var _require = __webpack_require__(3);
+
+const connect = _require.connect;
+
+var _require2 = __webpack_require__(2);
const FormattedMessage = _require2.FormattedMessage;
const shortURL = __webpack_require__(4);
-const LinkMenu = __webpack_require__(12);
+const LinkMenu = __webpack_require__(5);
-var _require3 = __webpack_require__(0);
+var _require3 = __webpack_require__(1);
const ac = _require3.actionCreators;
const TOP_SITES_SOURCE = "TOP_SITES";
const TOP_SITES_CONTEXT_MENU_OPTIONS = ["CheckPinTopSite", "Separator", "OpenInNewWindow", "OpenInPrivateWindow", "Separator", "BlockUrl", "DeleteUrl"];
class TopSite extends React.Component {
constructor(props) {
@@ -1329,38 +1934,38 @@ class TopSite extends React.Component {
render() {
var _props = this.props;
const link = _props.link,
index = _props.index,
dispatch = _props.dispatch;
const isContextMenuOpen = this.state.showContextMenu && this.state.activeTile === index;
const title = link.pinTitle || shortURL(link);
- const screenshotClassName = `screenshot${link.screenshot ? " active" : ""}`;
- const topSiteOuterClassName = `top-site-outer${isContextMenuOpen ? " active" : ""}`;
- const style = { backgroundImage: link.screenshot ? `url(${link.screenshot})` : "none" };
+ const screenshotClassName = `screenshot${ link.screenshot ? " active" : "" }`;
+ const topSiteOuterClassName = `top-site-outer${ isContextMenuOpen ? " active" : "" }`;
+ const style = { backgroundImage: link.screenshot ? `url(${ link.screenshot })` : "none" };
return React.createElement(
"li",
- { className: topSiteOuterClassName, key: link.url },
+ { className: topSiteOuterClassName, key: link.guid || link.url },
React.createElement(
"a",
{ onClick: () => this.trackClick(), href: link.url },
React.createElement(
"div",
{ className: "tile", "aria-hidden": true },
React.createElement(
"span",
{ className: "letter-fallback" },
title[0]
),
React.createElement("div", { className: screenshotClassName, style: style })
),
React.createElement(
"div",
- { className: `title ${link.isPinned ? "pinned" : ""}` },
+ { className: `title ${ link.isPinned ? "pinned" : "" }` },
link.isPinned && React.createElement("div", { className: "icon icon-pin-small" }),
React.createElement(
"span",
null,
title
)
)
),
@@ -1369,17 +1974,17 @@ class TopSite extends React.Component {
{ className: "context-menu-button",
onClick: e => {
e.preventDefault();
this.toggleContextMenu(e, index);
} },
React.createElement(
"span",
{ className: "sr-only" },
- `Open context menu for ${title}`
+ `Open context menu for ${ title }`
)
),
React.createElement(LinkMenu, {
dispatch: dispatch,
visible: isContextMenuOpen,
onUpdate: val => this.setState({ showContextMenu: val }),
site: link,
index: index,
@@ -1396,35 +2001,100 @@ const TopSites = props => React.createEl
"h3",
{ className: "section-title" },
React.createElement(FormattedMessage, { id: "header_top_sites" })
),
React.createElement(
"ul",
{ className: "top-sites-list" },
props.TopSites.rows.map((link, index) => link && React.createElement(TopSite, {
- key: link.url,
+ key: link.guid || link.url,
dispatch: props.dispatch,
link: link,
index: index }))
)
);
module.exports = connect(state => ({ TopSites: state.TopSites }))(TopSites);
module.exports._unconnected = TopSites;
module.exports.TopSite = TopSite;
/***/ }),
-/* 16 */
+/* 20 */
/***/ (function(module, exports, __webpack_require__) {
"use strict";
-var _require = __webpack_require__(0);
+const React = __webpack_require__(0);
+
+var _require = __webpack_require__(2);
+
+const FormattedMessage = _require.FormattedMessage;
+
+
+class Topic extends React.Component {
+ render() {
+ var _props = this.props;
+ const url = _props.url,
+ name = _props.name;
+
+ return React.createElement(
+ "li",
+ null,
+ React.createElement(
+ "a",
+ { key: name, className: "topic-link", href: url },
+ name
+ )
+ );
+ }
+}
+
+class Topics extends React.Component {
+ render() {
+ var _props2 = this.props;
+ const topics = _props2.topics,
+ read_more_endpoint = _props2.read_more_endpoint;
+
+ return React.createElement(
+ "div",
+ { className: "topic" },
+ React.createElement(
+ "span",
+ null,
+ React.createElement(FormattedMessage, { id: "pocket_read_more" })
+ ),
+ React.createElement(
+ "ul",
+ null,
+ topics.map(t => React.createElement(Topic, { key: t.name, url: t.url, name: t.name }))
+ ),
+ React.createElement(
+ "a",
+ { className: "topic-read-more", href: read_more_endpoint },
+ React.createElement(FormattedMessage, { id: "pocket_read_even_more" }),
+ React.createElement("span", { className: "topic-read-more-logo" })
+ )
+ );
+ }
+}
+
+module.exports = Topics;
+module.exports._unconnected = Topics;
+module.exports.Topic = Topic;
+
+/***/ }),
+/* 21 */
+/***/ (function(module, exports, __webpack_require__) {
+
+"use strict";
+
+
+var _require = __webpack_require__(1);
const at = _require.actionTypes,
ac = _require.actionCreators;
const shortURL = __webpack_require__(4);
/**
* List of functions that return items that can be included as menu options in a
@@ -1519,17 +2189,17 @@ module.exports = {
userEvent: "SAVE_TO_POCKET"
})
};
module.exports.CheckBookmark = site => site.bookmarkGuid ? module.exports.RemoveBookmark(site) : module.exports.AddBookmark(site);
module.exports.CheckPinTopSite = (site, index) => site.isPinned ? module.exports.UnpinTopSite(site) : module.exports.PinTopSite(site, index);
/***/ }),
-/* 17 */
+/* 22 */
/***/ (function(module, exports, __webpack_require__) {
"use strict";
/* globals Services */
let usablePerfObj;
@@ -1609,63 +2279,108 @@ var _PerfService = function _PerfService
* @return {Number} the returned start time, as a DOMHighResTimeStamp
*
* @throws {Error} "No Marks with the name ..." if none are available
*/
getMostRecentAbsMarkStartByName(name) {
let entries = this.getEntriesByName(name, "mark");
if (!entries.length) {
- throw new Error(`No marks with the name ${name}`);
+ throw new Error(`No marks with the name ${ name }`);
}
let mostRecentEntry = entries[entries.length - 1];
return this._perf.timeOrigin + mostRecentEntry.startTime;
}
};
var perfService = new _PerfService();
module.exports = {
_PerfService,
perfService
};
/***/ }),
-/* 18 */
+/* 23 */
+/***/ (function(module, exports) {
+
+var g;
+
+// This works in non-strict mode
+g = (function() {
+ return this;
+})();
+
+try {
+ // This works if eval is allowed (see CSP)
+ g = g || Function("return this")() || (1,eval)("this");
+} catch(e) {
+ // This works if the window reference is available
+ if(typeof window === "object")
+ g = window;
+}
+
+// g can still be undefined, but nothing to do about it...
+// We return undefined, instead of nothing here, so it's
+// easier to handle this case. if(!global) { ...}
+
+module.exports = g;
+
+
+/***/ }),
+/* 24 */
/***/ (function(module, exports) {
module.exports = Redux;
/***/ }),
-/* 19 */
+/* 25 */
/***/ (function(module, exports, __webpack_require__) {
"use strict";
-const React = __webpack_require__(1);
-const ReactDOM = __webpack_require__(9);
-const Base = __webpack_require__(5);
+const React = __webpack_require__(0);
+const ReactDOM = __webpack_require__(11);
+const Base = __webpack_require__(6);
-var _require = __webpack_require__(2);
+var _require = __webpack_require__(3);
const Provider = _require.Provider;
-const initStore = __webpack_require__(7);
+const initStore = __webpack_require__(8);
-var _require2 = __webpack_require__(8);
+var _require2 = __webpack_require__(10);
const reducers = _require2.reducers;
-const DetectUserSessionStart = __webpack_require__(6);
+const DetectUserSessionStart = __webpack_require__(7);
+
+var _require3 = __webpack_require__(9);
+
+const SnippetsProvider = _require3.SnippetsProvider;
+
new DetectUserSessionStart().sendEventOrAddListener();
const store = initStore(reducers);
ReactDOM.render(React.createElement(
Provider,
{ store: store },
React.createElement(Base, null)
), document.getElementById("root"));
+// Trigger snippets when snippets data has been received.
+const snippets = new SnippetsProvider();
+const unsubscribe = store.subscribe(() => {
+ const state = store.getState();
+ if (state.Snippets.initialized) {
+ snippets.init({
+ snippetsURL: state.Snippets.snippetsURL,
+ version: state.Snippets.version
+ });
+ unsubscribe();
+ }
+});
+
/***/ })
/******/ ]);
\ No newline at end of file
--- a/browser/extensions/activity-stream/data/content/activity-stream.css
+++ b/browser/extensions/activity-stream/data/content/activity-stream.css
@@ -1,8 +1,9 @@
+@charset "UTF-8";
html {
box-sizing: border-box; }
*,
*::before,
*::after {
box-sizing: inherit; }
@@ -25,16 +26,18 @@ input {
width: 16px;
height: 16px;
background-size: 16px;
background-position: center center;
background-repeat: no-repeat;
vertical-align: middle; }
.icon.icon-spacer {
margin-inline-end: 8px; }
+ .icon.icon-small-spacer {
+ margin-inline-end: 6px; }
.icon.icon-bookmark {
background-image: url("assets/glyph-bookmark-16.svg"); }
.icon.icon-bookmark-remove {
background-image: url("assets/glyph-bookmark-remove-16.svg"); }
.icon.icon-delete {
background-image: url("assets/glyph-delete-16.svg"); }
.icon.icon-dismiss {
background-image: url("assets/glyph-dismiss-16.svg"); }
@@ -45,21 +48,29 @@ input {
.icon.icon-settings {
background-image: url("assets/glyph-settings-16.svg"); }
.icon.icon-pin {
background-image: url("assets/glyph-pin-16.svg"); }
.icon.icon-unpin {
background-image: url("assets/glyph-unpin-16.svg"); }
.icon.icon-pocket {
background-image: url("assets/glyph-pocket-16.svg"); }
+ .icon.icon-historyItem {
+ background-image: url("assets/glyph-historyItem-16.svg"); }
+ .icon.icon-trending {
+ background-image: url("assets/glyph-trending-16.svg"); }
+ .icon.icon-now {
+ background-image: url("assets/glyph-now-16.svg"); }
.icon.icon-pin-small {
background-image: url("assets/glyph-pin-12.svg");
background-size: 12px;
height: 12px;
width: 12px; }
+ .icon.icon-check {
+ background-image: url("chrome://browser/skin/check.svg"); }
html,
body,
#root {
height: 100%; }
body {
background: #F6F6F8;
@@ -129,32 +140,45 @@ a {
box-shadow: 0 0 0 5px rgba(0, 0, 0, 0.1);
transition: box-shadow 150ms; }
.actions button.done {
background: #0695F9;
border: solid 1px #1677CF;
color: #FFF;
margin-inline-start: auto; }
+#snippets-container {
+ display: none;
+ position: fixed;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ background: white;
+ height: 122px; }
+
+#snippets {
+ max-width: 736px;
+ margin: 0 auto; }
+
.outer-wrapper {
display: flex;
flex-grow: 1;
padding: 62px 32px 32px;
height: 100%; }
main {
margin: auto; }
@media (min-width: 672px) {
main {
width: 608px; } }
@media (min-width: 800px) {
main {
width: 736px; } }
main section {
- margin-bottom: 41px; }
+ margin-bottom: 40px; }
.section-title {
color: #6E707E;
font-size: 13px;
font-weight: bold;
text-transform: uppercase;
margin: 0 0 18px; }
@@ -200,20 +224,20 @@ main {
transform: scale(0.25);
opacity: 0;
transition-property: transform, opacity;
transition-duration: 200ms;
z-index: 399; }
.top-sites-list .top-site-outer .context-menu-button:focus, .top-sites-list .top-site-outer .context-menu-button:active {
transform: scale(1);
opacity: 1; }
- .top-sites-list .top-site-outer:hover .tile, .top-sites-list .top-site-outer:active .tile, .top-sites-list .top-site-outer:focus .tile, .top-sites-list .top-site-outer.active .tile {
+ .top-sites-list .top-site-outer:hover .tile, .top-sites-list .top-site-outer:focus .tile, .top-sites-list .top-site-outer.active .tile {
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.1), 0 0 0 5px rgba(0, 0, 0, 0.1);
transition: box-shadow 150ms; }
- .top-sites-list .top-site-outer:hover .context-menu-button, .top-sites-list .top-site-outer:active .context-menu-button, .top-sites-list .top-site-outer:focus .context-menu-button, .top-sites-list .top-site-outer.active .context-menu-button {
+ .top-sites-list .top-site-outer:hover .context-menu-button, .top-sites-list .top-site-outer:focus .context-menu-button, .top-sites-list .top-site-outer.active .context-menu-button {
transform: scale(1);
opacity: 1; }
.top-sites-list .top-site-outer .tile {
position: relative;
height: 96px;
width: 96px;
border-radius: 6px;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.1);
@@ -253,16 +277,127 @@ main {
.top-sites-list .top-site-outer .title span {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap; }
.top-sites-list .top-site-outer .title.pinned span {
padding: 0 13px; }
+.sections-list .section-top-bar {
+ position: relative;
+ height: 16px;
+ margin-bottom: 18px; }
+ .sections-list .section-top-bar .section-title {
+ float: left; }
+ .sections-list .section-top-bar .section-info-option {
+ float: right; }
+ .sections-list .section-top-bar .info-option-icon {
+ background-image: url("assets/glyph-info-option-12.svg");
+ background-size: 12px 12px;
+ background-repeat: no-repeat;
+ background-position: center;
+ height: 16px;
+ width: 16px;
+ display: inline-block; }
+ .sections-list .section-top-bar .section-info-option div {
+ visibility: hidden;
+ opacity: 0;
+ transition: visibility 0.2s, opacity 0.2s ease-out;
+ transition-delay: 0.5s; }
+ .sections-list .section-top-bar .section-info-option:hover div {
+ visibility: visible;
+ opacity: 1;
+ transition: visibility 0.2s, opacity 0.2s ease-out; }
+ .sections-list .section-top-bar .info-option {
+ z-index: 9999;
+ position: absolute;
+ background: #FFF;
+ border: solid 1px rgba(0, 0, 0, 0.1);
+ border-radius: 3px;
+ font-size: 13px;
+ color: #0C0C0D;
+ line-height: 120%;
+ width: 320px;
+ right: 0;
+ top: 34px;
+ margin-top: -4px;
+ margin-right: -4px;
+ padding: 24px;
+ -moz-user-select: none; }
+ .sections-list .section-top-bar .info-option-header {
+ font-size: 15px;
+ font-weight: 600; }
+ .sections-list .section-top-bar .info-option-body {
+ margin: 0;
+ margin-top: 12px; }
+ .sections-list .section-top-bar .info-option-link {
+ display: block;
+ margin-top: 12px;
+ color: #0A84FF; }
+
+.sections-list .section-list {
+ width: 768px;
+ clear: both;
+ margin: 0; }
+
+.sections-list .section-empty-state {
+ width: 100%;
+ height: 266px;
+ display: flex;
+ border: solid 1px rgba(0, 0, 0, 0.1);
+ border-radius: 3px; }
+ .sections-list .section-empty-state .empty-state {
+ margin: auto;
+ max-width: 350px; }
+ .sections-list .section-empty-state .empty-state .empty-state-icon {
+ background-size: 50px 50px;
+ background-repeat: no-repeat;
+ background-position: center;
+ fill: rgba(160, 160, 160, 0.4);
+ -moz-context-properties: fill;
+ height: 50px;
+ width: 50px;
+ margin: 0 auto;
+ display: block; }
+ .sections-list .section-empty-state .empty-state .empty-state-message {
+ margin-bottom: 0;
+ font-size: 13px;
+ font-weight: 300;
+ color: #A0A0A0;
+ text-align: center; }
+
+.topic {
+ font-size: 13px;
+ color: #BFC0C7;
+ min-width: 780px; }
+ .topic ul {
+ display: inline;
+ padding-left: 12px; }
+ .topic ul li {
+ display: inline; }
+ .topic ul li::after {
+ content: '•';
+ padding-left: 8px;
+ padding-right: 8px; }
+ .topic ul li:last-child::after {
+ content: none; }
+ .topic .topic-link {
+ color: #008EA4; }
+ .topic .topic-read-more {
+ float: right;
+ margin-right: 40px;
+ color: #008EA4; }
+ .topic .topic-read-more-logo {
+ padding-right: 10px;
+ margin-left: 5px;
+ background-image: url("assets/topic-show-more-12.svg");
+ background-repeat: no-repeat;
+ background-position-y: 2px; }
+
.search-wrapper {
cursor: default;
display: flex;
position: relative;
margin: 0 0 48px;
width: 100%;
height: 36px; }
.search-wrapper input {
@@ -511,8 +646,114 @@ main {
z-index: 11001; }
.modal {
background: #FFF;
border: solid 1px rgba(0, 0, 0, 0.1);
border-radius: 3px;
font-size: 14px;
z-index: 11002; }
+
+.card-outer {
+ background: #FFF;
+ display: inline-block;
+ margin-inline-end: 32px;
+ margin-bottom: 16px;
+ width: 224px;
+ border-radius: 3px;
+ border-color: rgba(0, 0, 0, 0.1);
+ height: 266px;
+ position: relative; }
+ .card-outer .context-menu-button {
+ cursor: pointer;
+ position: absolute;
+ top: -13.5px;
+ offset-inline-end: -13.5px;
+ width: 27px;
+ height: 27px;
+ background-color: #FFF;
+ background-image: url("assets/glyph-more-16.svg");
+ background-position: 65%;
+ background-repeat: no-repeat;
+ background-clip: padding-box;
+ border: 1px solid rgba(0, 0, 0, 0.2);
+ border-radius: 100%;
+ box-shadow: 0 2px 0 rgba(0, 0, 0, 0.1);
+ transform: scale(0.25);
+ opacity: 0;
+ transition-property: transform, opacity;
+ transition-duration: 200ms;
+ z-index: 399; }
+ .card-outer .context-menu-button:focus, .card-outer .context-menu-button:active {
+ transform: scale(1);
+ opacity: 1; }
+ .card-outer .card {
+ height: 100%;
+ border-radius: 3px; }
+ .card-outer > a {
+ display: block;
+ color: inherit;
+ height: 100%;
+ outline: none;
+ position: absolute; }
+ .card-outer > a.active .card, .card-outer > a:focus .card {
+ box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.1), 0 0 0 5px rgba(0, 0, 0, 0.1);
+ transition: box-shadow 150ms; }
+ .card-outer:hover, .card-outer:focus, .card-outer.active {
+ outline: none;
+ box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.1), 0 0 0 5px rgba(0, 0, 0, 0.1);
+ transition: box-shadow 150ms; }
+ .card-outer:hover .context-menu-button, .card-outer:focus .context-menu-button, .card-outer.active .context-menu-button {
+ transform: scale(1);
+ opacity: 1; }
+ .card-outer .card-preview-image {
+ position: relative;
+ background-size: cover;
+ background-position: center;
+ background-repeat: no-repeat;
+ height: 122px;
+ border-bottom-color: rgba(0, 0, 0, 0.1);
+ border-bottom-style: solid;
+ border-bottom-width: 1px;
+ border-radius: 3px 3px 0 0; }
+ .card-outer .card-details {
+ padding: 10px 16px 12px; }
+ .card-outer .card-text {
+ overflow: hidden;
+ max-height: 78px; }
+ .card-outer .card-text.full-height {
+ max-height: 200px; }
+ .card-outer .card-host-name {
+ color: #858585;
+ font-size: 10px;
+ padding-bottom: 4px;
+ text-transform: uppercase; }
+ .card-outer .card-title {
+ margin: 0 0 2px;
+ font-size: inherit;
+ word-wrap: break-word; }
+ .card-outer .card-description {
+ font-size: 12px;
+ margin: 0;
+ word-wrap: break-word;
+ overflow: hidden;
+ line-height: 18px;
+ max-height: 34px; }
+ .card-outer .card-context {
+ padding: 16px 16px 14px 14px;
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ color: #A0A0A0;
+ font-size: 11px;
+ display: flex;
+ align-items: center; }
+ .card-outer .card-context-icon {
+ opacity: 0.5;
+ font-size: 13px;
+ margin-inline-end: 6px;
+ display: block; }
+ .card-outer .card-context-label {
+ flex-grow: 1;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap; }
--- a/browser/extensions/activity-stream/data/content/activity-stream.html
+++ b/browser/extensions/activity-stream/data/content/activity-stream.html
@@ -3,16 +3,20 @@
<head>
<meta charset="utf-8">
<title></title>
<link rel="stylesheet" href="chrome://browser/content/contentSearchUI.css" />
<link rel="stylesheet" href="resource://activity-stream/data/content/activity-stream.css" />
</head>
<body class="activity-stream">
<div id="root"></div>
+ <div id="snippets-container">
+ <div id="topSection"></div> <!-- TODO: placeholder for v4 snippets. It should be removed when we switch to v5 -->
+ <div id="snippets"></div>
+ </div>
<script src="chrome://browser/content/contentSearchUI.js"></script>
<script src="resource://activity-stream/vendor/react.js"></script>
<script src="resource://activity-stream/vendor/react-dom.js"></script>
<script src="resource://activity-stream/vendor/react-intl.js"></script>
<script src="resource://activity-stream/vendor/redux.js"></script>
<script src="resource://activity-stream/vendor/react-redux.js"></script>
<script src="resource://activity-stream/data/content/activity-stream.bundle.js"></script>
</body>
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/data/content/assets/glyph-historyItem-16.svg
@@ -0,0 +1,6 @@
+<!-- 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/. -->
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
+ <path fill="#4d4d4d" d="M365,190a4,4,0,1,1,4-4A4,4,0,0,1,365,190Zm0-6a2,2,0,1,0,2,2A2,2,0,0,0,365,184Z" transform="translate(-357 -178)"/>
+</svg>
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/data/content/assets/glyph-info-option-12.svg
@@ -0,0 +1,1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12"><path fill="#999" d="M6 0a6 6 0 1 0 6 6 6 6 0 0 0-6-6zm.7 10.26a1.13 1.13 0 0 1-.78.28 1.13 1.13 0 0 1-.78-.28 1 1 0 0 1 0-1.42 1.13 1.13 0 0 1 .78-.28 1.13 1.13 0 0 1 .78.28 1 1 0 0 1 0 1.42zM8.55 5a3 3 0 0 1-.62.81l-.67.63a1.58 1.58 0 0 0-.4.57 2.24 2.24 0 0 0-.12.74H5.06a3.82 3.82 0 0 1 .19-1.35 2.11 2.11 0 0 1 .63-.86 4.17 4.17 0 0 0 .66-.67 1.09 1.09 0 0 0 .23-.67.73.73 0 0 0-.77-.86.71.71 0 0 0-.57.26 1.1 1.1 0 0 0-.23.7h-2A2.36 2.36 0 0 1 4 2.47a2.94 2.94 0 0 1 2-.65 3.06 3.06 0 0 1 2 .6 2.12 2.12 0 0 1 .72 1.72 2 2 0 0 1-.17.86z"/></svg>
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/data/content/assets/glyph-now-16.svg
@@ -0,0 +1,6 @@
+<!-- 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/. -->
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
+ <path fill="#4d4d4d" d="M8 0a8 8 0 1 0 8 8 8.009 8.009 0 0 0-8-8zm0 14a6 6 0 1 1 6-6 6.007 6.007 0 0 1-6 6zm3.5-6H8V4.5a.5.5 0 0 0-1 0v4a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 0-1z"/>
+</svg>
--- a/browser/extensions/activity-stream/data/content/assets/glyph-pocket-16.svg
+++ b/browser/extensions/activity-stream/data/content/assets/glyph-pocket-16.svg
@@ -1,6 +1,6 @@
<!-- 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/. -->
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
- <path fill="context-fill" d="M8 15a8 8 0 0 1-8-8V3a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v4a8 8 0 0 1-8 8zm3.985-10.032a.99.99 0 0 0-.725.319L7.978 8.57 4.755 5.336A.984.984 0 0 0 4 4.968a1 1 0 0 0-.714 1.7l-.016.011 3.293 3.306.707.707a1 1 0 0 0 1.414 0l.707-.707L12.7 6.679a1 1 0 0 0-.715-1.711z"/>
-</svg>
\ No newline at end of file
+ <path fill="#4d4d4d" d="M8 15a8 8 0 0 1-8-8V3a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v4a8 8 0 0 1-8 8zm3.985-10.032a.99.99 0 0 0-.725.319L7.978 8.57 4.755 5.336A.984.984 0 0 0 4 4.968a1 1 0 0 0-.714 1.7l-.016.011 3.293 3.306.707.707a1 1 0 0 0 1.414 0l.707-.707L12.7 6.679a1 1 0 0 0-.715-1.711z"/>
+</svg>
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/data/content/assets/glyph-trending-16.svg
@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="16px" height="16px" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+ <g id="Symbols" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+ <g id="Context-/-Pocket-Trending" fill="#999999">
+ <path d="M12.164765,5.74981818 C12.4404792,5.74981818 12.5976221,6.06981818 12.4233364,6.28509091 C10.7404792,8.37236364 4.26619353,15.6829091 4.15905067,15.744 C5.70047924,12.3301818 7.1276221,8.976 7.1276221,8.976 L4.3276221,8.976 C4.09905067,8.976 3.9376221,8.74472727 4.02333638,8.52654545 C4.70047924,6.77672727 6.86190781,1.32945455 7.30476495,0.216727273 C7.35333638,0.0916363636 7.46190781,0.0174545455 7.59476495,0.016 C8.32476495,0.0130909091 10.7904792,0.00290909091 12.5790507,0 C12.844765,0 12.9976221,0.305454545 12.8433364,0.525090909 L9.17190781,5.74981818 L12.164765,5.74981818 Z" id="Fill-1"></path>
+ </g>
+ </g>
+</svg>
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/data/content/assets/topic-show-more-12.svg
@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="12px" height="12px" viewBox="0 0 12 12" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+ <!-- Generator: Sketch 43.2 (39069) - http://www.bohemiancoding.com/sketch -->
+ <title>Icon / ></title>
+ <desc>Created with Sketch.</desc>
+ <defs></defs>
+ <g id="Symbols" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" stroke-linecap="round" stroke-linejoin="round">
+ <g id="Icon-/->" stroke-width="2" stroke="#008EA4">
+ <polyline id="Path-2" points="4 2 8 6 4 10"></polyline>
+ </g>
+ </g>
+</svg>
\ No newline at end of file
--- a/browser/extensions/activity-stream/data/locales.json
+++ b/browser/extensions/activity-stream/data/locales.json
@@ -1020,16 +1020,17 @@
},
"en-US": {
"newtab_page_title": "New Tab",
"default_label_loading": "Loading…",
"header_top_sites": "Top Sites",
"header_stories": "Top Stories",
"header_visit_again": "Visit Again",
"header_bookmarks": "Recent Bookmarks",
+ "header_recommended_by": "Recommended by {provider}",
"header_bookmarks_placeholder": "You don’t have any bookmarks yet.",
"header_stories_from": "from",
"type_label_visited": "Visited",
"type_label_bookmarked": "Bookmarked",
"type_label_synced": "Synced from another device",
"type_label_recommended": "Trending",
"type_label_open": "Open",
"type_label_topic": "Topic",
@@ -1046,16 +1047,17 @@
"confirm_history_delete_p1": "Are you sure you want to delete every instance of this page from your history?",
"confirm_history_delete_notice_p2": "This action cannot be undone.",
"menu_action_save_to_pocket": "Save to Pocket",
"search_for_something_with": "Search for {search_term} with:",
"search_button": "Search",
"search_header": "{search_engine_name} Search",
"search_web_placeholder": "Search the Web",
"search_settings": "Change Search Settings",
+ "section_info_option": "Info",
"welcome_title": "Welcome to new tab",
"welcome_body": "Firefox will use this space to show your most relevant bookmarks, articles, videos, and pages you’ve recently visited, so you can get back to them easily.",
"welcome_label": "Identifying your Highlights",
"time_label_less_than_minute": "<1m",
"time_label_minute": "{number}m",
"time_label_hour": "{number}h",
"time_label_day": "{number}d",
"settings_pane_button_label": "Customize your New Tab page",
@@ -1090,17 +1092,18 @@
"topsites_form_add_button": "Add",
"topsites_form_save_button": "Save",
"topsites_form_cancel_button": "Cancel",
"topsites_form_url_validation": "Valid URL required",
"pocket_read_more": "Popular Topics:",
"pocket_read_even_more": "View More Stories",
"pocket_feedback_header": "The best of the web, curated by over 25 million people.",
"pocket_feedback_body": "Pocket, a part of the Mozilla family, will help connect you to high-quality content that you may not have found otherwise.",
- "pocket_send_feedback": "Send Feedback"
+ "pocket_send_feedback": "Send Feedback",
+ "empty_state_topstories": "You’ve caught up. Check back later for more top stories from Pocket. Can’t wait? Select a popular topic to find more great stories from around the web."
},
"en-ZA": {},
"eo": {
"newtab_page_title": "Nova legosigno",
"default_label_loading": "Ŝargado…",
"header_top_sites": "Plej vizititaj",
"header_highlights": "Elstaraĵoj",
"type_label_visited": "Vizititaj",
--- a/browser/extensions/activity-stream/lib/ActivityStream.jsm
+++ b/browser/extensions/activity-stream/lib/ActivityStream.jsm
@@ -9,21 +9,44 @@ const {utils: Cu} = Components;
// common case to avoid the overhead of wrapping and detecting lazy loading.
const {actionTypes: at} = Cu.import("resource://activity-stream/common/Actions.jsm", {});
const {DefaultPrefs} = Cu.import("resource://activity-stream/lib/ActivityStreamPrefs.jsm", {});
const {LocalizationFeed} = Cu.import("resource://activity-stream/lib/LocalizationFeed.jsm", {});
const {NewTabInit} = Cu.import("resource://activity-stream/lib/NewTabInit.jsm", {});
const {PlacesFeed} = Cu.import("resource://activity-stream/lib/PlacesFeed.jsm", {});
const {PrefsFeed} = Cu.import("resource://activity-stream/lib/PrefsFeed.jsm", {});
const {Store} = Cu.import("resource://activity-stream/lib/Store.jsm", {});
+const {SnippetsFeed} = Cu.import("resource://activity-stream/lib/SnippetsFeed.jsm", {});
+const {SystemTickFeed} = Cu.import("resource://activity-stream/lib/SystemTickFeed.jsm", {});
const {TelemetryFeed} = Cu.import("resource://activity-stream/lib/TelemetryFeed.jsm", {});
const {TopSitesFeed} = Cu.import("resource://activity-stream/lib/TopSitesFeed.jsm", {});
+const {TopStoriesFeed} = Cu.import("resource://activity-stream/lib/TopStoriesFeed.jsm", {});
const REASON_ADDON_UNINSTALL = 6;
+// Sections, keyed by section id
+const SECTIONS = new Map([
+ ["topstories", {
+ feed: TopStoriesFeed,
+ prefTitle: "Fetches content recommendations from a configurable content provider",
+ showByDefault: false
+ }]
+]);
+
+const SECTION_FEEDS_CONFIG = Array.from(SECTIONS.entries()).map(entry => {
+ const id = entry[0];
+ const {feed: Feed, prefTitle, showByDefault: value} = entry[1];
+ return {
+ name: `section.${id}`,
+ factory: () => new Feed(),
+ title: prefTitle || `${id} section feed`,
+ value
+ };
+});
+
const PREFS_CONFIG = new Map([
["default.sites", {
title: "Comma-separated list of default top sites to fill in behind visited sites",
value: "https://www.facebook.com/,https://www.youtube.com/,https://www.amazon.com/,https://www.yahoo.com/,https://www.ebay.com/,https://twitter.com/"
}],
["showSearch", {
title: "Show the Search bar on the New Tab page",
value: true
@@ -40,21 +63,34 @@ const PREFS_CONFIG = new Map([
["telemetry.log", {
title: "Log telemetry events in the console",
value: false,
value_local_dev: true
}],
["telemetry.ping.endpoint", {
title: "Telemetry server endpoint",
value: "https://tiles.services.mozilla.com/v4/links/activity-stream"
+ }],
+ ["feeds.section.topstories.options", {
+ title: "Configuration options for top stories feed",
+ value: `{
+ "stories_endpoint": "https://getpocket.com/v3/firefox/global-recs?consumer_key=$apiKey",
+ "topics_endpoint": "https://getpocket.com/v3/firefox/trending-topics?consumer_key=$apiKey",
+ "read_more_endpoint": "https://getpocket.com/explore/trending?src=ff_new_tab",
+ "learn_more_endpoint": "https://getpocket.com/firefox_learnmore?src=ff_newtab",
+ "survey_link": "https://www.surveymonkey.com/r/newtabffx",
+ "api_key_pref": "extensions.pocket.oAuthConsumerKey",
+ "provider_name": "Pocket",
+ "provider_icon": "pocket"
+ }`
}]
]);
const FEEDS_CONFIG = new Map();
-for (const {name, factory, title, value} of [
+for (const {name, factory, title, value} of SECTION_FEEDS_CONFIG.concat([
{
name: "localization",
factory: () => new LocalizationFeed(),
title: "Initialize strings and detect locale for Activity Stream",
value: true
},
{
name: "newtabinit",
@@ -70,28 +106,40 @@ for (const {name, factory, title, value}
},
{
name: "prefs",
factory: () => new PrefsFeed(PREFS_CONFIG),
title: "Preferences",
value: true
},
{
+ name: "snippets",
+ factory: () => new SnippetsFeed(),
+ title: "Gets snippets data",
+ value: false
+ },
+ {
+ name: "systemtick",
+ factory: () => new SystemTickFeed(),
+ title: "Produces system tick events to periodically check for data expiry",
+ value: true
+ },
+ {
name: "telemetry",
factory: () => new TelemetryFeed(),
title: "Relays telemetry-related actions to TelemetrySender",
value: true
},
{
name: "topsites",
factory: () => new TopSitesFeed(),
title: "Queries places and gets metadata for Top Sites section",
value: true
}
-]) {
+])) {
const pref = `feeds.${name}`;
FEEDS_CONFIG.set(pref, factory);
PREFS_CONFIG.set(pref, {title, value});
}
this.ActivityStream = class ActivityStream {
/**
@@ -130,9 +178,9 @@ this.ActivityStream = class ActivityStre
// so we DON'T want to do this on an upgrade/downgrade, only on a
// real uninstall
this._defaultPrefs.reset();
}
}
};
this.PREFS_CONFIG = PREFS_CONFIG;
-this.EXPORTED_SYMBOLS = ["ActivityStream"];
+this.EXPORTED_SYMBOLS = ["ActivityStream", "SECTIONS"];
--- a/browser/extensions/activity-stream/lib/PlacesFeed.jsm
+++ b/browser/extensions/activity-stream/lib/PlacesFeed.jsm
@@ -8,16 +8,18 @@ Cu.import("resource://gre/modules/Servic
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
const {actionCreators: ac, actionTypes: at} = Cu.import("resource://activity-stream/common/Actions.jsm", {});
XPCOMUtils.defineLazyModuleGetter(this, "NewTabUtils",
"resource://gre/modules/NewTabUtils.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "PlacesUtils",
"resource://gre/modules/PlacesUtils.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Pocket",
+ "chrome://pocket/content/Pocket.jsm");
const LINK_BLOCKED_EVENT = "newtab-linkBlocked";
/**
* Observer - a wrapper around history/bookmark observers to add the QueryInterface.
*/
class Observer {
constructor(dispatch, observerInterface) {
@@ -200,16 +202,19 @@ class PlacesFeed {
NewTabUtils.activityStreamLinks.addBookmark(action.data);
break;
case at.DELETE_BOOKMARK_BY_ID:
NewTabUtils.activityStreamLinks.deleteBookmark(action.data);
break;
case at.DELETE_HISTORY_URL:
NewTabUtils.activityStreamLinks.deleteHistoryEntry(action.data);
break;
+ case at.SAVE_TO_POCKET:
+ Pocket.savePage(action._target.browser, action.data.site.url, action.data.site.title);
+ break;
}
}
}
this.PlacesFeed = PlacesFeed;
// Exported for testing only
PlacesFeed.HistoryObserver = HistoryObserver;
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/lib/SnippetsFeed.jsm
@@ -0,0 +1,58 @@
+/* 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";
+
+const {utils: Cu} = Components;
+
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/Console.jsm");
+const {actionTypes: at, actionCreators: ac} = Cu.import("resource://activity-stream/common/Actions.jsm", {});
+
+// Url to fetch snippets, in the urlFormatter service format.
+const SNIPPETS_URL_PREF = "browser.aboutHomeSnippets.updateUrl";
+
+// Should be bumped up if the snippets content format changes.
+const STARTPAGE_VERSION = 4;
+
+this.SnippetsFeed = class SnippetsFeed {
+ constructor() {
+ this._onUrlChange = this._onUrlChange.bind(this);
+ }
+ get snippetsURL() {
+ const updateURL = Services
+ .prefs.getStringPref(SNIPPETS_URL_PREF)
+ .replace("%STARTPAGE_VERSION%", STARTPAGE_VERSION);
+ return Services.urlFormatter.formatURL(updateURL);
+ }
+ init() {
+ const data = {
+ snippetsURL: this.snippetsURL,
+ version: STARTPAGE_VERSION
+ };
+ this.store.dispatch(ac.BroadcastToContent({type: at.SNIPPETS_DATA, data}));
+ Services.prefs.addObserver(SNIPPETS_URL_PREF, this._onUrlChange);
+ }
+ uninit() {
+ this.store.dispatch({type: at.SNIPPETS_RESET});
+ Services.prefs.removeObserver(SNIPPETS_URL_PREF, this._onUrlChange);
+ }
+ _onUrlChange() {
+ this.store.dispatch(ac.BroadcastToContent({
+ type: at.SNIPPETS_DATA,
+ data: {snippetsURL: this.snippetsURL}
+ }));
+ }
+ onAction(action) {
+ switch (action.type) {
+ case at.INIT:
+ this.init();
+ break;
+ case at.FEED_INIT:
+ if (action.data === "feeds.snippets") { this.init(); }
+ break;
+ }
+ }
+};
+
+this.EXPORTED_SYMBOLS = ["SnippetsFeed"];
--- a/browser/extensions/activity-stream/lib/Store.jsm
+++ b/browser/extensions/activity-stream/lib/Store.jsm
@@ -4,16 +4,17 @@
"use strict";
const {utils: Cu} = Components;
const {ActivityStreamMessageChannel} = Cu.import("resource://activity-stream/lib/ActivityStreamMessageChannel.jsm", {});
const {Prefs} = Cu.import("resource://activity-stream/lib/ActivityStreamPrefs.jsm", {});
const {reducers} = Cu.import("resource://activity-stream/common/Reducers.jsm", {});
const {redux} = Cu.import("resource://activity-stream/vendor/Redux.jsm", {});
+const {actionTypes: at} = Cu.import("resource://activity-stream/common/Actions.jsm", {});
/**
* Store - This has a similar structure to a redux store, but includes some extra
* functionality to allow for routing of actions between the Main processes
* and child processes via a ActivityStreamMessageChannel.
* It also accepts an array of "Feeds" on inititalization, which
* can listen for any action that is dispatched through the store.
*/
@@ -86,16 +87,17 @@ this.Store = class Store {
/**
* onPrefChanged - Listener for handling feed changes.
*/
onPrefChanged(name, value) {
if (this._feedFactories.has(name)) {
if (value) {
this.initFeed(name);
+ this.dispatch({type: at.FEED_INIT, data: name});
} else {
this.uninitFeed(name);
}
}
}
/**
* init - Initializes the ActivityStreamMessageChannel channel, and adds feeds.
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/lib/SystemTickFeed.jsm
@@ -0,0 +1,35 @@
+/* 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";
+
+const {utils: Cu} = Components;
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+const {actionTypes: at} = Cu.import("resource://activity-stream/common/Actions.jsm", {});
+
+XPCOMUtils.defineLazyModuleGetter(this, "setInterval", "resource://gre/modules/Timer.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "clearInterval", "resource://gre/modules/Timer.jsm");
+
+// Frequency at which SYSTEM_TICK events are fired
+const SYSTEM_TICK_INTERVAL = 5 * 60 * 1000;
+
+this.SystemTickFeed = class SystemTickFeed {
+ init() {
+ this.intervalId = setInterval(() => this.store.dispatch({type: at.SYSTEM_TICK}), SYSTEM_TICK_INTERVAL);
+ }
+
+ onAction(action) {
+ switch (action.type) {
+ case at.INIT:
+ this.init();
+ break;
+ case at.UNINIT:
+ clearInterval(this.intervalId);
+ break;
+ }
+ }
+};
+
+this.SYSTEM_TICK_INTERVAL = SYSTEM_TICK_INTERVAL;
+this.EXPORTED_SYMBOLS = ["SystemTickFeed", "SYSTEM_TICK_INTERVAL"];
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/lib/TopStoriesFeed.jsm
@@ -0,0 +1,187 @@
+/* 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";
+
+const {utils: Cu} = Components;
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/NewTabUtils.jsm");
+Cu.importGlobalProperties(["fetch"]);
+
+const {actionCreators: ac, actionTypes: at} = Cu.import("resource://activity-stream/common/Actions.jsm", {});
+const {Prefs} = Cu.import("resource://activity-stream/lib/ActivityStreamPrefs.jsm", {});
+
+const STORIES_UPDATE_TIME = 30 * 60 * 1000; // 30 minutes
+const TOPICS_UPDATE_TIME = 3 * 60 * 60 * 1000; // 3 hours
+const SECTION_ID = "TopStories";
+
+this.TopStoriesFeed = class TopStoriesFeed {
+ constructor() {
+ this.storiesLastUpdated = 0;
+ this.topicsLastUpdated = 0;
+ }
+
+ init() {
+ try {
+ const prefs = new Prefs();
+ const options = JSON.parse(prefs.get("feeds.section.topstories.options"));
+ const apiKey = this._getApiKeyFromPref(options.api_key_pref);
+ this.stories_endpoint = this._produceUrlWithApiKey(options.stories_endpoint, apiKey);
+ this.topics_endpoint = this._produceUrlWithApiKey(options.topics_endpoint, apiKey);
+ this.read_more_endpoint = options.read_more_endpoint;
+
+ // TODO https://github.com/mozilla/activity-stream/issues/2902
+ const sectionOptions = {
+ id: SECTION_ID,
+ icon: options.provider_icon,
+ title: {id: "header_recommended_by", values: {provider: options.provider_name}},
+ rows: [],
+ maxCards: 3,
+ contextMenuOptions: ["SaveToPocket", "Separator", "CheckBookmark", "Separator", "OpenInNewWindow", "OpenInPrivateWindow", "Separator", "BlockUrl"],
+ infoOption: {
+ header: {id: "pocket_feedback_header"},
+ body: {id: "pocket_feedback_body"},
+ link: {
+ href: options.survey_link,
+ id: "pocket_send_feedback"
+ }
+ },
+ emptyState: {
+ message: {id: "empty_state_topstories"},
+ icon: "check"
+ }
+ };
+ this.store.dispatch(ac.BroadcastToContent({type: at.SECTION_REGISTER, data: sectionOptions}));
+
+ this.fetchStories();
+ this.fetchTopics();
+ } catch (e) {
+ Cu.reportError(`Problem initializing top stories feed: ${e.message}`);
+ }
+ }
+
+ uninit() {
+ this.store.dispatch(ac.BroadcastToContent({type: at.SECTION_DEREGISTER, data: SECTION_ID}));
+ }
+
+ async fetchStories() {
+ if (this.stories_endpoint) {
+ const stories = await fetch(this.stories_endpoint)
+ .then(response => {
+ if (response.ok) {
+ return response.text();
+ }
+ throw new Error(`Stories endpoint returned unexpected status: ${response.status}`);
+ })
+ .then(body => {
+ let items = JSON.parse(body).list;
+ items = items
+ .filter(s => !NewTabUtils.blockedLinks.isBlocked(s.dedupe_url))
+ .map(s => ({
+ "guid": s.id,
+ "type": "trending",
+ "title": s.title,
+ "description": s.excerpt,
+ "image": this._normalizeUrl(s.image_src),
+ "url": s.dedupe_url,
+ "lastVisitDate": s.published_timestamp
+ }));
+ return items;
+ })
+ .catch(error => Cu.reportError(`Failed to fetch content: ${error.message}`));
+
+ if (stories) {
+ this.dispatchUpdateEvent(this.storiesLastUpdated,
+ {"type": at.SECTION_ROWS_UPDATE, "data": {"id": SECTION_ID, "rows": stories}});
+ this.storiesLastUpdated = Date.now();
+ }
+ }
+ }
+
+ async fetchTopics() {
+ if (this.topics_endpoint) {
+ const topics = await fetch(this.topics_endpoint)
+ .then(response => {
+ if (response.ok) {
+ return response.text();
+ }
+ throw new Error(`Topics endpoint returned unexpected status: ${response.status}`);
+ })
+ .then(body => JSON.parse(body).topics)
+ .catch(error => Cu.reportError(`Failed to fetch topics: ${error.message}`));
+
+ if (topics) {
+ this.dispatchUpdateEvent(this.topicsLastUpdated,
+ {"type": at.SECTION_ROWS_UPDATE, "data": {"id": SECTION_ID, "topics": topics, "read_more_endpoint": this.read_more_endpoint}});
+ this.topicsLastUpdated = Date.now();
+ }
+ }
+ }
+
+ dispatchUpdateEvent(lastUpdated, evt) {
+ if (lastUpdated === 0) {
+ this.store.dispatch(ac.BroadcastToContent(evt));
+ } else {
+ this.store.dispatch(evt);
+ }
+ }
+
+ _getApiKeyFromPref(apiKeyPref) {
+ if (!apiKeyPref) {
+ return apiKeyPref;
+ }
+
+ return new Prefs().get(apiKeyPref) || Services.prefs.getCharPref(apiKeyPref);
+ }
+
+ _produceUrlWithApiKey(url, apiKey) {
+ if (!url) {
+ return url;
+ }
+
+ if (url.includes("$apiKey") && !apiKey) {
+ throw new Error(`An API key was specified but none configured: ${url}`);
+ }
+
+ return url.replace("$apiKey", apiKey);
+ }
+
+ // Need to remove parenthesis from image URLs as React will otherwise
+ // fail to render them properly as part of the card template.
+ _normalizeUrl(url) {
+ if (url) {
+ return url.replace(/\(/g, "%28").replace(/\)/g, "%29");
+ }
+ return url;
+ }
+
+ onAction(action) {
+ switch (action.type) {
+ case at.INIT:
+ this.init();
+ break;
+ case at.SYSTEM_TICK:
+ if (Date.now() - this.storiesLastUpdated >= STORIES_UPDATE_TIME) {
+ this.fetchStories();
+ }
+ if (Date.now() - this.topicsLastUpdated >= TOPICS_UPDATE_TIME) {
+ this.fetchTopics();
+ }
+ break;
+ case at.UNINIT:
+ this.uninit();
+ break;
+ case at.FEED_INIT:
+ if (action.data === "feeds.section.topstories") {
+ this.init();
+ }
+ break;
+ }
+ }
+};
+
+this.STORIES_UPDATE_TIME = STORIES_UPDATE_TIME;
+this.TOPICS_UPDATE_TIME = TOPICS_UPDATE_TIME;
+this.SECTION_ID = SECTION_ID;
+this.EXPORTED_SYMBOLS = ["TopStoriesFeed", "STORIES_UPDATE_TIME", "TOPICS_UPDATE_TIME", "SECTION_ID"];
--- a/browser/extensions/activity-stream/test/functional/mochitest/browser.ini
+++ b/browser/extensions/activity-stream/test/functional/mochitest/browser.ini
@@ -1,8 +1,7 @@
[DEFAULT]
-skip-if=!nightly_build
support-files =
blue_page.html
[browser_as_load_location.js]
[browser_getScreenshots.js]
skip-if=true # issue 2851
deleted file mode 100644
--- a/browser/extensions/activity-stream/test/mozinfo.json
+++ /dev/null
@@ -1,3 +0,0 @@
-{
- "activity_stream": true
-}
--- a/browser/extensions/activity-stream/test/unit/common/Reducers.test.js
+++ b/browser/extensions/activity-stream/test/unit/common/Reducers.test.js
@@ -1,10 +1,11 @@
const {reducers, INITIAL_STATE, insertPinned} = require("common/Reducers.jsm");
-const {TopSites, App, Prefs, Dialog} = reducers;
+const {TopSites, App, Snippets, Prefs, Dialog, Sections} = reducers;
+
const {actionTypes: at} = require("common/Actions.jsm");
describe("Reducers", () => {
describe("App", () => {
it("should return the initial state", () => {
const nextState = App(undefined, {type: "FOO"});
assert.equal(nextState, INITIAL_STATE.App);
});
@@ -72,16 +73,20 @@ describe("Reducers", () => {
assert.equal(newRow.url, action.data.url);
assert.equal(newRow.bookmarkGuid, action.data.bookmarkGuid);
assert.equal(newRow.bookmarkTitle, action.data.bookmarkTitle);
assert.equal(newRow.bookmarkDateCreated, action.data.lastModified);
// old row is unchanged
assert.equal(nextState.rows[0], oldState.rows[0]);
});
+ it("should not update state for empty action.data on PLACES_BOOKMARK_ADDED", () => {
+ const nextState = TopSites(undefined, {type: at.PLACES_BOOKMARK_ADDED});
+ assert.equal(nextState, INITIAL_STATE.TopSites);
+ });
it("should remove a bookmark on PLACES_BOOKMARK_REMOVED", () => {
const oldState = {
rows: [{url: "foo.com"}, {
url: "bar.com",
bookmarkGuid: "bookmark123",
bookmarkTitle: "Title for bar.com",
lastModified: 123456
}]
@@ -93,16 +98,20 @@ describe("Reducers", () => {
assert.equal(newRow.url, oldState.rows[1].url);
assert.isUndefined(newRow.bookmarkGuid);
assert.isUndefined(newRow.bookmarkTitle);
assert.isUndefined(newRow.bookmarkDateCreated);
// old row is unchanged
assert.deepEqual(nextState.rows[0], oldState.rows[0]);
});
+ it("should not update state for empty action.data on PLACES_BOOKMARK_REMOVED", () => {
+ const nextState = TopSites(undefined, {type: at.PLACES_BOOKMARK_REMOVED});
+ assert.equal(nextState, INITIAL_STATE.TopSites);
+ });
it("should remove a link on PLACES_LINK_BLOCKED and PLACES_LINK_DELETED", () => {
const events = [at.PLACES_LINK_BLOCKED, at.PLACES_LINK_DELETED];
events.forEach(event => {
const oldState = {rows: [{url: "foo.com"}, {url: "bar.com"}]};
const action = {type: event, data: {url: "bar.com"}};
const nextState = TopSites(oldState, action);
assert.deepEqual(nextState.rows, [{url: "foo.com"}]);
});
@@ -174,16 +183,80 @@ describe("Reducers", () => {
});
it("should return inital state on DELETE_HISTORY_URL", () => {
const action = {type: at.DELETE_HISTORY_URL};
const nextState = Dialog(INITIAL_STATE.Dialog, action);
assert.deepEqual(INITIAL_STATE.Dialog, nextState);
});
});
+ describe("Sections", () => {
+ let oldState;
+
+ beforeEach(() => {
+ oldState = new Array(5).fill(null).map((v, i) => ({
+ id: `foo_bar_${i}`,
+ title: `Foo Bar ${i}`,
+ initialized: false,
+ rows: [{url: "www.foo.bar"}, {url: "www.other.url"}]
+ }));
+ });
+
+ it("should return INITIAL_STATE by default", () => {
+ assert.equal(INITIAL_STATE.Sections, Sections(undefined, {type: "non_existent"}));
+ });
+ it("should remove the correct section on SECTION_DEREGISTER", () => {
+ const newState = Sections(oldState, {type: at.SECTION_DEREGISTER, data: "foo_bar_2"});
+ assert.lengthOf(newState, 4);
+ const expectedNewState = oldState.splice(2, 1) && oldState;
+ assert.deepEqual(newState, expectedNewState);
+ });
+ it("should add a section on SECTION_REGISTER if it doesn't already exist", () => {
+ const action = {type: at.SECTION_REGISTER, data: {id: "foo_bar_5", title: "Foo Bar 5"}};
+ const newState = Sections(oldState, action);
+ assert.lengthOf(newState, 6);
+ const insertedSection = newState.find(section => section.id === "foo_bar_5");
+ assert.propertyVal(insertedSection, "title", action.data.title);
+ });
+ it("should set newSection.rows === [] if no rows are provided on SECTION_REGISTER", () => {
+ const action = {type: at.SECTION_REGISTER, data: {id: "foo_bar_5", title: "Foo Bar 5"}};
+ const newState = Sections(oldState, action);
+ const insertedSection = newState.find(section => section.id === "foo_bar_5");
+ assert.deepEqual(insertedSection.rows, []);
+ });
+ it("should update a section on SECTION_REGISTER if it already exists", () => {
+ const NEW_TITLE = "New Title";
+ const action = {type: at.SECTION_REGISTER, data: {id: "foo_bar_2", title: NEW_TITLE}};
+ const newState = Sections(oldState, action);
+ assert.lengthOf(newState, 5);
+ const updatedSection = newState.find(section => section.id === "foo_bar_2");
+ assert.ok(updatedSection && updatedSection.title === NEW_TITLE);
+ });
+ it("should have no effect on SECTION_ROWS_UPDATE if the id doesn't exist", () => {
+ const action = {type: at.SECTION_ROWS_UPDATE, data: {id: "fake_id", data: "fake_data"}};
+ const newState = Sections(oldState, action);
+ assert.deepEqual(oldState, newState);
+ });
+ it("should update the section rows with the correct data on SECTION_ROWS_UPDATE", () => {
+ const FAKE_DATA = ["some", "fake", "data"];
+ const action = {type: at.SECTION_ROWS_UPDATE, data: {id: "foo_bar_2", rows: FAKE_DATA}};
+ const newState = Sections(oldState, action);
+ const updatedSection = newState.find(section => section.id === "foo_bar_2");
+ assert.equal(updatedSection.rows, FAKE_DATA);
+ });
+ it("should remove blocked and deleted urls from all rows in all sections", () => {
+ const blockAction = {type: at.PLACES_LINK_BLOCKED, data: {url: "www.foo.bar"}};
+ const deleteAction = {type: at.PLACES_LINK_DELETED, data: {url: "www.foo.bar"}};
+ const newBlockState = Sections(oldState, blockAction);
+ const newDeleteState = Sections(oldState, deleteAction);
+ newBlockState.concat(newDeleteState).forEach(section => {
+ assert.deepEqual(section.rows, [{url: "www.other.url"}]);
+ });
+ });
+ });
describe("#insertPinned", () => {
let links;
beforeEach(() => {
links = new Array(12).fill(null).map((v, i) => ({url: `site${i}.com`}));
});
it("should place pinned links where they belong", () => {
@@ -239,9 +312,28 @@ describe("Reducers", () => {
assert.notProperty(result[2], "pinIndex");
});
it("should handle a link present in both the links and pinned list", () => {
const pinned = [links[7]];
const result = insertPinned(links, pinned);
assert.equal(links.length, result.length);
});
});
+ describe("Snippets", () => {
+ it("should return INITIAL_STATE by default", () => {
+ assert.equal(Snippets(undefined, {type: "some_action"}), INITIAL_STATE.Snippets);
+ });
+ it("should set initialized to true on a SNIPPETS_DATA action", () => {
+ const state = Snippets(undefined, {type: at.SNIPPETS_DATA, data: {}});
+ assert.isTrue(state.initialized);
+ });
+ it("should set the snippet data on a SNIPPETS_DATA action", () => {
+ const data = {snippetsURL: "foo.com", version: 4};
+ const state = Snippets(undefined, {type: at.SNIPPETS_DATA, data});
+ assert.propertyVal(state, "snippetsURL", data.snippetsURL);
+ assert.propertyVal(state, "version", data.version);
+ });
+ it("should reset to the initial state on a SNIPPETS_RESET action", () => {
+ const state = Snippets({initalized: true, foo: "bar"}, {type: at.SNIPPETS_RESET});
+ assert.equal(state, INITIAL_STATE.Snippets);
+ });
+ });
});
--- a/browser/extensions/activity-stream/test/unit/lib/ActivityStream.test.js
+++ b/browser/extensions/activity-stream/test/unit/lib/ActivityStream.test.js
@@ -1,27 +1,31 @@
const injector = require("inject!lib/ActivityStream.jsm");
const REASON_ADDON_UNINSTALL = 6;
describe("ActivityStream", () => {
let sandbox;
let as;
let ActivityStream;
+ let SECTIONS;
function Fake() {}
beforeEach(() => {
sandbox = sinon.sandbox.create();
- ({ActivityStream} = injector({
+ ({ActivityStream, SECTIONS} = injector({
"lib/LocalizationFeed.jsm": {LocalizationFeed: Fake},
"lib/NewTabInit.jsm": {NewTabInit: Fake},
"lib/PlacesFeed.jsm": {PlacesFeed: Fake},
"lib/TelemetryFeed.jsm": {TelemetryFeed: Fake},
"lib/TopSitesFeed.jsm": {TopSitesFeed: Fake},
- "lib/PrefsFeed.jsm": {PrefsFeed: Fake}
+ "lib/PrefsFeed.jsm": {PrefsFeed: Fake},
+ "lib/SnippetsFeed.jsm": {SnippetsFeed: Fake},
+ "lib/TopStoriesFeed.jsm": {TopStoriesFeed: Fake},
+ "lib/SystemTickFeed.jsm": {SystemTickFeed: Fake}
}));
as = new ActivityStream();
sandbox.stub(as.store, "init");
sandbox.stub(as.store, "uninit");
sandbox.stub(as._defaultPrefs, "init");
sandbox.stub(as._defaultPrefs, "reset");
});
@@ -101,10 +105,26 @@ describe("ActivityStream", () => {
it("should create a Telemetry feed", () => {
const feed = as.feeds.get("feeds.telemetry")();
assert.instanceOf(feed, Fake);
});
it("should create a Prefs feed", () => {
const feed = as.feeds.get("feeds.prefs")();
assert.instanceOf(feed, Fake);
});
+ it("should create a section feed for each section in SECTIONS", () => {
+ // If new sections are added, their feeds will have to be added to the
+ // list of injected feeds above for this test to pass
+ SECTIONS.forEach((value, key) => {
+ const feed = as.feeds.get(`feeds.section.${key}`)();
+ assert.instanceOf(feed, Fake);
+ });
+ });
+ it("should create a Snippets feed", () => {
+ const feed = as.feeds.get("feeds.snippets")();
+ assert.instanceOf(feed, Fake);
+ });
+ it("should create a SystemTick feed", () => {
+ const feed = as.feeds.get("feeds.systemtick")();
+ assert.instanceOf(feed, Fake);
+ });
});
});
--- a/browser/extensions/activity-stream/test/unit/lib/PlacesFeed.test.js
+++ b/browser/extensions/activity-stream/test/unit/lib/PlacesFeed.test.js
@@ -23,16 +23,17 @@ describe("PlacesFeed", () => {
deleteHistoryEntry: sandbox.spy(),
blockURL: sandbox.spy()
}
});
globals.set("PlacesUtils", {
history: {addObserver: sandbox.spy(), removeObserver: sandbox.spy()},
bookmarks: {TYPE_BOOKMARK, addObserver: sandbox.spy(), removeObserver: sandbox.spy()}
});
+ globals.set("Pocket", {savePage: sandbox.spy()});
global.Components.classes["@mozilla.org/browser/nav-history-service;1"] = {
getService() {
return global.PlacesUtils.history;
}
};
global.Components.classes["@mozilla.org/browser/nav-bookmarks-service;1"] = {
getService() {
return global.PlacesUtils.bookmarks;
@@ -93,16 +94,20 @@ describe("PlacesFeed", () => {
it("should delete a bookmark on DELETE_BOOKMARK_BY_ID", () => {
feed.onAction({type: at.DELETE_BOOKMARK_BY_ID, data: "g123kd"});
assert.calledWith(global.NewTabUtils.activityStreamLinks.deleteBookmark, "g123kd");
});
it("should delete a history entry on DELETE_HISTORY_URL", () => {
feed.onAction({type: at.DELETE_HISTORY_URL, data: "guava.com"});
assert.calledWith(global.NewTabUtils.activityStreamLinks.deleteHistoryEntry, "guava.com");
});
+ it("should save to Pocket on SAVE_TO_POCKET", () => {
+ feed.onAction({type: at.SAVE_TO_POCKET, data: {site: {url: "raspberry.com", title: "raspberry"}}, _target: {browser: {}}});
+ assert.calledWith(global.Pocket.savePage, {}, "raspberry.com", "raspberry");
+ });
});
describe("#observe", () => {
it("should dispatch a PLACES_LINK_BLOCKED action with the url of the blocked link", () => {
feed.observe(null, BLOCKED_EVENT, "foo123.com");
assert.equal(feed.store.dispatch.firstCall.args[0].type, at.PLACES_LINK_BLOCKED);
assert.deepEqual(feed.store.dispatch.firstCall.args[0].data, {url: "foo123.com"});
});
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/test/unit/lib/SnippetsFeed.test.js
@@ -0,0 +1,60 @@
+const {SnippetsFeed} = require("lib/SnippetsFeed.jsm");
+const {actionTypes: at, actionCreators: ac} = require("common/Actions.jsm");
+
+describe("SnippetsFeed", () => {
+ let sandbox;
+ beforeEach(() => {
+ sandbox = sinon.sandbox.create();
+ });
+ afterEach(() => {
+ sandbox.restore();
+ });
+ it("should dispatch the right version and snippetsURL on INIT", () => {
+ const url = "foo.com/%STARTPAGE_VERSION%";
+ sandbox.stub(global.Services.prefs, "getStringPref").returns(url);
+ const feed = new SnippetsFeed();
+ feed.store = {dispatch: sandbox.stub()};
+
+ feed.onAction({type: at.INIT});
+
+ assert.calledWith(feed.store.dispatch, ac.BroadcastToContent({
+ type: at.SNIPPETS_DATA,
+ data: {
+ snippetsURL: "foo.com/4",
+ version: 4
+ }
+ }));
+ });
+ it("should call .init when a FEED_INIT happens for feeds.snippets", () => {
+ const feed = new SnippetsFeed();
+ sandbox.stub(feed, "init");
+ feed.store = {dispatch: sandbox.stub()};
+
+ feed.onAction({type: at.FEED_INIT, data: "feeds.snippets"});
+
+ assert.calledOnce(feed.init);
+ });
+ it("should dispatch a SNIPPETS_RESET on uninit", () => {
+ const feed = new SnippetsFeed();
+ feed.store = {dispatch: sandbox.stub()};
+
+ feed.uninit();
+
+ assert.calledWith(feed.store.dispatch, {type: at.SNIPPETS_RESET});
+ });
+ describe("_onUrlChange", () => {
+ it("should dispatch a new snippetsURL", () => {
+ const url = "boo.com/%STARTPAGE_VERSION%";
+ sandbox.stub(global.Services.prefs, "getStringPref").returns(url);
+ const feed = new SnippetsFeed();
+ feed.store = {dispatch: sandbox.stub()};
+
+ feed._onUrlChange();
+
+ assert.calledWith(feed.store.dispatch, ac.BroadcastToContent({
+ type: at.SNIPPETS_DATA,
+ data: {snippetsURL: "boo.com/4"}
+ }));
+ });
+ });
+});
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/test/unit/lib/SystemTickFeed.test.js
@@ -0,0 +1,41 @@
+"use strict";
+const injector = require("inject!lib/SystemTickFeed.jsm");
+const {actionTypes: at} = require("common/Actions.jsm");
+
+describe("System Tick Feed", () => {
+ let SystemTickFeed;
+ let SYSTEM_TICK_INTERVAL;
+ let instance;
+ let clock;
+
+ beforeEach(() => {
+ clock = sinon.useFakeTimers();
+
+ ({SystemTickFeed, SYSTEM_TICK_INTERVAL} = injector({}));
+ instance = new SystemTickFeed();
+ instance.store = {getState() { return {}; }, dispatch() {}};
+ });
+ afterEach(() => {
+ clock.restore();
+ });
+ it("should create a SystemTickFeed", () => {
+ assert.instanceOf(instance, SystemTickFeed);
+ });
+ it("should fire SYSTEM_TICK events at configured interval", () => {
+ let expectation = sinon.mock(instance.store).expects("dispatch")
+ .twice()
+ .withExactArgs({type: at.SYSTEM_TICK});
+
+ instance.onAction({type: at.INIT});
+ clock.tick(SYSTEM_TICK_INTERVAL * 2);
+ expectation.verify();
+ });
+ it("should not fire SYSTEM_TICK events after UNINIT", () => {
+ let expectation = sinon.mock(instance.store).expects("dispatch")
+ .never();
+
+ instance.onAction({type: at.UNINIT});
+ clock.tick(SYSTEM_TICK_INTERVAL * 2);
+ expectation.verify();
+ });
+});
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/test/unit/lib/TopStoriesFeed.test.js
@@ -0,0 +1,257 @@
+"use strict";
+const injector = require("inject!lib/TopStoriesFeed.jsm");
+const {FakePrefs} = require("test/unit/utils");
+const {actionCreators: ac, actionTypes: at} = require("common/Actions.jsm");
+const {GlobalOverrider} = require("test/unit/utils");
+
+describe("Top Stories Feed", () => {
+ let TopStoriesFeed;
+ let STORIES_UPDATE_TIME;
+ let TOPICS_UPDATE_TIME;
+ let SECTION_ID;
+ let instance;
+ let clock;
+ let globals;
+
+ beforeEach(() => {
+ FakePrefs.prototype.prefs["feeds.section.topstories.options"] = `{
+ "stories_endpoint": "https://somedomain.org/stories?key=$apiKey",
+ "topics_endpoint": "https://somedomain.org/topics?key=$apiKey",
+ "survey_link": "https://www.surveymonkey.com/r/newtabffx",
+ "api_key_pref": "apiKeyPref",
+ "provider_name": "test-provider",
+ "provider_icon": "provider-icon"
+ }`;
+ FakePrefs.prototype.prefs.apiKeyPref = "test-api-key";
+
+ globals = new GlobalOverrider();
+ clock = sinon.useFakeTimers();
+
+ ({TopStoriesFeed, STORIES_UPDATE_TIME, TOPICS_UPDATE_TIME, SECTION_ID} = injector({"lib/ActivityStreamPrefs.jsm": {Prefs: FakePrefs}}));
+ instance = new TopStoriesFeed();
+ instance.store = {getState() { return {}; }, dispatch: sinon.spy()};
+ });
+ afterEach(() => {
+ globals.restore();
+ clock.restore();
+ });
+ describe("#init", () => {
+ it("should create a TopStoriesFeed", () => {
+ assert.instanceOf(instance, TopStoriesFeed);
+ });
+ it("should initialize endpoints based on prefs", () => {
+ instance.onAction({type: at.INIT});
+ assert.equal("https://somedomain.org/stories?key=test-api-key", instance.stories_endpoint);
+ assert.equal("https://somedomain.org/topics?key=test-api-key", instance.topics_endpoint);
+ });
+ it("should register section", () => {
+ const expectedSectionOptions = {
+ id: SECTION_ID,
+ icon: "provider-icon",
+ title: {id: "header_recommended_by", values: {provider: "test-provider"}},
+ rows: [],
+ maxCards: 3,
+ contextMenuOptions: ["SaveToPocket", "Separator", "CheckBookmark", "Separator", "OpenInNewWindow", "OpenInPrivateWindow", "Separator", "BlockUrl"],
+ infoOption: {
+ header: {id: "pocket_feedback_header"},
+ body: {id: "pocket_feedback_body"},
+ link: {
+ href: "https://www.surveymonkey.com/r/newtabffx",
+ id: "pocket_send_feedback"
+ }
+ },
+ emptyState: {
+ message: {id: "empty_state_topstories"},
+ icon: "check"
+ }
+ };
+
+ instance.onAction({type: at.INIT});
+ assert.calledOnce(instance.store.dispatch);
+ assert.propertyVal(instance.store.dispatch.firstCall.args[0], "type", at.SECTION_REGISTER);
+ assert.calledWith(instance.store.dispatch, ac.BroadcastToContent({
+ type: at.SECTION_REGISTER,
+ data: expectedSectionOptions
+ }));
+ });
+ it("should fetch stories on init", () => {
+ instance.fetchStories = sinon.spy();
+ instance.fetchTopics = sinon.spy();
+ instance.onAction({type: at.INIT});
+ assert.calledOnce(instance.fetchStories);
+ });
+ it("should fetch topics on init", () => {
+ instance.fetchStories = sinon.spy();
+ instance.fetchTopics = sinon.spy();
+ instance.onAction({type: at.INIT});
+ assert.calledOnce(instance.fetchTopics);
+ });
+ it("should not fetch if endpoint not configured", () => {
+ let fetchStub = globals.sandbox.stub();
+ globals.set("fetch", fetchStub);
+ FakePrefs.prototype.prefs["feeds.section.topstories.options"] = "{}";
+ instance.init();
+ assert.notCalled(fetchStub);
+ });
+ it("should report error for invalid configuration", () => {
+ globals.sandbox.spy(global.Components.utils, "reportError");
+ FakePrefs.prototype.prefs["feeds.section.topstories.options"] = "invalid";
+ instance.init();
+
+ assert.called(Components.utils.reportError);
+ });
+ it("should report error for missing api key", () => {
+ let fakeServices = {prefs: {getCharPref: sinon.spy()}};
+ globals.set("Services", fakeServices);
+ globals.sandbox.spy(global.Components.utils, "reportError");
+ FakePrefs.prototype.prefs["feeds.section.topstories.options"] = `{
+ "stories_endpoint": "https://somedomain.org/stories?key=$apiKey",
+ "topics_endpoint": "https://somedomain.org/topics?key=$apiKey"
+ }`;
+ instance.init();
+
+ assert.called(Components.utils.reportError);
+ });
+ it("should deregister section", () => {
+ instance.onAction({type: at.UNINIT});
+ assert.calledOnce(instance.store.dispatch);
+ assert.calledWith(instance.store.dispatch, ac.BroadcastToContent({
+ type: at.SECTION_DEREGISTER,
+ data: SECTION_ID
+ }));
+ });
+ it("should initialize on FEED_INIT", () => {
+ instance.init = sinon.spy();
+ instance.onAction({type: at.FEED_INIT, data: "feeds.section.topstories"});
+ assert.calledOnce(instance.init);
+ });
+ });
+ describe("#fetch", () => {
+ it("should fetch stories and send event", async () => {
+ let fetchStub = globals.sandbox.stub();
+ globals.set("fetch", fetchStub);
+ globals.set("NewTabUtils", {blockedLinks: {isBlocked: globals.sandbox.spy()}});
+
+ const response = `{"list": [{"id" : "1",
+ "title": "title",
+ "excerpt": "description",
+ "image_src": "image-url",
+ "dedupe_url": "rec-url",
+ "published_timestamp" : "123"
+ }]}`;
+ const stories = [{
+ "guid": "1",
+ "type": "trending",
+ "title": "title",
+ "description": "description",
+ "image": "image-url",
+ "url": "rec-url",
+ "lastVisitDate": "123"
+ }];
+
+ instance.stories_endpoint = "stories-endpoint";
+ fetchStub.resolves({ok: true, status: 200, text: () => response});
+ await instance.fetchStories();
+
+ assert.calledOnce(fetchStub);
+ assert.calledWithExactly(fetchStub, instance.stories_endpoint);
+ assert.calledOnce(instance.store.dispatch);
+ assert.propertyVal(instance.store.dispatch.firstCall.args[0], "type", at.SECTION_ROWS_UPDATE);
+ assert.deepEqual(instance.store.dispatch.firstCall.args[0].data.id, SECTION_ID);
+ assert.deepEqual(instance.store.dispatch.firstCall.args[0].data.rows, stories);
+ });
+ it("should dispatch events", () => {
+ instance.dispatchUpdateEvent(123, {});
+ assert.calledOnce(instance.store.dispatch);
+ });
+ it("should report error for unexpected stories response", async () => {
+ let fetchStub = globals.sandbox.stub();
+ globals.set("fetch", fetchStub);
+ globals.sandbox.spy(global.Components.utils, "reportError");
+
+ instance.stories_endpoint = "stories-endpoint";
+ fetchStub.resolves({ok: false, status: 400});
+ await instance.fetchStories();
+
+ assert.calledOnce(fetchStub);
+ assert.calledWithExactly(fetchStub, instance.stories_endpoint);
+ assert.notCalled(instance.store.dispatch);
+ assert.called(Components.utils.reportError);
+ });
+ it("should exclude blocked (dismissed) URLs", async () => {
+ let fetchStub = globals.sandbox.stub();
+ globals.set("fetch", fetchStub);
+ globals.set("NewTabUtils", {blockedLinks: {isBlocked: url => url === "blocked"}});
+
+ const response = `{"list": [{"dedupe_url" : "blocked"}, {"dedupe_url" : "not_blocked"}]}`;
+ instance.stories_endpoint = "stories-endpoint";
+ fetchStub.resolves({ok: true, status: 200, text: () => response});
+ await instance.fetchStories();
+
+ assert.calledOnce(instance.store.dispatch);
+ assert.propertyVal(instance.store.dispatch.firstCall.args[0], "type", at.SECTION_ROWS_UPDATE);
+ assert.equal(instance.store.dispatch.firstCall.args[0].data.rows.length, 1);
+ assert.equal(instance.store.dispatch.firstCall.args[0].data.rows[0].url, "not_blocked");
+ });
+ it("should fetch topics and send event", async () => {
+ let fetchStub = globals.sandbox.stub();
+ globals.set("fetch", fetchStub);
+
+ const response = `{"topics": [{"name" : "topic1", "url" : "url-topic1"}, {"name" : "topic2", "url" : "url-topic2"}]}`;
+ const topics = [{
+ "name": "topic1",
+ "url": "url-topic1"
+ }, {
+ "name": "topic2",
+ "url": "url-topic2"
+ }];
+
+ instance.topics_endpoint = "topics-endpoint";
+ fetchStub.resolves({ok: true, status: 200, text: () => response});
+ await instance.fetchTopics();
+
+ assert.calledOnce(fetchStub);
+ assert.calledWithExactly(fetchStub, instance.topics_endpoint);
+ assert.calledOnce(instance.store.dispatch);
+ assert.propertyVal(instance.store.dispatch.firstCall.args[0], "type", at.SECTION_ROWS_UPDATE);
+ assert.deepEqual(instance.store.dispatch.firstCall.args[0].data.id, SECTION_ID);
+ assert.deepEqual(instance.store.dispatch.firstCall.args[0].data.topics, topics);
+ });
+ it("should report error for unexpected topics response", async () => {
+ let fetchStub = globals.sandbox.stub();
+ globals.set("fetch", fetchStub);
+ globals.sandbox.spy(global.Components.utils, "reportError");
+
+ instance.topics_endpoint = "topics-endpoint";
+ fetchStub.resolves({ok: false, status: 400});
+ await instance.fetchTopics();
+
+ assert.calledOnce(fetchStub);
+ assert.calledWithExactly(fetchStub, instance.topics_endpoint);
+ assert.notCalled(instance.store.dispatch);
+ assert.called(Components.utils.reportError);
+ });
+ });
+ describe("#update", () => {
+ it("should fetch stories after update interval", () => {
+ instance.fetchStories = sinon.spy();
+ instance.fetchTopics = sinon.spy();
+ instance.onAction({type: at.SYSTEM_TICK});
+ assert.notCalled(instance.fetchStories);
+
+ clock.tick(STORIES_UPDATE_TIME);
+ instance.onAction({type: at.SYSTEM_TICK});
+ assert.calledOnce(instance.fetchStories);
+ });
+ it("should fetch topics after update interval", () => {
+ instance.fetchStories = sinon.spy();
+ instance.fetchTopics = sinon.spy();
+ instance.onAction({type: at.SYSTEM_TICK});
+ assert.notCalled(instance.fetchTopics);
+
+ clock.tick(TOPICS_UPDATE_TIME);
+ instance.onAction({type: at.SYSTEM_TICK});
+ assert.calledOnce(instance.fetchTopics);
+ });
+ });
+});
--- a/browser/extensions/activity-stream/test/unit/lib/init-store.test.js
+++ b/browser/extensions/activity-stream/test/unit/lib/init-store.test.js
@@ -19,16 +19,26 @@ describe("initStore", () => {
it("should add a listener for incoming actions", () => {
assert.calledWith(global.addMessageListener, initStore.INCOMING_MESSAGE_NAME);
const callback = global.addMessageListener.firstCall.args[1];
globals.sandbox.spy(store, "dispatch");
const message = {name: initStore.INCOMING_MESSAGE_NAME, data: {type: "FOO"}};
callback(message);
assert.calledWith(store.dispatch, message.data);
});
+ it("should log errors from failed messages", () => {
+ const callback = global.addMessageListener.firstCall.args[1];
+ globals.sandbox.stub(global.console, "error");
+ globals.sandbox.stub(store, "dispatch").throws(Error("failed"));
+
+ const message = {name: initStore.INCOMING_MESSAGE_NAME, data: {type: "FOO"}};
+ callback(message);
+
+ assert.calledOnce(global.console.error);
+ });
it("should replace the state if a MERGE_STORE_ACTION is dispatched", () => {
store.dispatch({type: initStore.MERGE_STORE_ACTION, data: {number: 42}});
assert.deepEqual(store.getState(), {number: 42});
});
it("should send out SendToMain ations", () => {
const action = ac.SendToMain({type: "FOO"});
store.dispatch(action);
assert.calledWith(global.sendAsyncMessage, initStore.OUTGOING_MESSAGE_NAME, action);
--- a/browser/extensions/activity-stream/test/unit/unit-entry.js
+++ b/browser/extensions/activity-stream/test/unit/unit-entry.js
@@ -24,26 +24,29 @@ overrider.set({
},
// eslint-disable-next-line object-shorthand
ContentSearchUIController: function() {}, // NB: This is a function/constructor
dump() {},
fetch() {},
Preferences: FakePrefs,
Services: {
locale: {getRequestedLocale() {}},
+ urlFormatter: {formatURL: str => str},
mm: {
addMessageListener: (msg, cb) => cb(),
removeMessageListener() {}
},
appShell: {hiddenDOMWindow: {performance: new FakePerformance()}},
obs: {
addObserver() {},
removeObserver() {}
},
prefs: {
+ addObserver() {},
+ removeObserver() {},
getStringPref() {},
getDefaultBranch() {
return {
setBoolPref() {},
setIntPref() {},
setStringPref() {},
clearUserPref() {}
};