Bug 1379522 - Add pinned sites, context menu ordering, cleared history, other fixes to Activity Stream. r=ursula

MozReview-Commit-ID: ESlcuSuzoDH

--HG--
extra : rebase_source : 83739c36ee85a074fc672a8c446e0bea095e2284
This commit is contained in:
Ed Lee 2017-07-09 17:04:38 -07:00
parent dce63d52a4
commit 3c6952efcf
16 changed files with 1075 additions and 399 deletions

View File

@ -39,6 +39,7 @@ for (const type of [
"NEW_TAB_VISIBLE",
"OPEN_NEW_WINDOW",
"OPEN_PRIVATE_WINDOW",
"PINNED_SITES_UPDATED",
"PLACES_BOOKMARK_ADDED",
"PLACES_BOOKMARK_CHANGED",
"PLACES_BOOKMARK_REMOVED",
@ -47,11 +48,14 @@ for (const type of [
"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"
]) {

View File

@ -51,9 +51,44 @@ function App(prevState = INITIAL_STATE.App, action) {
}
}
/**
* insertPinned - Inserts pinned links in their specified slots
*
* @param {array} a list of links
* @param {array} a list of pinned links
* @return {array} resulting list of links with pinned links inserted
*/
function insertPinned(links, pinned) {
// Remove any pinned links
const pinnedUrls = pinned.map(link => link && link.url);
let newLinks = links.filter(link => (link ? !pinnedUrls.includes(link.url) : false));
newLinks = newLinks.map(link => {
if (link && link.isPinned) {
delete link.isPinned;
delete link.pinTitle;
delete link.pinIndex;
}
return link;
});
// Then insert them in their specified location
pinned.forEach((val, index) => {
if (!val) { return; }
let link = Object.assign({}, val, {isPinned: true, pinIndex: index, pinTitle: val.title});
if (index > newLinks.length) {
newLinks[index] = link;
} else {
newLinks.splice(index, 0, link);
}
});
return newLinks;
}
function TopSites(prevState = INITIAL_STATE.TopSites, action) {
let hasMatch;
let newRows;
let pinned;
switch (action.type) {
case at.TOP_SITES_UPDATED:
if (!action.data) {
@ -62,7 +97,7 @@ function TopSites(prevState = INITIAL_STATE.TopSites, action) {
return Object.assign({}, prevState, {initialized: true, rows: action.data});
case at.SCREENSHOT_UPDATED:
newRows = prevState.rows.map(row => {
if (row.url === action.data.url) {
if (row && row.url === action.data.url) {
hasMatch = true;
return Object.assign({}, row, {screenshot: action.data.screenshot});
}
@ -71,7 +106,7 @@ function TopSites(prevState = INITIAL_STATE.TopSites, action) {
return hasMatch ? Object.assign({}, prevState, {rows: newRows}) : prevState;
case at.PLACES_BOOKMARK_ADDED:
newRows = prevState.rows.map(site => {
if (site.url === action.data.url) {
if (site && site.url === action.data.url) {
const {bookmarkGuid, bookmarkTitle, lastModified} = action.data;
return Object.assign({}, site, {bookmarkGuid, bookmarkTitle, bookmarkDateCreated: lastModified});
}
@ -80,7 +115,7 @@ function TopSites(prevState = INITIAL_STATE.TopSites, action) {
return Object.assign({}, prevState, {rows: newRows});
case at.PLACES_BOOKMARK_REMOVED:
newRows = prevState.rows.map(site => {
if (site.url === action.data.url) {
if (site && site.url === action.data.url) {
const newSite = Object.assign({}, site);
delete newSite.bookmarkGuid;
delete newSite.bookmarkTitle;
@ -92,7 +127,11 @@ function TopSites(prevState = INITIAL_STATE.TopSites, action) {
return Object.assign({}, prevState, {rows: newRows});
case at.PLACES_LINK_DELETED:
case at.PLACES_LINK_BLOCKED:
newRows = prevState.rows.filter(val => val.url !== action.data.url);
newRows = prevState.rows.filter(val => val && val.url !== action.data.url);
return Object.assign({}, prevState, {rows: newRows});
case at.PINNED_SITES_UPDATED:
pinned = action.data;
newRows = insertPinned(prevState.rows, pinned);
return Object.assign({}, prevState, {rows: newRows});
default:
return prevState;
@ -128,5 +167,6 @@ function Prefs(prevState = INITIAL_STATE.Prefs, action) {
this.INITIAL_STATE = INITIAL_STATE;
this.reducers = {TopSites, App, Prefs, Dialog};
this.insertPinned = insertPinned;
this.EXPORTED_SYMBOLS = ["reducers", "INITIAL_STATE"];
this.EXPORTED_SYMBOLS = ["reducers", "INITIAL_STATE", "insertPinned"];

View File

@ -63,17 +63,11 @@
/******/ __webpack_require__.p = "";
/******/
/******/ // Load entry module and return exports
/******/ return __webpack_require__(__webpack_require__.s = 18);
/******/ return __webpack_require__(__webpack_require__.s = 19);
/******/ })
/************************************************************************/
/******/ ([
/* 0 */
/***/ (function(module, exports) {
module.exports = React;
/***/ }),
/* 1 */
/***/ (function(module, exports, __webpack_require__) {
"use strict";
@ -103,7 +97,7 @@ const globalImportContext = typeof Window === "undefined" ? BACKGROUND_PROCESS :
// 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", "PLACES_BOOKMARK_ADDED", "PLACES_BOOKMARK_CHANGED", "PLACES_BOOKMARK_REMOVED", "PLACES_HISTORY_CLEARED", "PLACES_LINK_BLOCKED", "PLACES_LINK_DELETED", "PREFS_INITIAL_VALUES", "PREF_CHANGED", "SCREENSHOT_UPDATED", "SET_PREF", "TELEMETRY_PERFORMANCE_EVENT", "TELEMETRY_UNDESIRED_EVENT", "TELEMETRY_USER_EVENT", "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", "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"]) {
actionTypes[type] = type;
}
@ -281,6 +275,12 @@ module.exports = {
CONTENT_MESSAGE_TYPE
};
/***/ }),
/* 1 */
/***/ (function(module, exports) {
module.exports = React;
/***/ }),
/* 2 */
/***/ (function(module, exports) {
@ -300,7 +300,42 @@ module.exports = ReactIntl;
"use strict";
const React = __webpack_require__(0);
/**
* shortURL - Creates a short version of a link's url, used for display purposes
* e.g. {url: http://www.foosite.com, eTLD: "com"} => "foosite"
*
* @param {obj} link A link object
* {str} link.url (required)- The url of the link
* {str} link.eTLD (required) - The tld of the link
* e.g. for https://foo.org, the tld would be "org"
* Note that this property is added in various queries for ActivityStream
* via Services.eTLD.getPublicSuffix
* {str} link.hostname (optional) - The hostname of the url
* e.g. for http://www.hello.com/foo/bar, the hostname would be "www.hello.com"
* @return {str} A short url
*/
module.exports = function shortURL(link) {
if (!link.url && !link.hostname) {
return "";
}
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();
};
/***/ }),
/* 5 */
/***/ (function(module, exports, __webpack_require__) {
"use strict";
const React = __webpack_require__(1);
var _require = __webpack_require__(2);
@ -311,10 +346,10 @@ var _require2 = __webpack_require__(3);
const addLocaleData = _require2.addLocaleData,
IntlProvider = _require2.IntlProvider;
const TopSites = __webpack_require__(14);
const Search = __webpack_require__(13);
const ConfirmDialog = __webpack_require__(9);
const PreferencesPane = __webpack_require__(12);
const TopSites = __webpack_require__(15);
const Search = __webpack_require__(14);
const ConfirmDialog = __webpack_require__(10);
const PreferencesPane = __webpack_require__(13);
// Locales that should be displayed RTL
const RTL_LIST = ["ar", "he", "fa", "ur"];
@ -385,17 +420,17 @@ class Base extends React.Component {
module.exports = connect(state => ({ App: state.App, Prefs: state.Prefs }))(Base);
/***/ }),
/* 5 */
/* 6 */
/***/ (function(module, exports, __webpack_require__) {
"use strict";
var _require = __webpack_require__(1);
var _require = __webpack_require__(0);
const at = _require.actionTypes;
var _require2 = __webpack_require__(16);
var _require2 = __webpack_require__(17);
const perfSvc = _require2.perfService;
@ -460,7 +495,7 @@ module.exports = class DetectUserSessionStart {
};
/***/ }),
/* 6 */
/* 7 */
/***/ (function(module, exports, __webpack_require__) {
"use strict";
@ -468,13 +503,13 @@ module.exports = class DetectUserSessionStart {
/* eslint-env mozilla/frame-script */
var _require = __webpack_require__(17);
var _require = __webpack_require__(18);
const createStore = _require.createStore,
combineReducers = _require.combineReducers,
applyMiddleware = _require.applyMiddleware;
var _require2 = __webpack_require__(1);
var _require2 = __webpack_require__(0);
const au = _require2.actionUtils;
@ -540,7 +575,7 @@ module.exports.OUTGOING_MESSAGE_NAME = OUTGOING_MESSAGE_NAME;
module.exports.INCOMING_MESSAGE_NAME = INCOMING_MESSAGE_NAME;
/***/ }),
/* 7 */
/* 8 */
/***/ (function(module, exports, __webpack_require__) {
"use strict";
@ -549,7 +584,7 @@ module.exports.INCOMING_MESSAGE_NAME = INCOMING_MESSAGE_NAME;
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
var _require = __webpack_require__(1);
var _require = __webpack_require__(0);
const at = _require.actionTypes;
@ -607,12 +642,49 @@ function App() {
}
}
/**
* insertPinned - Inserts pinned links in their specified slots
*
* @param {array} a list of links
* @param {array} a list of pinned links
* @return {array} resulting list of links with pinned links inserted
*/
function insertPinned(links, pinned) {
// Remove any pinned links
const pinnedUrls = pinned.map(link => link && link.url);
let newLinks = links.filter(link => link ? !pinnedUrls.includes(link.url) : false);
newLinks = newLinks.map(link => {
if (link && link.isPinned) {
delete link.isPinned;
delete link.pinTitle;
delete link.pinIndex;
}
return link;
});
// Then insert them in their specified location
pinned.forEach((val, index) => {
if (!val) {
return;
}
let link = Object.assign({}, val, { isPinned: true, pinIndex: index, pinTitle: val.title });
if (index > newLinks.length) {
newLinks[index] = link;
} else {
newLinks.splice(index, 0, link);
}
});
return newLinks;
}
function TopSites() {
let prevState = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : INITIAL_STATE.TopSites;
let action = arguments[1];
let hasMatch;
let newRows;
let pinned;
switch (action.type) {
case at.TOP_SITES_UPDATED:
if (!action.data) {
@ -621,7 +693,7 @@ function TopSites() {
return Object.assign({}, prevState, { initialized: true, rows: action.data });
case at.SCREENSHOT_UPDATED:
newRows = prevState.rows.map(row => {
if (row.url === action.data.url) {
if (row && row.url === action.data.url) {
hasMatch = true;
return Object.assign({}, row, { screenshot: action.data.screenshot });
}
@ -630,7 +702,7 @@ function TopSites() {
return hasMatch ? Object.assign({}, prevState, { rows: newRows }) : prevState;
case at.PLACES_BOOKMARK_ADDED:
newRows = prevState.rows.map(site => {
if (site.url === action.data.url) {
if (site && site.url === action.data.url) {
var _action$data2 = action.data;
const bookmarkGuid = _action$data2.bookmarkGuid,
bookmarkTitle = _action$data2.bookmarkTitle,
@ -643,7 +715,7 @@ function TopSites() {
return Object.assign({}, prevState, { rows: newRows });
case at.PLACES_BOOKMARK_REMOVED:
newRows = prevState.rows.map(site => {
if (site.url === action.data.url) {
if (site && site.url === action.data.url) {
const newSite = Object.assign({}, site);
delete newSite.bookmarkGuid;
delete newSite.bookmarkTitle;
@ -655,7 +727,11 @@ function TopSites() {
return Object.assign({}, prevState, { rows: newRows });
case at.PLACES_LINK_DELETED:
case at.PLACES_LINK_BLOCKED:
newRows = prevState.rows.filter(val => val.url !== action.data.url);
newRows = prevState.rows.filter(val => val && val.url !== action.data.url);
return Object.assign({}, prevState, { rows: newRows });
case at.PINNED_SITES_UPDATED:
pinned = action.data;
newRows = insertPinned(prevState.rows, pinned);
return Object.assign({}, prevState, { rows: newRows });
default:
return prevState;
@ -698,23 +774,24 @@ function Prefs() {
var reducers = { TopSites, App, Prefs, Dialog };
module.exports = {
reducers,
INITIAL_STATE
INITIAL_STATE,
insertPinned
};
/***/ }),
/* 8 */
/* 9 */
/***/ (function(module, exports) {
module.exports = ReactDOM;
/***/ }),
/* 9 */
/* 10 */
/***/ (function(module, exports, __webpack_require__) {
"use strict";
const React = __webpack_require__(0);
const React = __webpack_require__(1);
var _require = __webpack_require__(2);
@ -724,7 +801,7 @@ var _require2 = __webpack_require__(3);
const FormattedMessage = _require2.FormattedMessage;
var _require3 = __webpack_require__(1);
var _require3 = __webpack_require__(0);
const actionTypes = _require3.actionTypes,
ac = _require3.actionCreators;
@ -827,13 +904,13 @@ module.exports._unconnected = ConfirmDialog;
module.exports.Dialog = ConfirmDialog;
/***/ }),
/* 10 */
/* 11 */
/***/ (function(module, exports, __webpack_require__) {
"use strict";
const React = __webpack_require__(0);
const React = __webpack_require__(1);
class ContextMenu extends React.Component {
constructor(props) {
@ -910,108 +987,39 @@ class ContextMenu extends React.Component {
module.exports = ContextMenu;
/***/ }),
/* 11 */
/* 12 */
/***/ (function(module, exports, __webpack_require__) {
"use strict";
const React = __webpack_require__(0);
const React = __webpack_require__(1);
var _require = __webpack_require__(3);
const injectIntl = _require.injectIntl;
const ContextMenu = __webpack_require__(10);
const ContextMenu = __webpack_require__(11);
var _require2 = __webpack_require__(1);
var _require2 = __webpack_require__(0);
const at = _require2.actionTypes,
ac = _require2.actionCreators;
const ac = _require2.actionCreators;
const RemoveBookmark = site => ({
id: "menu_action_remove_bookmark",
icon: "bookmark-remove",
action: ac.SendToMain({
type: at.DELETE_BOOKMARK_BY_ID,
data: site.bookmarkGuid
}),
userEvent: "BOOKMARK_DELETE"
});
const AddBookmark = site => ({
id: "menu_action_bookmark",
icon: "bookmark",
action: ac.SendToMain({
type: at.BOOKMARK_URL,
data: site.url
}),
userEvent: "BOOKMARK_ADD"
});
const OpenInNewWindow = site => ({
id: "menu_action_open_new_window",
icon: "new-window",
action: ac.SendToMain({
type: at.OPEN_NEW_WINDOW,
data: { url: site.url }
}),
userEvent: "OPEN_NEW_WINDOW"
});
const OpenInPrivateWindow = site => ({
id: "menu_action_open_private_window",
icon: "new-window-private",
action: ac.SendToMain({
type: at.OPEN_PRIVATE_WINDOW,
data: { url: site.url }
}),
userEvent: "OPEN_PRIVATE_WINDOW"
});
const BlockUrl = site => ({
id: "menu_action_dismiss",
icon: "dismiss",
action: ac.SendToMain({
type: at.BLOCK_URL,
data: site.url
}),
userEvent: "BLOCK"
});
const DeleteUrl = site => ({
id: "menu_action_delete",
icon: "delete",
action: {
type: at.DIALOG_OPEN,
data: {
onConfirm: [ac.SendToMain({ type: at.DELETE_HISTORY_URL, data: site.url }), ac.UserEvent({ event: "DELETE" })],
body_string_id: ["confirm_history_delete_p1", "confirm_history_delete_notice_p2"],
confirm_button_string_id: "menu_action_delete"
}
},
userEvent: "DIALOG_OPEN"
});
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;
const site = props.site,
index = props.index,
source = props.source;
const isBookmark = site.bookmarkGuid;
const isDefault = site.isDefault;
// Handle special case of default site
const options = [
const propOptions = !site.isDefault ? props.options : DEFAULT_SITE_MENU_OPTIONS;
// Bookmarks
!isDefault && (isBookmark ? RemoveBookmark(site) : AddBookmark(site)), !isDefault && { type: "separator" },
// Menu items for all sites
OpenInNewWindow(site), OpenInPrivateWindow(site),
// Blocking and deleting
!isDefault && { type: "separator" }, !isDefault && BlockUrl(site), !isDefault && DeleteUrl(site)].filter(o => o).map(option => {
const options = propOptions.map(o => linkMenuOptions[o](site, index)).map(option => {
const action = option.action,
id = option.id,
type = option.type,
@ -1024,8 +1032,8 @@ class LinkMenu extends React.Component {
if (userEvent) {
props.dispatch(ac.UserEvent({
event: userEvent,
source: props.source,
action_position: props.index
source,
action_position: index
}));
}
};
@ -1052,13 +1060,13 @@ module.exports = injectIntl(LinkMenu);
module.exports._unconnected = LinkMenu;
/***/ }),
/* 12 */
/* 13 */
/***/ (function(module, exports, __webpack_require__) {
"use strict";
const React = __webpack_require__(0);
const React = __webpack_require__(1);
var _require = __webpack_require__(2);
@ -1069,7 +1077,7 @@ var _require2 = __webpack_require__(3);
const injectIntl = _require2.injectIntl,
FormattedMessage = _require2.FormattedMessage;
var _require3 = __webpack_require__(1);
var _require3 = __webpack_require__(0);
const ac = _require3.actionCreators;
@ -1178,14 +1186,14 @@ module.exports.PreferencesPane = PreferencesPane;
module.exports.PreferencesInput = PreferencesInput;
/***/ }),
/* 13 */
/* 14 */
/***/ (function(module, exports, __webpack_require__) {
"use strict";
/* globals ContentSearchUIController */
const React = __webpack_require__(0);
const React = __webpack_require__(1);
var _require = __webpack_require__(2);
@ -1196,7 +1204,7 @@ var _require2 = __webpack_require__(3);
const FormattedMessage = _require2.FormattedMessage,
injectIntl = _require2.injectIntl;
var _require3 = __webpack_require__(1);
var _require3 = __webpack_require__(0);
const ac = _require3.actionCreators;
@ -1277,13 +1285,13 @@ module.exports = connect()(injectIntl(Search));
module.exports._unconnected = Search;
/***/ }),
/* 14 */
/* 15 */
/***/ (function(module, exports, __webpack_require__) {
"use strict";
const React = __webpack_require__(0);
const React = __webpack_require__(1);
var _require = __webpack_require__(2);
@ -1293,14 +1301,15 @@ var _require2 = __webpack_require__(3);
const FormattedMessage = _require2.FormattedMessage;
const shortURL = __webpack_require__(15);
const LinkMenu = __webpack_require__(11);
const shortURL = __webpack_require__(4);
const LinkMenu = __webpack_require__(12);
var _require3 = __webpack_require__(1);
var _require3 = __webpack_require__(0);
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) {
@ -1374,7 +1383,8 @@ class TopSite extends React.Component {
onUpdate: val => this.setState({ showContextMenu: val }),
site: link,
index: index,
source: TOP_SITES_SOURCE })
source: TOP_SITES_SOURCE,
options: TOP_SITES_CONTEXT_MENU_OPTIONS })
);
}
}
@ -1390,7 +1400,7 @@ const TopSites = props => React.createElement(
React.createElement(
"ul",
{ className: "top-sites-list" },
props.TopSites.rows.map((link, index) => React.createElement(TopSite, {
props.TopSites.rows.map((link, index) => link && React.createElement(TopSite, {
key: link.url,
dispatch: props.dispatch,
link: link,
@ -1403,42 +1413,118 @@ module.exports._unconnected = TopSites;
module.exports.TopSite = TopSite;
/***/ }),
/* 15 */
/* 16 */
/***/ (function(module, exports, __webpack_require__) {
"use strict";
var _require = __webpack_require__(0);
const at = _require.actionTypes,
ac = _require.actionCreators;
const shortURL = __webpack_require__(4);
/**
* shortURL - Creates a short version of a link's url, used for display purposes
* e.g. {url: http://www.foosite.com, eTLD: "com"} => "foosite"
*
* @param {obj} link A link object
* {str} link.url (required)- The url of the link
* {str} link.eTLD (required) - The tld of the link
* e.g. for https://foo.org, the tld would be "org"
* Note that this property is added in various queries for ActivityStream
* via Services.eTLD.getPublicSuffix
* {str} link.hostname (optional) - The hostname of the url
* e.g. for http://www.hello.com/foo/bar, the hostname would be "www.hello.com"
* @return {str} A short url
* List of functions that return items that can be included as menu options in a
* LinkMenu. All functions take the site as the first parameter, and optionally
* the index of the site.
*/
module.exports = function shortURL(link) {
if (!link.url && !link.hostname) {
return "";
}
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();
module.exports = {
Separator: () => ({ type: "separator" }),
RemoveBookmark: site => ({
id: "menu_action_remove_bookmark",
icon: "bookmark-remove",
action: ac.SendToMain({
type: at.DELETE_BOOKMARK_BY_ID,
data: site.bookmarkGuid
}),
userEvent: "BOOKMARK_DELETE"
}),
AddBookmark: site => ({
id: "menu_action_bookmark",
icon: "bookmark",
action: ac.SendToMain({
type: at.BOOKMARK_URL,
data: site.url
}),
userEvent: "BOOKMARK_ADD"
}),
OpenInNewWindow: site => ({
id: "menu_action_open_new_window",
icon: "new-window",
action: ac.SendToMain({
type: at.OPEN_NEW_WINDOW,
data: { url: site.url }
}),
userEvent: "OPEN_NEW_WINDOW"
}),
OpenInPrivateWindow: site => ({
id: "menu_action_open_private_window",
icon: "new-window-private",
action: ac.SendToMain({
type: at.OPEN_PRIVATE_WINDOW,
data: { url: site.url }
}),
userEvent: "OPEN_PRIVATE_WINDOW"
}),
BlockUrl: site => ({
id: "menu_action_dismiss",
icon: "dismiss",
action: ac.SendToMain({
type: at.BLOCK_URL,
data: site.url
}),
userEvent: "BLOCK"
}),
DeleteUrl: site => ({
id: "menu_action_delete",
icon: "delete",
action: {
type: at.DIALOG_OPEN,
data: {
onConfirm: [ac.SendToMain({ type: at.DELETE_HISTORY_URL, data: site.url }), ac.UserEvent({ event: "DELETE" })],
body_string_id: ["confirm_history_delete_p1", "confirm_history_delete_notice_p2"],
confirm_button_string_id: "menu_action_delete"
}
},
userEvent: "DIALOG_OPEN"
}),
PinTopSite: (site, index) => ({
id: "menu_action_pin",
icon: "pin",
action: ac.SendToMain({
type: at.TOP_SITES_PIN,
data: { site: { url: site.url, title: shortURL(site) }, index }
}),
userEvent: "PIN"
}),
UnpinTopSite: site => ({
id: "menu_action_unpin",
icon: "unpin",
action: ac.SendToMain({
type: at.TOP_SITES_UNPIN,
data: { site: { url: site.url } }
}),
userEvent: "UNPIN"
}),
SaveToPocket: site => ({
id: "menu_action_save_to_pocket",
icon: "pocket",
action: ac.SendToMain({
type: at.SAVE_TO_POCKET,
data: { site: { url: site.url, title: site.title } }
}),
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);
/***/ }),
/* 16 */
/* 17 */
/***/ (function(module, exports, __webpack_require__) {
"use strict";
@ -1543,33 +1629,33 @@ module.exports = {
};
/***/ }),
/* 17 */
/* 18 */
/***/ (function(module, exports) {
module.exports = Redux;
/***/ }),
/* 18 */
/* 19 */
/***/ (function(module, exports, __webpack_require__) {
"use strict";
const React = __webpack_require__(0);
const ReactDOM = __webpack_require__(8);
const Base = __webpack_require__(4);
const React = __webpack_require__(1);
const ReactDOM = __webpack_require__(9);
const Base = __webpack_require__(5);
var _require = __webpack_require__(2);
const Provider = _require.Provider;
const initStore = __webpack_require__(6);
const initStore = __webpack_require__(7);
var _require2 = __webpack_require__(7);
var _require2 = __webpack_require__(8);
const reducers = _require2.reducers;
const DetectUserSessionStart = __webpack_require__(5);
const DetectUserSessionStart = __webpack_require__(6);
new DetectUserSessionStart().sendEventOrAddListener();

View File

@ -44,6 +44,12 @@ input {
background-image: url("assets/glyph-newWindow-private-16.svg"); }
.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-pin-small {
background-image: url("assets/glyph-pin-12.svg");
background-size: 12px;

View File

@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<style>
path {
fill: #4d4d4d;
}
</style>
<path d="M14.707,13.293,11.414,10l2.293-2.293a1,1,0,0,0,0-1.414A4.384,4.384,0,0,0,10.586,5h-.172A2.415,2.415,0,0,1,8,2.586V2a1,1,0,0,0-1.707-.707l-5,5A1,1,0,0,0,2,8h.586A2.415,2.415,0,0,1,5,10.414v.169a4.036,4.036,0,0,0,1.337,3.166,1,1,0,0,0,1.37-.042L10,11.414l3.293,3.293a1,1,0,0,0,1.414-1.414ZM7.129,11.456A2.684,2.684,0,0,1,7,10.583v-.169A4.386,4.386,0,0,0,5.708,7.293,4.414,4.414,0,0,0,4.136,6.278L6.279,4.136A4.4,4.4,0,0,0,7.292,5.707,4.384,4.384,0,0,0,10.414,7h.172a2.4,2.4,0,0,1,.848.152Z"/>
</svg>

After

Width:  |  Height:  |  Size: 652 B

View File

@ -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="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>

After

Width:  |  Height:  |  Size: 597 B

View File

@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<style>
path {
fill: #4d4d4d;
}
</style>
<g>
<path d="M11.414,10l2.293-2.293a1,1,0,0,0,0-1.414,4.418,4.418,0,0,0-.8-.622L11.425,7.15l.008,0-4.3,4.3,0-.017-1.48,1.476a3.865,3.865,0,0,0,.692.834,1,1,0,0,0,1.37-.042L10,11.414l3.293,3.293a1,1,0,0,0,1.414-1.414Z"/>
<path d="M14.707,1.293a1,1,0,0,0-1.414,0L9.7,4.882A2.382,2.382,0,0,1,8,2.586V2a1,1,0,0,0-1.707-.707l-5,5A1,1,0,0,0,2,8h.586a2.382,2.382,0,0,1,2.3,1.7L1.293,13.293a1,1,0,1,0,1.414,1.414l12-12A1,1,0,0,0,14.707,1.293Zm-9,6A4.414,4.414,0,0,0,4.136,6.278L6.279,4.136A4.4,4.4,0,0,0,7.292,5.707a4.191,4.191,0,0,0,.9.684l-1.8,1.8A4.2,4.2,0,0,0,5.708,7.293Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 739 B

File diff suppressed because it is too large Load Diff

View File

@ -55,8 +55,9 @@ this.TelemetryFeed = class TelemetryFeed {
* addSession - Start tracking a new session
*
* @param {string} id the portID of the open session
* @param {number} absVisChangeTime absolute timestamp of
* @param {number} absVisChangeTime Optional. Absolute timestamp of
* document.visibilityState becoming visible
* @return {obj} Session object
*/
addSession(id, absVisChangeTime) {
// XXX note that there is a race condition here; we're assuming that no
@ -78,25 +79,30 @@ this.TelemetryFeed = class TelemetryFeed {
// introduce, rather than doing the correct by complicated thing. It may
// well be worth reexamining this hypothesis after we have more experience
// with the data.
let absBrowserOpenTabStart =
perfService.getMostRecentAbsMarkStartByName("browser-open-newtab-start");
let absBrowserOpenTabStart;
try {
absBrowserOpenTabStart = perfService.getMostRecentAbsMarkStartByName("browser-open-newtab-start");
} catch (e) {
// Just use undefined so it doesn't get sent to the server
}
this.sessions.set(id, {
// If we're missing either starting timestamps, treat it as an unexpected
// session; otherwise, assume it's the usual behavior.
const triggerType = absBrowserOpenTabStart === undefined ||
absVisChangeTime === undefined ? "unexpected" : "menu_plus_or_keyboard";
const session = {
start_time: Components.utils.now(),
session_id: String(gUUIDGenerator.generateUUID()),
page: "about:newtab", // TODO: Handle about:home here and in perf below
perf: {
load_trigger_ts: absBrowserOpenTabStart,
load_trigger_type: "menu_plus_or_keyboard",
load_trigger_type: triggerType,
visibility_event_rcvd_ts: absVisChangeTime
}
});
let duration = absVisChangeTime - absBrowserOpenTabStart;
this.store.dispatch({
type: at.TELEMETRY_PERFORMANCE_EVENT,
data: {visability_duration: duration}
});
};
this.sessions.set(id, session);
return session;
}
/**
@ -133,7 +139,7 @@ this.TelemetryFeed = class TelemetryFeed {
// If the ping is part of a user session, add session-related info
if (portID) {
const session = this.sessions.get(portID);
const session = this.sessions.get(portID) || this.addSession(portID);
Object.assign(ping, {
session_id: session.session_id,
page: session.page

View File

@ -8,6 +8,7 @@ Cu.import("resource://gre/modules/XPCOMUtils.jsm");
const {actionCreators: ac, actionTypes: at} = Cu.import("resource://activity-stream/common/Actions.jsm", {});
const {Prefs} = Cu.import("resource://activity-stream/lib/ActivityStreamPrefs.jsm", {});
const {insertPinned} = Cu.import("resource://activity-stream/common/Reducers.jsm", {});
XPCOMUtils.defineLazyModuleGetter(this, "NewTabUtils",
"resource://gre/modules/NewTabUtils.jsm");
@ -39,23 +40,6 @@ this.TopSitesFeed = class TopSitesFeed {
const action = {type: at.SCREENSHOT_UPDATED, data: {url, screenshot}};
this.store.dispatch(ac.BroadcastToContent(action));
}
sortLinks(frecent, pinned) {
let sortedLinks = [...frecent, ...DEFAULT_TOP_SITES];
sortedLinks = sortedLinks.filter(link => !NewTabUtils.pinnedLinks.isPinned(link));
// Insert the pinned links in their specified location
pinned.forEach((val, index) => {
if (!val) { return; }
let link = Object.assign({}, val, {isPinned: true, pinIndex: index, pinTitle: val.title});
if (index > sortedLinks.length) {
sortedLinks[index] = link;
} else {
sortedLinks.splice(index, 0, link);
}
});
return sortedLinks.slice(0, TOP_SITES_SHOWMORE_LENGTH);
}
async getLinksWithDefaults(action) {
let pinned = NewTabUtils.pinnedLinks.links;
let frecent = await NewTabUtils.activityStreamLinks.getTopSites();
@ -66,38 +50,65 @@ this.TopSitesFeed = class TopSitesFeed {
frecent = frecent.filter(link => link && link.type !== "affiliate");
}
return this.sortLinks(frecent, pinned);
return insertPinned([...frecent, ...DEFAULT_TOP_SITES], pinned).slice(0, TOP_SITES_SHOWMORE_LENGTH);
}
async refresh(action) {
async refresh(target = null) {
const links = await this.getLinksWithDefaults();
// First, cache existing screenshots in case we need to reuse them
const currentScreenshots = {};
for (const link of this.store.getState().TopSites.rows) {
if (link.screenshot) {
if (link && link.screenshot) {
currentScreenshots[link.url] = link.screenshot;
}
}
// Now, get a screenshot for every item
for (let link of links) {
if (!link) { continue; }
if (currentScreenshots[link.url]) {
link.screenshot = currentScreenshots[link.url];
} else {
this.getScreenshot(link.url);
}
}
const newAction = {type: at.TOP_SITES_UPDATED, data: links};
// Send an update to content so the preloaded tab can get the updated content
this.store.dispatch(ac.SendToContent(newAction, action.meta.fromTarget));
if (target) {
// Send an update to content so the preloaded tab can get the updated content
this.store.dispatch(ac.SendToContent(newAction, target));
} else {
// Broadcast an update to all open content pages
this.store.dispatch(ac.BroadcastToContent(newAction));
}
this.lastUpdated = Date.now();
}
openNewWindow(action, isPrivate = false) {
const win = action._target.browser.ownerGlobal;
win.openLinkIn(action.data.url, "window", {private: isPrivate});
}
_getPinnedWithData() {
// Augment the pinned links with any other extra data we have for them already in the store
const links = this.store.getState().TopSites.rows;
const pinned = NewTabUtils.pinnedLinks.links;
return pinned.map(pinnedLink => (pinnedLink ? Object.assign(links.find(link => link && link.url === pinnedLink.url) || {}, pinnedLink, {isDefault: false}) : pinnedLink));
}
pin(action) {
const {site, index} = action.data;
NewTabUtils.pinnedLinks.pin(site, index);
this.store.dispatch(ac.BroadcastToContent({
type: at.PINNED_SITES_UPDATED,
data: this._getPinnedWithData()
}));
}
unpin(action) {
const {site} = action.data;
NewTabUtils.pinnedLinks.unpin(site);
this.store.dispatch(ac.BroadcastToContent({
type: at.PINNED_SITES_UPDATED,
data: this._getPinnedWithData()
}));
}
onAction(action) {
let realRows;
switch (action.type) {
@ -115,16 +126,24 @@ this.TopSitesFeed = class TopSitesFeed {
// is greater than 15 minutes, refresh the data.
(Date.now() - this.lastUpdated >= UPDATE_TIME)
) {
this.refresh(action);
this.refresh(action.meta.fromTarget);
}
break;
case at.OPEN_NEW_WINDOW:
this.openNewWindow(action);
break;
case at.OPEN_PRIVATE_WINDOW: {
case at.OPEN_PRIVATE_WINDOW:
this.openNewWindow(action, true);
break;
}
case at.PLACES_HISTORY_CLEARED:
this.refresh();
break;
case at.TOP_SITES_PIN:
this.pin(action);
break;
case at.TOP_SITES_UNPIN:
this.unpin(action);
break;
}
}
};

View File

@ -39,7 +39,10 @@ const UserEventAction = Joi.object().keys({
"OPEN_NEWTAB_PREFS",
"CLOSE_NEWTAB_PREFS",
"BOOKMARK_DELETE",
"BOOKMARK_ADD"
"BOOKMARK_ADD",
"PIN",
"UNPIN",
"SAVE_TO_POCKET"
]).required(),
source: Joi.valid(["TOP_SITES"]),
action_position: Joi.number().integer()
@ -82,8 +85,8 @@ const SessionPing = Joi.object().keys(Object.assign({}, baseKeys, {
//
// Not required at least for the error cases where the observer event
// doesn't fire
load_trigger_type: Joi.valid(["menu_plus_or_keyboard"])
.notes(["server counter", "server counter alert"]),
load_trigger_type: Joi.valid(["menu_plus_or_keyboard", "unexpected"])
.notes(["server counter", "server counter alert"]).required(),
// When the page itself receives an event that document.visibilityState
// == visible.

View File

@ -1,4 +1,4 @@
const {reducers, INITIAL_STATE} = require("common/Reducers.jsm");
const {reducers, INITIAL_STATE, insertPinned} = require("common/Reducers.jsm");
const {TopSites, App, Prefs, Dialog} = reducers;
const {actionTypes: at} = require("common/Actions.jsm");
@ -107,6 +107,13 @@ describe("Reducers", () => {
assert.deepEqual(nextState.rows, [{url: "foo.com"}]);
});
});
it("should insert pinned links on PINNED_SITES_UPDATED", () => {
const oldState = {rows: [{url: "foo.com"}, {url: "bar.com"}]};
const action = {type: at.PINNED_SITES_UPDATED, data: [{url: "baz.com", title: "baz"}]};
const nextState = TopSites(oldState, action);
console.log(nextState.rows);
assert.deepEqual(nextState.rows, [{url: "baz.com", title: "baz", isPinned: true, pinIndex: 0, pinTitle: "baz"}, {url: "foo.com"}, {url: "bar.com"}]);
});
});
describe("Prefs", () => {
function prevState(custom = {}) {
@ -173,4 +180,69 @@ describe("Reducers", () => {
assert.deepEqual(INITIAL_STATE.Dialog, nextState);
});
});
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", () => {
const pinned = [
{"url": "http://github.com/mozilla/activity-stream", "title": "moz/a-s"},
{"url": "http://example.com", "title": "example"}
];
const result = insertPinned(links, pinned);
for (let index of [0, 1]) {
assert.equal(result[index].url, pinned[index].url);
assert.ok(result[index].isPinned);
assert.equal(result[index].pinTitle, pinned[index].title);
assert.equal(result[index].pinIndex, index);
}
assert.deepEqual(result.slice(2), links);
});
it("should handle empty slots in the pinned list", () => {
const pinned = [
null,
{"url": "http://github.com/mozilla/activity-stream", "title": "moz/a-s"},
null,
null,
{"url": "http://example.com", "title": "example"}
];
const result = insertPinned(links, pinned);
for (let index of [1, 4]) {
assert.equal(result[index].url, pinned[index].url);
assert.ok(result[index].isPinned);
assert.equal(result[index].pinTitle, pinned[index].title);
assert.equal(result[index].pinIndex, index);
}
result.splice(4, 1);
result.splice(1, 1);
assert.deepEqual(result, links);
});
it("should handle a pinned site past the end of the list of links", () => {
const pinned = [];
pinned[11] = {"url": "http://github.com/mozilla/activity-stream", "title": "moz/a-s"};
const result = insertPinned([], pinned);
assert.equal(result[11].url, pinned[11].url);
assert.isTrue(result[11].isPinned);
assert.equal(result[11].pinTitle, pinned[11].title);
assert.equal(result[11].pinIndex, 11);
});
it("should unpin previously pinned links no longer in the pinned list", () => {
const pinned = [];
links[2].isPinned = true;
links[2].pinTitle = "pinned site";
links[2].pinIndex = 2;
const result = insertPinned(links, pinned);
assert.notProperty(result[2], "isPinned");
assert.notProperty(result[2], "pinTitle");
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);
});
});
});

View File

@ -30,11 +30,6 @@ describe("TelemetryFeed", () => {
"common/PerfService.jsm": {perfService}
});
function addSession(id) {
instance.addSession(id);
return instance.sessions.get(id);
}
beforeEach(() => {
globals = new GlobalOverrider();
sandbox = globals.sandbox;
@ -64,26 +59,52 @@ describe("TelemetryFeed", () => {
});
});
describe("#addSession", () => {
it("should add a session", () => {
addSession("foo");
assert.isTrue(instance.sessions.has("foo"));
it("should add a session and return it", () => {
const session = instance.addSession("foo");
assert.equal(instance.sessions.get("foo"), session);
});
it("should set the start_time", () => {
sandbox.spy(Components.utils, "now");
const session = addSession("foo");
const session = instance.addSession("foo");
assert.calledOnce(Components.utils.now);
assert.equal(session.start_time, Components.utils.now.firstCall.returnValue);
});
it("should set the session_id", () => {
sandbox.spy(global.gUUIDGenerator, "generateUUID");
const session = addSession("foo");
const session = instance.addSession("foo");
assert.calledOnce(global.gUUIDGenerator.generateUUID);
assert.equal(session.session_id, global.gUUIDGenerator.generateUUID.firstCall.returnValue);
});
it("should set the page", () => {
const session = addSession("foo");
const session = instance.addSession("foo");
assert.equal(session.page, "about:newtab"); // This is hardcoded for now.
});
it("should set the perf type when lacking timestamp", () => {
const session = instance.addSession("foo");
assert.propertyVal(session.perf, "load_trigger_type", "unexpected");
});
it("should set the perf type with timestamp", () => {
const session = instance.addSession("foo", 123);
assert.propertyVal(session.perf, "load_trigger_type", "menu_plus_or_keyboard"); // This is hardcoded for now.
});
it("should save visibility time", () => {
const session = instance.addSession("foo", 123);
assert.propertyVal(session.perf, "visibility_event_rcvd_ts", 123);
});
it("should not save visibility time when lacking timestamp", () => {
const session = instance.addSession("foo");
assert.propertyVal(session.perf, "visibility_event_rcvd_ts", undefined);
});
});
describe("#browserOpenNewtabStart", () => {
it("should call perfService.mark with browser-open-newtab-start", () => {
@ -102,20 +123,25 @@ describe("TelemetryFeed", () => {
});
it("should add a session_duration", () => {
sandbox.stub(instance, "sendEvent");
const session = addSession("foo");
const session = instance.addSession("foo");
instance.endSession("foo");
assert.property(session, "session_duration");
});
it("should remove the session from .sessions", () => {
sandbox.stub(instance, "sendEvent");
addSession("foo");
instance.addSession("foo");
instance.endSession("foo");
assert.isFalse(instance.sessions.has("foo"));
});
it("should call createSessionSendEvent and sendEvent with the sesssion", () => {
sandbox.stub(instance, "sendEvent");
sandbox.stub(instance, "createSessionEndEvent");
const session = addSession("foo");
const session = instance.addSession("foo");
instance.endSession("foo");
// Did we call sendEvent with the result of createSessionEndEvent?
@ -124,7 +150,7 @@ describe("TelemetryFeed", () => {
});
});
describe("ping creators", () => {
beforeEach(async () => await instance.init());
beforeEach(() => instance.init());
describe("#createPing", () => {
it("should create a valid base ping without a session if no portID is supplied", async () => {
const ping = await instance.createPing();
@ -145,13 +171,21 @@ describe("TelemetryFeed", () => {
assert.propertyVal(ping, "session_id", sessionID);
assert.propertyVal(ping, "page", "about:newtab");
});
it("should create an unexpected base ping if no session yet portID is supplied", async () => {
const ping = await instance.createPing("foo");
assert.validate(ping, BasePing);
assert.propertyVal(ping, "page", "about:newtab");
assert.propertyVal(instance.sessions.get("foo").perf, "load_trigger_type", "unexpected");
});
});
describe("#createUserEvent", () => {
it("should create a valid event", async () => {
const portID = "foo";
const data = {source: "TOP_SITES", event: "CLICK"};
const action = ac.SendToMain(ac.UserEvent(data), portID);
const session = addSession(portID);
const session = instance.addSession(portID);
const ping = await instance.createUserEvent(action);
// Is it valid?
@ -163,6 +197,7 @@ describe("TelemetryFeed", () => {
describe("#createUndesiredEvent", () => {
it("should create a valid event without a session", async () => {
const action = ac.UndesiredEvent({source: "TOP_SITES", event: "MISSING_IMAGE", value: 10});
const ping = await instance.createUndesiredEvent(action);
// Is it valid?
@ -174,7 +209,8 @@ describe("TelemetryFeed", () => {
const portID = "foo";
const data = {source: "TOP_SITES", event: "MISSING_IMAGE", value: 10};
const action = ac.SendToMain(ac.UndesiredEvent(data), portID);
const session = addSession(portID);
const session = instance.addSession(portID);
const ping = await instance.createUndesiredEvent(action);
// Is it valid?
@ -199,7 +235,8 @@ describe("TelemetryFeed", () => {
const portID = "foo";
const data = {event: "PAGE_LOADED", value: 100};
const action = ac.SendToMain(ac.PerfEvent(data), portID);
const session = addSession(portID);
const session = instance.addSession(portID);
const ping = await instance.createPerformanceEvent(action);
// Is it valid?
@ -228,6 +265,21 @@ describe("TelemetryFeed", () => {
assert.propertyVal(ping, "page", "about:newtab");
assert.propertyVal(ping, "session_duration", 12345);
});
it("should create a valid unexpected session event", async () => {
const ping = await instance.createSessionEndEvent({
session_id: FAKE_UUID,
page: "about:newtab",
session_duration: 12345,
perf: {load_trigger_type: "unexpected"}
});
// Is it valid?
assert.validate(ping, SessionPing);
assert.propertyVal(ping, "session_id", FAKE_UUID);
assert.propertyVal(ping, "page", "about:newtab");
assert.propertyVal(ping, "session_duration", 12345);
assert.propertyVal(ping.perf, "load_trigger_type", "unexpected");
});
});
});
describe("#sendEvent", () => {

View File

@ -4,6 +4,7 @@ const {UPDATE_TIME, TOP_SITES_SHOWMORE_LENGTH} = require("lib/TopSitesFeed.jsm")
const {FakePrefs, GlobalOverrider} = require("test/unit/utils");
const action = {meta: {fromTarget: {}}};
const {actionTypes: at} = require("common/Actions.jsm");
const {insertPinned} = require("common/Reducers.jsm");
const FAKE_LINKS = new Array(TOP_SITES_SHOWMORE_LENGTH).fill(null).map((v, i) => ({url: `site${i}.com`}));
const FAKE_SCREENSHOT = "data123";
@ -24,13 +25,18 @@ describe("Top Sites Feed", () => {
activityStreamLinks: {getTopSites: sandbox.spy(() => Promise.resolve(links))},
pinnedLinks: {
links: [],
isPinned: () => false
isPinned: () => false,
pin: sandbox.spy(),
unpin: sandbox.spy()
}
};
globals.set("NewTabUtils", fakeNewTabUtils);
globals.set("PreviewProvider", {getThumbnail: sandbox.spy(() => Promise.resolve(FAKE_SCREENSHOT))});
FakePrefs.prototype.prefs["default.sites"] = "https://foo.com/";
({TopSitesFeed, DEFAULT_TOP_SITES} = injector({"lib/ActivityStreamPrefs.jsm": {Prefs: FakePrefs}}));
({TopSitesFeed, DEFAULT_TOP_SITES} = injector({
"lib/ActivityStreamPrefs.jsm": {Prefs: FakePrefs},
"common/Reducers.jsm": {insertPinned}
}));
feed = new TopSitesFeed();
feed.store = {dispatch: sinon.spy(), getState() { return {TopSites: {rows: Array(12).fill("site")}}; }};
links = FAKE_LINKS;
@ -56,54 +62,6 @@ describe("Top Sites Feed", () => {
assert.equal(DEFAULT_TOP_SITES.length, 0);
});
});
describe("#sortLinks", () => {
beforeEach(() => {
feed.init();
});
it("should place pinned links where they belong", () => {
const pinned = [
{"url": "http://github.com/mozilla/activity-stream", "title": "moz/a-s"},
{"url": "http://example.com", "title": "example"}
];
const result = feed.sortLinks(links, pinned);
for (let index of [0, 1]) {
assert.equal(result[index].url, pinned[index].url);
assert.ok(result[index].isPinned);
assert.equal(result[index].pinTitle, pinned[index].title);
assert.equal(result[index].pinIndex, index);
}
assert.deepEqual(result.slice(2), links.slice(0, -2));
});
it("should handle empty slots in the pinned list", () => {
const pinned = [
null,
{"url": "http://github.com/mozilla/activity-stream", "title": "moz/a-s"},
null,
null,
{"url": "http://example.com", "title": "example"}
];
const result = feed.sortLinks(links, pinned);
for (let index of [1, 4]) {
assert.equal(result[index].url, pinned[index].url);
assert.ok(result[index].isPinned);
assert.equal(result[index].pinTitle, pinned[index].title);
assert.equal(result[index].pinIndex, index);
}
result.splice(4, 1);
result.splice(1, 1);
assert.deepEqual(result, links.slice(0, -2));
});
it("should handle a pinned site past the end of the list of frecent+default", () => {
const pinned = [];
pinned[11] = {"url": "http://github.com/mozilla/activity-stream", "title": "moz/a-s"};
const result = feed.sortLinks([], pinned);
assert.equal(result[11].url, pinned[11].url);
assert.isTrue(result[11].isPinned);
assert.equal(result[11].pinTitle, pinned[11].title);
assert.equal(result[11].pinIndex, 11);
});
});
describe("#getLinksWithDefaults", () => {
beforeEach(() => {
feed.init();
@ -156,6 +114,13 @@ describe("Top Sites Feed", () => {
}
});
});
it("should handle empty slots in the resulting top sites array", async () => {
links = [FAKE_LINKS[0]];
fakeNewTabUtils.pinnedLinks.links = [null, null, FAKE_LINKS[1], null, null, null, null, null, FAKE_LINKS[2]];
sandbox.stub(feed, "getScreenshot");
await feed.refresh(action);
assert.calledOnce(feed.store.dispatch);
});
});
describe("getScreenshot", () => {
it("should call PreviewProvider.getThumbnail with the right url", async () => {
@ -165,36 +130,37 @@ describe("Top Sites Feed", () => {
});
});
describe("#onAction", () => {
const newTabAction = {type: at.NEW_TAB_LOAD, meta: {fromTarget: "target"}};
it("should call refresh if there are not enough sites on NEW_TAB_LOAD", () => {
feed.store.getState = function() { return {TopSites: {rows: []}}; };
sinon.stub(feed, "refresh");
feed.onAction({type: at.NEW_TAB_LOAD});
assert.calledOnce(feed.refresh);
feed.onAction(newTabAction);
assert.calledWith(feed.refresh, newTabAction.meta.fromTarget);
});
it("should call refresh if there are not sites on NEW_TAB_LOAD, not counting defaults", () => {
feed.store.getState = function() { return {TopSites: {rows: [{url: "foo.com"}, ...DEFAULT_TOP_SITES]}}; };
sinon.stub(feed, "refresh");
feed.onAction({type: at.NEW_TAB_LOAD});
assert.calledOnce(feed.refresh);
feed.onAction(newTabAction);
assert.calledWith(feed.refresh, newTabAction.meta.fromTarget);
});
it("should not call refresh if there are enough sites on NEW_TAB_LOAD", () => {
feed.lastUpdated = Date.now();
sinon.stub(feed, "refresh");
feed.onAction({type: at.NEW_TAB_LOAD});
feed.onAction(newTabAction);
assert.notCalled(feed.refresh);
});
it("should call refresh if .lastUpdated is too old on NEW_TAB_LOAD", () => {
feed.lastUpdated = 0;
clock.tick(UPDATE_TIME);
sinon.stub(feed, "refresh");
feed.onAction({type: at.NEW_TAB_LOAD});
assert.calledOnce(feed.refresh);
feed.onAction(newTabAction);
assert.calledWith(feed.refresh, newTabAction.meta.fromTarget);
});
it("should not call refresh if .lastUpdated is less than update time on NEW_TAB_LOAD", () => {
feed.lastUpdated = 0;
clock.tick(UPDATE_TIME - 1);
sinon.stub(feed, "refresh");
feed.onAction({type: at.NEW_TAB_LOAD});
feed.onAction(newTabAction);
assert.notCalled(feed.refresh);
});
it("should call openNewWindow with the correct url on OPEN_NEW_WINDOW", () => {
@ -219,5 +185,36 @@ describe("Top Sites Feed", () => {
feed.onAction(openWindowAction);
assert.calledOnce(openWindowAction._target.browser.ownerGlobal.openLinkIn);
});
it("should call with correct parameters on TOP_SITES_PIN", () => {
const pinAction = {
type: at.TOP_SITES_PIN,
data: {site: {url: "foo.com"}, index: 7}
};
feed.onAction(pinAction);
assert.calledOnce(fakeNewTabUtils.pinnedLinks.pin);
assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, pinAction.data.site, pinAction.data.index);
});
it("should call unpin with correct parameters on TOP_SITES_UNPIN", () => {
fakeNewTabUtils.pinnedLinks.links = [null, null, {url: "foo.com"}, null, null, null, null, null, FAKE_LINKS[0]];
const unpinAction = {
type: at.TOP_SITES_UNPIN,
data: {site: {url: "foo.com"}}
};
feed.onAction(unpinAction);
assert.calledOnce(fakeNewTabUtils.pinnedLinks.unpin);
assert.calledWith(fakeNewTabUtils.pinnedLinks.unpin, unpinAction.data.site);
});
it("should call refresh without a target if we clear history with PLACES_HISTORY_CLEARED", () => {
sandbox.stub(feed, "refresh");
feed.onAction({type: at.PLACES_HISTORY_CLEARED});
assert.calledOnce(feed.refresh);
assert.equal(feed.refresh.firstCall.args[0], null);
});
it("should still dispatch an action even if there's no target provided", async () => {
sandbox.stub(feed, "getScreenshot");
await feed.refresh();
assert.calledOnce(feed.store.dispatch);
assert.propertyVal(feed.store.dispatch.firstCall.args[0], "type", at.TOP_SITES_UPDATED);
});
});
});

View File

@ -292,6 +292,9 @@ user_pref("browser.translation.engine", "bing");
// Make sure we don't try to load snippets from the network.
user_pref("browser.aboutHomeSnippets.updateUrl", "nonexistent://test");
// Use an empty list of sites to avoid fetching
user_pref("browser.newtabpage.activity-stream.default.sites", "");
// Don't fetch directory tiles data from real servers
user_pref("browser.newtabpage.directory.source", 'data:application/json,{"testing":1}');

View File

@ -96,6 +96,7 @@ DEFAULTS = dict(
'dom.send_after_paint_to_content': True,
'security.turn_off_all_security_so_that_viruses_can_'
'take_over_this_computer': True,
'browser.newtabpage.activity-stream.default.sites': '',
'browser.newtabpage.directory.source':
'${webserver}/directoryLinks.json',
'browser.newtabpage.introShown': True,