From b6f0a8f2d8806aa5c8a67d13fb8b958fb173b543 Mon Sep 17 00:00:00 2001 From: Ed Lee Date: Thu, 5 Oct 2017 15:38:54 -0700 Subject: [PATCH] Bug 1405539 - Add responsive bookmarking, info doorhanger and bug fixes to Activity Stream. r=k88hudson MozReview-Commit-ID: C5LXhbuI0EA --HG-- extra : rebase_source : 580d19a032d6d033317c88470256d864ea8bc529 --- .../test/static/browser_parsable_css.js | 9 + .../activity-stream/common/Reducers.jsm | 37 +++- .../activity-stream-prerendered-debug.html | 2 +- .../data/content/activity-stream.bundle.js | 193 ++++++++++++++---- .../data/content/activity-stream.css | 47 +++-- .../activity-stream/data/locales.json | 145 ++++++++++--- .../extensions/activity-stream/install.rdf.in | 2 +- .../activity-stream/lib/HighlightsFeed.jsm | 45 ++-- .../activity-stream/lib/LinksCache.jsm | 59 +++--- .../activity-stream/lib/Screenshots.jsm | 35 ++-- .../activity-stream/lib/SectionsManager.jsm | 62 +++--- .../activity-stream/lib/TelemetryFeed.jsm | 4 +- .../activity-stream/lib/TopSitesFeed.jsm | 38 +--- .../activity-stream/lib/TopStoriesFeed.jsm | 11 +- .../test/unit/common/Reducers.test.js | 13 ++ .../test/unit/lib/HighlightsFeed.test.js | 48 ++++- .../test/unit/lib/SectionsManager.test.js | 57 ++++-- .../test/unit/lib/TopSitesFeed.test.js | 57 ++++-- .../test/unit/lib/TopStoriesFeed.test.js | 15 +- 19 files changed, 613 insertions(+), 266 deletions(-) diff --git a/browser/base/content/test/static/browser_parsable_css.js b/browser/base/content/test/static/browser_parsable_css.js index 475153a909a2..e9a97752f7b8 100644 --- a/browser/base/content/test/static/browser_parsable_css.js +++ b/browser/base/content/test/static/browser_parsable_css.js @@ -87,6 +87,15 @@ let allowedImageReferences = [ {file: "chrome://devtools/skin/images/dock-bottom-maximize@2x.png", from: "chrome://devtools/skin/toolbox.css", isFromDevTools: true}, + // Bug 1405539 + {file: "chrome://global/skin/arrow/panelarrow-vertical@2x.png", + from: "resource://activity-stream/data/content/activity-stream.css", + isFromDevTools: false, + platforms: ["linux", "win"]}, + {file: "chrome://global/skin/arrow/panelarrow-vertical-themed.svg", + from: "resource://activity-stream/data/content/activity-stream.css", + isFromDevTools: false, + platforms: ["macosx"]}, ]; // Add suffix to stylesheets' URI so that we always load them here and diff --git a/browser/extensions/activity-stream/common/Reducers.jsm b/browser/extensions/activity-stream/common/Reducers.jsm index f6c3f6d12466..8b869803d48c 100644 --- a/browser/extensions/activity-stream/common/Reducers.jsm +++ b/browser/extensions/activity-stream/common/Reducers.jsm @@ -4,6 +4,7 @@ "use strict"; const {actionTypes: at} = Components.utils.import("resource://activity-stream/common/Actions.jsm", {}); +const {Dedupe} = Components.utils.import("resource://activity-stream/common/Dedupe.jsm", {}); // Locales that should be displayed RTL const RTL_LIST = ["ar", "he", "fa", "ur"]; @@ -11,6 +12,8 @@ const RTL_LIST = ["ar", "he", "fa", "ur"]; const TOP_SITES_DEFAULT_LENGTH = 6; const TOP_SITES_SHOWMORE_LENGTH = 12; +const dedupe = new Dedupe(site => site && site.url); + const INITIAL_STATE = { App: { // Have we received real data from the app yet? @@ -230,7 +233,7 @@ function Sections(prevState = INITIAL_STATE.Sections, action) { } return newState; case at.SECTION_UPDATE: - return prevState.map(section => { + newState = prevState.map(section => { if (section && section.id === action.data.id) { // If the action is updating rows, we should consider initialized to be true. // This can be overridden if initialized is defined in the action.data @@ -239,6 +242,28 @@ function Sections(prevState = INITIAL_STATE.Sections, action) { } return section; }); + + if (!action.data.dedupeConfigurations) { + return newState; + } + + action.data.dedupeConfigurations.forEach(dedupeConf => { + newState = newState.map(section => { + if (section.id === dedupeConf.id) { + const dedupedRows = dedupeConf.dedupeFrom.reduce((rows, dedupeSectionId) => { + const dedupeSection = newState.find(s => s.id === dedupeSectionId); + const [, newRows] = dedupe.group(dedupeSection.rows, rows); + return newRows; + }, section.rows); + + return Object.assign({}, section, {rows: dedupedRows}); + } + + return section; + }); + }); + + return newState; case at.SECTION_UPDATE_CARD: return prevState.map(section => { if (section && section.id === action.data.id && section.rows) { @@ -261,10 +286,12 @@ function Sections(prevState = INITIAL_STATE.Sections, action) { // find the item within the rows that is attempted to be bookmarked if (item.url === action.data.url) { const {bookmarkGuid, bookmarkTitle, dateAdded} = action.data; - Object.assign(item, {bookmarkGuid, bookmarkTitle, bookmarkDateCreated: dateAdded}); - if (!item.type || item.type === "history") { - item.type = "bookmark"; - } + return Object.assign({}, item, { + bookmarkGuid, + bookmarkTitle, + bookmarkDateCreated: dateAdded, + type: "bookmark" + }); } return item; }) diff --git a/browser/extensions/activity-stream/data/content/activity-stream-prerendered-debug.html b/browser/extensions/activity-stream/data/content/activity-stream-prerendered-debug.html index b607f3c22936..43162246953f 100644 --- a/browser/extensions/activity-stream/data/content/activity-stream-prerendered-debug.html +++ b/browser/extensions/activity-stream/data/content/activity-stream-prerendered-debug.html @@ -9,7 +9,7 @@ -

    +

      diff --git a/browser/extensions/activity-stream/data/content/activity-stream.bundle.js b/browser/extensions/activity-stream/data/content/activity-stream.bundle.js index 75a8d469ebe9..a6a1df944aa1 100644 --- a/browser/extensions/activity-stream/data/content/activity-stream.bundle.js +++ b/browser/extensions/activity-stream/data/content/activity-stream.bundle.js @@ -351,6 +351,7 @@ module.exports = { const { actionTypes: at } = __webpack_require__(0); +const { Dedupe } = __webpack_require__(20); // Locales that should be displayed RTL const RTL_LIST = ["ar", "he", "fa", "ur"]; @@ -358,6 +359,8 @@ const RTL_LIST = ["ar", "he", "fa", "ur"]; const TOP_SITES_DEFAULT_LENGTH = 6; const TOP_SITES_SHOWMORE_LENGTH = 12; +const dedupe = new Dedupe(site => site && site.url); + const INITIAL_STATE = { App: { // Have we received real data from the app yet? @@ -580,7 +583,7 @@ function Sections(prevState = INITIAL_STATE.Sections, action) { } return newState; case at.SECTION_UPDATE: - return prevState.map(section => { + newState = prevState.map(section => { if (section && section.id === action.data.id) { // If the action is updating rows, we should consider initialized to be true. // This can be overridden if initialized is defined in the action.data @@ -589,6 +592,28 @@ function Sections(prevState = INITIAL_STATE.Sections, action) { } return section; }); + + if (!action.data.dedupeConfigurations) { + return newState; + } + + action.data.dedupeConfigurations.forEach(dedupeConf => { + newState = newState.map(section => { + if (section.id === dedupeConf.id) { + const dedupedRows = dedupeConf.dedupeFrom.reduce((rows, dedupeSectionId) => { + const dedupeSection = newState.find(s => s.id === dedupeSectionId); + const [, newRows] = dedupe.group(dedupeSection.rows, rows); + return newRows; + }, section.rows); + + return Object.assign({}, section, { rows: dedupedRows }); + } + + return section; + }); + }); + + return newState; case at.SECTION_UPDATE_CARD: return prevState.map(section => { if (section && section.id === action.data.id && section.rows) { @@ -611,10 +636,12 @@ function Sections(prevState = INITIAL_STATE.Sections, action) { // find the item within the rows that is attempted to be bookmarked if (item.url === action.data.url) { const { bookmarkGuid, bookmarkTitle, dateAdded } = action.data; - Object.assign(item, { bookmarkGuid, bookmarkTitle, bookmarkDateCreated: dateAdded }); - if (!item.type || item.type === "history") { - item.type = "bookmark"; - } + return Object.assign({}, item, { + bookmarkGuid, + bookmarkTitle, + bookmarkDateCreated: dateAdded, + type: "bookmark" + }); } return item; }) @@ -1099,9 +1126,9 @@ module.exports._unconnected = LinkMenu; const React = __webpack_require__(1); const { connect } = __webpack_require__(3); const { injectIntl, FormattedMessage } = __webpack_require__(2); -const Card = __webpack_require__(20); +const Card = __webpack_require__(21); const { PlaceholderCard } = Card; -const Topics = __webpack_require__(22); +const Topics = __webpack_require__(23); const { actionCreators: ac, actionTypes: at } = __webpack_require__(0); const VISIBLE = "visible"; @@ -1387,10 +1414,10 @@ module.exports.InfoIntl = InfoIntl; const ReactDOM = __webpack_require__(12); const Base = __webpack_require__(13); const { Provider } = __webpack_require__(3); -const initStore = __webpack_require__(29); +const initStore = __webpack_require__(30); const { reducers } = __webpack_require__(6); -const DetectUserSessionStart = __webpack_require__(31); -const { addSnippetsSubscriber } = __webpack_require__(32); +const DetectUserSessionStart = __webpack_require__(32); +const { addSnippetsSubscriber } = __webpack_require__(33); const { actionTypes: at, actionCreators: ac } = __webpack_require__(0); new DetectUserSessionStart().sendEventOrAddListener(); @@ -1427,13 +1454,13 @@ const React = __webpack_require__(1); const { connect } = __webpack_require__(3); const { addLocaleData, IntlProvider } = __webpack_require__(2); const TopSites = __webpack_require__(14); -const Search = __webpack_require__(23); -const ConfirmDialog = __webpack_require__(25); -const ManualMigration = __webpack_require__(26); -const PreferencesPane = __webpack_require__(27); +const Search = __webpack_require__(24); +const ConfirmDialog = __webpack_require__(26); +const ManualMigration = __webpack_require__(27); +const PreferencesPane = __webpack_require__(28); const Sections = __webpack_require__(10); const { actionTypes: at, actionCreators: ac } = __webpack_require__(0); -const { PrerenderData } = __webpack_require__(28); +const { PrerenderData } = __webpack_require__(29); // 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 @@ -2291,12 +2318,57 @@ module.exports.CheckPinTopSite = (site, index) => site.isPinned ? module.exports /***/ }), /* 20 */ +/***/ (function(module, exports) { + +var Dedupe = class Dedupe { + constructor(createKey, compare) { + this.createKey = createKey || this.defaultCreateKey; + this.compare = compare || this.defaultCompare; + } + + defaultCreateKey(item) { + return item; + } + + defaultCompare() { + return false; + } + + /** + * Dedupe any number of grouped elements favoring those from earlier groups. + * + * @param {Array} groups Contains an arbitrary number of arrays of elements. + * @returns {Array} A matching array of each provided group deduped. + */ + group(...groups) { + const globalKeys = new Set(); + const result = []; + for (const values of groups) { + const valueMap = new Map(); + for (const value of values) { + const key = this.createKey(value); + if (!globalKeys.has(key) && (!valueMap.has(key) || this.compare(valueMap.get(key), value))) { + valueMap.set(key, value); + } + } + result.push(valueMap); + valueMap.forEach((value, key) => globalKeys.add(key)); + } + return result.map(m => Array.from(m.values())); + } +}; +module.exports = { + Dedupe +}; + +/***/ }), +/* 21 */ /***/ (function(module, exports, __webpack_require__) { const React = __webpack_require__(1); const LinkMenu = __webpack_require__(9); const { FormattedMessage } = __webpack_require__(2); -const cardContextTypes = __webpack_require__(21); +const cardContextTypes = __webpack_require__(22); const { actionCreators: ac, actionTypes: at } = __webpack_require__(0); // Keep track of pending image loads to only request once @@ -2492,7 +2564,7 @@ module.exports = Card; module.exports.PlaceholderCard = PlaceholderCard; /***/ }), -/* 21 */ +/* 22 */ /***/ (function(module, exports) { module.exports = { @@ -2515,7 +2587,7 @@ module.exports = { }; /***/ }), -/* 22 */ +/* 23 */ /***/ (function(module, exports, __webpack_require__) { const React = __webpack_require__(1); @@ -2566,7 +2638,7 @@ module.exports._unconnected = Topics; module.exports.Topic = Topic; /***/ }), -/* 23 */ +/* 24 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -2577,7 +2649,7 @@ const React = __webpack_require__(1); const { connect } = __webpack_require__(3); const { FormattedMessage, injectIntl } = __webpack_require__(2); const { actionCreators: ac } = __webpack_require__(0); -const { IS_NEWTAB } = __webpack_require__(24); +const { IS_NEWTAB } = __webpack_require__(25); class Search extends React.PureComponent { constructor(props) { @@ -2677,7 +2749,7 @@ module.exports = connect(state => ({ locale: state.App.locale }))(injectIntl(Sea module.exports._unconnected = Search; /***/ }), -/* 24 */ +/* 25 */ /***/ (function(module, exports, __webpack_require__) { /* WEBPACK VAR INJECTION */(function(global) {module.exports = { @@ -2687,7 +2759,7 @@ module.exports._unconnected = Search; /* WEBPACK VAR INJECTION */}.call(exports, __webpack_require__(4))) /***/ }), -/* 25 */ +/* 26 */ /***/ (function(module, exports, __webpack_require__) { const React = __webpack_require__(1); @@ -2792,7 +2864,7 @@ module.exports._unconnected = ConfirmDialog; module.exports.Dialog = ConfirmDialog; /***/ }), -/* 26 */ +/* 27 */ /***/ (function(module, exports, __webpack_require__) { const React = __webpack_require__(1); @@ -2856,7 +2928,7 @@ module.exports = connect()(ManualMigration); module.exports._unconnected = ManualMigration; /***/ }), -/* 27 */ +/* 28 */ /***/ (function(module, exports, __webpack_require__) { const React = __webpack_require__(1); @@ -2884,7 +2956,12 @@ const PreferencesInput = props => React.createElement( "p", { className: "prefs-input-description" }, getFormattedMessage(props.descString) - ) + ), + React.Children.map(props.children, child => React.createElement( + "div", + { className: `options${child.props.disabled ? " disabled" : ""}` }, + child + )) ); class PreferencesPane extends React.PureComponent { @@ -2977,21 +3054,51 @@ class PreferencesPane extends React.PureComponent { null, React.createElement(FormattedMessage, { id: "settings_pane_body2" }) ), - React.createElement(PreferencesInput, { className: "showSearch", prefName: "showSearch", value: prefs.showSearch, onChange: this.handlePrefChange, - titleString: { id: "settings_pane_search_header" }, descString: { id: "settings_pane_search_body" } }), + React.createElement(PreferencesInput, { + className: "showSearch", + prefName: "showSearch", + value: prefs.showSearch, + onChange: this.handlePrefChange, + titleString: { id: "settings_pane_search_header" }, + descString: { id: "settings_pane_search_body" } }), React.createElement("hr", null), - React.createElement(PreferencesInput, { className: "showTopSites", prefName: "showTopSites", value: prefs.showTopSites, onChange: this.handlePrefChange, - titleString: { id: "settings_pane_topsites_header" }, descString: { id: "settings_pane_topsites_body" } }), React.createElement( - "div", - { className: `options${prefs.showTopSites ? "" : " disabled"}` }, - React.createElement(PreferencesInput, { className: "showMoreTopSites", prefName: "topSitesCount", disabled: !prefs.showTopSites, - value: prefs.topSitesCount !== TOP_SITES_DEFAULT_LENGTH, onChange: this.handlePrefChange, - titleString: { id: "settings_pane_topsites_options_showmore" }, labelClassName: "icon icon-topsites" }) + PreferencesInput, + { + className: "showTopSites", + prefName: "showTopSites", + value: prefs.showTopSites, + onChange: this.handlePrefChange, + titleString: { id: "settings_pane_topsites_header" }, + descString: { id: "settings_pane_topsites_body" } }, + React.createElement(PreferencesInput, { + className: "showMoreTopSites", + prefName: "topSitesCount", + disabled: !prefs.showTopSites, + value: prefs.topSitesCount !== TOP_SITES_DEFAULT_LENGTH, + onChange: this.handlePrefChange, + titleString: { id: "settings_pane_topsites_options_showmore" }, + labelClassName: "icon icon-topsites" }) ), - sections.filter(section => !section.shouldHidePref).map(({ id, title, enabled, pref }) => React.createElement(PreferencesInput, { key: id, className: "showSection", prefName: pref && pref.feed || id, - value: enabled, onChange: pref && pref.feed ? this.handlePrefChange : this.handleSectionChange, - titleString: pref && pref.titleString || title, descString: pref && pref.descString })), + sections.filter(section => !section.shouldHidePref).map(({ id, title, enabled, pref }) => React.createElement( + PreferencesInput, + { + key: id, + className: "showSection", + prefName: pref && pref.feed || id, + value: enabled, + onChange: pref && pref.feed ? this.handlePrefChange : this.handleSectionChange, + titleString: pref && pref.titleString || title, + descString: pref && pref.descString }, + pref.nestedPrefs && pref.nestedPrefs.map(nestedPref => React.createElement(PreferencesInput, { + key: nestedPref.name, + prefName: nestedPref.name, + disabled: !enabled, + value: prefs[nestedPref.name], + onChange: this.handlePrefChange, + titleString: nestedPref.titleString, + labelClassName: `icon ${nestedPref.icon}` })) + )), React.createElement("hr", null), React.createElement(PreferencesInput, { className: "showSnippets", prefName: "feeds.snippets", value: prefs["feeds.snippets"], onChange: this.handlePrefChange, @@ -3018,7 +3125,7 @@ module.exports.PreferencesPane = PreferencesPane; module.exports.PreferencesInput = PreferencesInput; /***/ }), -/* 28 */ +/* 29 */ /***/ (function(module, exports) { class _PrerenderData { @@ -3108,12 +3215,12 @@ module.exports = { }; /***/ }), -/* 29 */ +/* 30 */ /***/ (function(module, exports, __webpack_require__) { /* WEBPACK VAR INJECTION */(function(global) {/* eslint-env mozilla/frame-script */ -const { createStore, combineReducers, applyMiddleware } = __webpack_require__(30); +const { createStore, combineReducers, applyMiddleware } = __webpack_require__(31); const { actionTypes: at, actionCreators: ac, actionUtils: au } = __webpack_require__(0); const MERGE_STORE_ACTION = "NEW_TAB_INITIAL_STATE"; @@ -3223,13 +3330,13 @@ module.exports.INCOMING_MESSAGE_NAME = INCOMING_MESSAGE_NAME; /* WEBPACK VAR INJECTION */}.call(exports, __webpack_require__(4))) /***/ }), -/* 30 */ +/* 31 */ /***/ (function(module, exports) { module.exports = Redux; /***/ }), -/* 31 */ +/* 32 */ /***/ (function(module, exports, __webpack_require__) { /* WEBPACK VAR INJECTION */(function(global) {const { actionTypes: at } = __webpack_require__(0); @@ -3299,7 +3406,7 @@ module.exports = class DetectUserSessionStart { /* WEBPACK VAR INJECTION */}.call(exports, __webpack_require__(4))) /***/ }), -/* 32 */ +/* 33 */ /***/ (function(module, exports, __webpack_require__) { /* WEBPACK VAR INJECTION */(function(global) {const DATABASE_NAME = "snippets_db"; diff --git a/browser/extensions/activity-stream/data/content/activity-stream.css b/browser/extensions/activity-stream/data/content/activity-stream.css index e34bc234e53b..8cf945e72560 100644 --- a/browser/extensions/activity-stream/data/content/activity-stream.css +++ b/browser/extensions/activity-stream/data/content/activity-stream.css @@ -197,7 +197,7 @@ main { .section-top-bar { height: 16px; - margin-bottom: 12px; } + margin-bottom: 16px; } .section-title { font-size: 13px; @@ -421,11 +421,17 @@ main { opacity: 1; } .edit-topsites-wrapper .edit-topsites-button { - position: absolute; - offset-inline-end: 21px; - top: -2px; + border-right: 1px solid #D7D7DB; + line-height: 13px; + offset-inline-end: 24px; opacity: 0; + padding: 0 10px; + position: absolute; + top: 2px; transition: opacity 0.2s cubic-bezier(0.07, 0.95, 0, 1); } + .edit-topsites-wrapper .edit-topsites-button:dir(rtl) { + border-left: 1px solid #D7D7DB; + border-right: 0; } .edit-topsites-wrapper .edit-topsites-button:focus, .edit-topsites-wrapper .edit-topsites-button:active { opacity: 1; } .edit-topsites-wrapper .edit-topsites-button button { @@ -550,23 +556,38 @@ section.top-sites:hover .edit-topsites-button { .section-top-bar .info-option-icon:focus, .section-top-bar .info-option-icon:active { opacity: 1; } .section-top-bar .info-option-icon[aria-expanded="true"] { + background-color: rgba(12, 12, 13, 0.1); + border-radius: 1px; + box-shadow: 0 0 0 5px rgba(12, 12, 13, 0.1); fill: rgba(12, 12, 13, 0.8); } .section-top-bar .section-info-option .info-option { visibility: hidden; opacity: 0; transition: visibility 0.2s, opacity 0.2s cubic-bezier(0.07, 0.95, 0, 1); } - .section-top-bar .section-info-option .info-option::before { + .section-top-bar .section-info-option .info-option::after, .section-top-bar .section-info-option .info-option::before { content: ""; - display: block; + offset-inline-end: 0; + position: absolute; } + .section-top-bar .section-info-option .info-option::before { + background-image: url(chrome://global/skin/arrow/panelarrow-vertical-themed.svg), url(chrome://global/skin/arrow/panelarrow-vertical@2x.png); + background-position: calc(100% - 7px) bottom; + background-repeat: no-repeat; + background-size: 18px 10px; height: 32px; - left: 50%; - position: absolute; - right: 0; - top: -32px; } + top: -32px; + width: 43px; } + .section-top-bar .section-info-option .info-option:dir(rtl)::before { + background-position-x: 7px; } + .section-top-bar .section-info-option .info-option::after { + height: 10px; + offset-inline-start: 0; + top: -10px; } .section-top-bar .info-option-icon[aria-expanded="true"] + .info-option { visibility: visible; opacity: 1; transition: visibility 0.2s, opacity 0.2s cubic-bezier(0.07, 0.95, 0, 1); } + .section-top-bar .info-option-icon:not([aria-expanded="true"]) + .info-option { + pointer-events: none; } .section-top-bar .info-option { z-index: 9999; position: absolute; @@ -577,7 +598,7 @@ section.top-sites:hover .edit-topsites-button { line-height: 120%; margin-inline-end: -9px; offset-inline-end: 0; - top: 20px; + top: 26px; width: 320px; padding: 24px; box-shadow: 0 1px 4px 0 rgba(12, 12, 13, 0.1); @@ -758,7 +779,7 @@ section.section:hover .info-option-icon { background-color: rgba(12, 12, 13, 0.1); cursor: pointer; } .search-wrapper .search-button:active { - background-color: rgba(12, 12, 13, 0.15); } + background-color: rgba(12, 12, 13, 0.2); } .search-wrapper .search-button:dir(rtl) { transform: scaleX(-1); } .search-wrapper .contentSearchSuggestionTable { @@ -840,7 +861,7 @@ section.section:hover .info-option-icon { .prefs-pane .prefs-modal-inner-wrapper section { margin: 20px 0; } .prefs-pane .prefs-modal-inner-wrapper section p { - margin: 5px 0 5px 30px; } + margin: 5px 0 20px 30px; } .prefs-pane .prefs-modal-inner-wrapper section label { display: inline-block; position: relative; diff --git a/browser/extensions/activity-stream/data/locales.json b/browser/extensions/activity-stream/data/locales.json index 7a151253ba58..c2d4a37cfe1c 100644 --- a/browser/extensions/activity-stream/data/locales.json +++ b/browser/extensions/activity-stream/data/locales.json @@ -4,6 +4,7 @@ "default_label_loading": "Tye ka cano…", "header_top_sites": "Kakube maloyo", "header_stories": "Lok madito", + "header_highlights": "Wiye madito", "header_visit_again": "Lim doki", "header_bookmarks": "Alamabuk ma cok coki", "header_recommended_by": "Lami tam obedo {provider}", @@ -35,6 +36,8 @@ "search_web_placeholder": "Yeny kakube", "search_settings": "Lok ter me yeny", "section_info_option": "Ngec", + "section_info_send_feedback": "Cwal adwogi", + "section_info_privacy_notice": "Ngec me mung", "welcome_title": "Wajoli i dirica matidi manyen", "welcome_body": "Firefox bi tic ki kabedo man me nyuto alamabukke mamegi, coc akwana, vidio, ki potbukke ma ilimo cokcoki ma pi gi tego loyo, wek i dok ii gi ma yot.", "welcome_label": "Tye ka kube ki wiye madito mamegi", @@ -44,6 +47,7 @@ "time_label_day": "nino{number}", "settings_pane_button_label": "Yub potbuk me dirica matidi mamegi manyen", "settings_pane_header": "Ter me dirica matidi manyen", + "settings_pane_body2": "Yer ngo ma i neno i potbuk man.", "settings_pane_search_header": "Yeny", "settings_pane_search_body": "Yeny Kakube ki i dirica ni matidi manyen.", "settings_pane_topsites_header": "Kakube ma gi loyo", @@ -53,6 +57,12 @@ "settings_pane_bookmarks_body": "Alamabukke ni ma kicweyo manyen i kabedo acel macek.", "settings_pane_visit_again_header": "Lim Kidoco", "settings_pane_visit_again_body": "Firefox bi nyuti but gin mukato me yeny mamegi ma itwero mito me poo ikome onyo dok cen iyie.", + "settings_pane_highlights_header": "Wiye madito", + "settings_pane_highlights_body2": "Nong yoo ni cen i jami mamit ma ilimo gi cokcokki onyo iketo alamabuk.", + "settings_pane_highlights_options_bookmarks": "Alamabuk", + "settings_pane_highlights_options_visited": "Kakube ma kilimo", + "settings_pane_snippets_header": "Kwena macek", + "settings_pane_snippets_body": "Kwan ngec manyen macego dok mamit ki bot Mozilla ikom Firefox, kwo me intanet, ki meme mabino atata.", "settings_pane_done_button": "Otum", "edit_topsites_button_text": "Yubi", "edit_topsites_button_label": "Yub bute pi kakubi ni ma giloyo", @@ -75,8 +85,10 @@ "pocket_read_more": "Lok macuk gi lamal:", "pocket_read_even_more": "Nen Lok mapol", "pocket_feedback_header": "Kakube maber loyo, dano makato milion 25 aye oyubo.", + "pocket_description": "Nong jami me rwom ma lamal ma itwero keng woko, ki kony ma aa ki bot Pocket, dong tye but Mozilla.", "highlights_empty_state": "Cak yeny, ka wa binyuto coc akwana mabeco, video, ki potbuk mukene ma ilimo cokcokki onyo ma kiketo alamabuk kany.", "topstories_empty_state": "Ityeko weng. Rot doki lacen pi lok madito mapol ki bot {provider}. Pe itwero kuro? Yer lok macuke lamal me nongo lok mabeco mapol ki i but kakube.", + "manual_migration_explanation2": "Tem Firefox ki alamabuk, gin mukato ki mung me donyo ki ii layeny mukene.", "manual_migration_cancel_button": "Pe Apwoyo", "manual_migration_import_button": "Kel kombedi" }, @@ -163,10 +175,54 @@ "newtab_page_title": "Llingüeta nueva", "default_label_loading": "Cargando…", "header_top_sites": "Sitios destacaos", + "header_stories": "Histories destacaes", + "header_highlights": "Los destacaos", + "header_visit_again": "Visitar de nueves", + "header_bookmarks": "Marcadores recientes", + "header_bookmarks_placeholder": "Entá nun tienes dengún marcador.", + "header_stories_from": "de", + "type_label_visited": "Visitóse", + "type_label_bookmarked": "Amestóse a marcadores", + "type_label_synced": "Sincronizóse dende otru preséu", + "type_label_recommended": "Tendencia", "type_label_open": "Abrir", "type_label_topic": "Tema", - "welcome_title": "Bienllegáu/ada a la llingüeta nueva", - "welcome_body": "Firefox usará esti espaciu p'amosate los marcadores, artículos, vídeos y páxines más relevantes de los que visitares apocayá, asina pues volver a ello de mou cenciellu." + "type_label_now": "Agora", + "menu_action_bookmark": "Amestar a marcadores", + "menu_action_remove_bookmark": "Desaniciar marcador", + "menu_action_copy_address": "Copiar direición", + "menu_action_email_link": "Unviar enllaz per corréu…", + "menu_action_open_new_window": "Abrir nuna ventana nueva", + "menu_action_open_private_window": "Abrir nuna ventana privada nueva", + "menu_action_dismiss": "Escartar", + "menu_action_delete": "Desaniciar del historial", + "menu_action_pin": "Fixar", + "menu_action_unpin": "Desfixar", + "confirm_history_delete_p1": "¿De xuru que quies desaniciar cada instancia d'esta páxina del to historial?", + "confirm_history_delete_notice_p2": "Esta aición nun pue desfacese.", + "menu_action_save_to_pocket": "Guardar en Pocket", + "search_for_something_with": "Guetar {search_term} con:", + "search_button": "Guetar", + "search_header": "Gueta en {search_engine_name}", + "search_web_placeholder": "Guetar na web", + "search_settings": "Camudar axustes de gueta", + "section_info_option": "Información", + "section_info_send_feedback": "Unviar comentarios", + "section_info_privacy_notice": "Nota de privacidá", + "welcome_title": "Afáyate na llingüeta nueva", + "welcome_body": "Firefox usará esti espaciu p'amosate los marcadores, artículos, vídeos y páxines más relevantes que visitares apocayá, asina pues volver a ellos de mou cenciellu.", + "time_label_less_than_minute": "<1m", + "time_label_minute": "{number}m", + "time_label_hour": "{number}h", + "time_label_day": "{number}d", + "settings_pane_done_button": "Fecho", + "edit_topsites_showmore_button": "Amosar más", + "edit_topsites_done_button": "Fecho", + "topsites_form_add_button": "Amestar", + "topsites_form_save_button": "Guardar", + "topsites_form_cancel_button": "Encaboxar", + "pocket_read_more": "Temes populares:", + "pocket_read_even_more": "Ver más histories" }, "az": { "newtab_page_title": "Yeni Vərəq", @@ -999,7 +1055,7 @@ "default_label_loading": "Indlæser…", "header_top_sites": "Mest besøgte websider", "header_stories": "Tophistorier", - "header_highlights": "Højdepunkter", + "header_highlights": "Fremhævede", "header_visit_again": "Besøg igen", "header_bookmarks": "Seneste bogmærker", "header_recommended_by": "Anbefalet af {provider}", @@ -1032,15 +1088,17 @@ "search_settings": "Skift søgeindstillinger", "section_info_option": "Info", "section_info_send_feedback": "Send feedback", + "section_info_privacy_notice": "Privatlivspolitik", "welcome_title": "Velkommen til nyt faneblad", "welcome_body": "Firefox vil bruge denne plads til at vise dine mest relevante bogmærker, artikler, videoer og sider, du har besøgt for nylig - så kan du nemmere finde dem.", - "welcome_label": "Finder dine højdepunkter", + "welcome_label": "Finder dine vigtigste sider", "time_label_less_than_minute": "<1 m.", "time_label_minute": "{number} m.", "time_label_hour": "{number} t.", "time_label_day": "{number} d.", "settings_pane_button_label": "Tilpas siden Nyt faneblad", "settings_pane_header": "Indstillinger for Nyt faneblad", + "settings_pane_body2": "Vælg, hvad du vil se på denne side.", "settings_pane_search_header": "Søgning", "settings_pane_search_body": "Søg på nettet fra Nyt faneblad.", "settings_pane_topsites_header": "Mest besøgte websider", @@ -1050,6 +1108,12 @@ "settings_pane_bookmarks_body": "Dine seneste bogmærker samlet ét sted.", "settings_pane_visit_again_header": "Besøg igen", "settings_pane_visit_again_body": "Firefox viser dig dele af din browserhistorik, som du måske vil huske på eller vende tilbage til.", + "settings_pane_highlights_header": "Fremhævede", + "settings_pane_highlights_body2": "Find tilbage til interessant indhold, du har besøgt eller gemt et bogmærke til for nylig.", + "settings_pane_highlights_options_bookmarks": "Bogmærker", + "settings_pane_highlights_options_visited": "Besøgte websider", + "settings_pane_snippets_header": "Notitser", + "settings_pane_snippets_body": "Læs korte opdateringer fra Mozilla om Firefox, internet-kultur og lidt underholdning fra tid til anden.", "settings_pane_done_button": "Færdig", "edit_topsites_button_text": "Rediger", "edit_topsites_button_label": "Tilpas afsnittet Mest besøgte websider", @@ -1072,7 +1136,10 @@ "pocket_read_more": "Populære emner:", "pocket_read_even_more": "Se flere historier", "pocket_feedback_header": "Det bedste fra nettet, udvalgt af mere end 25 millioner mennesker.", + "pocket_description": "Opdag indhold af høj kvalitet, som du måske ellers ikke ville have opdaget. Indholdet kommer fra Pocket, der nu er en del af Mozilla.", + "highlights_empty_state": "Gå i gang med at browse, så vil vi vise dig nogle af de artikler, videoer og andre sider, du har besøgt eller gemt et bogmærke til for nylig.", "topstories_empty_state": "Der er ikke flere nye historier. Kom tilbage senere for at se flere tophistorier fra {provider}. Kan du ikke vente? Vælg et populært emne og find flere spændende historier fra hele verden.", + "manual_migration_explanation2": "Prøv Firefox med bogmærkerne, historikken og adgangskoderne fra en anden browser.", "manual_migration_cancel_button": "Nej tak", "manual_migration_import_button": "Importer nu" }, @@ -2404,7 +2471,7 @@ "time_label_day": "{number} j", "settings_pane_button_label": "Personnaliser la page Nouvel onglet", "settings_pane_header": "Préférences Nouvel onglet", - "settings_pane_body2": "Choisissez les éléments à afficher sur cette page.", + "settings_pane_body2": "Choisissez les éléments à afficher sur la page.", "settings_pane_search_header": "Recherche", "settings_pane_search_body": "Effectuez une recherche sur le Web depuis le nouvel onglet.", "settings_pane_topsites_header": "Sites les plus visités", @@ -3285,19 +3352,19 @@ "header_stories": "Historias popular", "header_highlights": "In evidentia", "header_visit_again": "Visita de novo", - "header_bookmarks": "Paginas marcate recentemente", + "header_bookmarks": "Marcapaginas recente", "header_recommended_by": "Recommendate per {provider}", - "header_bookmarks_placeholder": "Tu ha ancora nulle paginas marcate.", + "header_bookmarks_placeholder": "Tu ha ancora nulle marcapaginas.", "header_stories_from": "de", "type_label_visited": "Visitate", - "type_label_bookmarked": "Marcate", + "type_label_bookmarked": "Marcapaginas addite", "type_label_synced": "Synchronisate de altere apparato", "type_label_recommended": "Tendentias", "type_label_open": "Aperite", "type_label_topic": "Subjecto", "type_label_now": "Ora", - "menu_action_bookmark": "Marcar le pagina", - "menu_action_remove_bookmark": "Dismarcar le pagina", + "menu_action_bookmark": "Adder marcapaginas", + "menu_action_remove_bookmark": "Remover le marcapaginas", "menu_action_copy_address": "Copiar le adresse", "menu_action_email_link": "Inviar le ligamine per email…", "menu_action_open_new_window": "Aperir in un nove fenestra", @@ -3318,7 +3385,7 @@ "section_info_send_feedback": "Inviar feedback", "section_info_privacy_notice": "Advertentia de privacitate", "welcome_title": "Benvenite al nove scheda", - "welcome_body": "Firefox usara iste spatio pro monstrar tu paginas marcate le plus relevante, articulos, videos e paginas que tu ha visitate recentemente, de sorta que tu pote revider los facilemente.", + "welcome_body": "Firefox usara iste spatio pro monstrar tu marcapaginas le plus relevante, articulos, videos e paginas que tu ha visitate recentemente, de sorta que tu pote revider los facilemente.", "welcome_label": "Identificante tu evidentias", "time_label_less_than_minute": "<1 min", "time_label_minute": "{number} min", @@ -3332,13 +3399,13 @@ "settings_pane_topsites_header": "Sitos popular", "settings_pane_topsites_body": "Acceder al sitos web que tu plus visita.", "settings_pane_topsites_options_showmore": "Monstrar duo lineas", - "settings_pane_bookmarks_header": "Paginas marcate recentemente", - "settings_pane_bookmarks_body": "Tu paginas marcate recentemente a un sol loco.", + "settings_pane_bookmarks_header": "Marcapaginas recente", + "settings_pane_bookmarks_body": "Tu marcapaginas le plus recente a un sol loco.", "settings_pane_visit_again_header": "Visitar de novo", "settings_pane_visit_again_body": "Firefox te monstrara partes de tu chronologia de navigation que tu pote voler rememorar o visitar novemente.", "settings_pane_highlights_header": "In evidentia", - "settings_pane_highlights_body2": "Retrova cosas interessante que tu ha recentemente visitate o marcate.", - "settings_pane_highlights_options_bookmarks": "Paginas marcate", + "settings_pane_highlights_body2": "Retrova cosas interessante que tu ha recentemente visitate o addite marcapaginas.", + "settings_pane_highlights_options_bookmarks": "Marcapaginas", "settings_pane_highlights_options_visited": "Sitos visitate", "settings_pane_snippets_header": "Breve novas", "settings_pane_snippets_body": "Lege breve e legier novas de Mozilla super Firefox, cultura internet e occasionalmente super alcun meme.", @@ -3365,9 +3432,9 @@ "pocket_read_even_more": "Vider plus historias", "pocket_feedback_header": "Le melior del web, selectionate per 25 milliones de personas.", "pocket_description": "Discoperir contento de alte qualitate que tu poterea alteremente non cognoscer, con le adjuta de Pocket, ora parte de Mozilla.", - "highlights_empty_state": "Comencia navigar e nos te monstrara alcun del grande articulos, videos e altere paginas que tu ha recentemente visitate o marcate hic.", + "highlights_empty_state": "Comencia navigar e nos te monstrara alcun del grande articulos, videos e altere paginas que tu ha recentemente visitate o addite marcapaginas hic.", "topstories_empty_state": "Tu ja es in die con toto. Reveni plus tarde pro plus historias popular de {provider}. Non vole attender? Selectiona un subjecto popular pro trovar plus altere historias interessante del web.", - "manual_migration_explanation2": "Essaya Firefox con le paginas marcate, le chronologia e le contrasignos de altere navigator.", + "manual_migration_explanation2": "Essaya Firefox con le marcapaginas, le chronologia e le contrasignos de un altere navigator.", "manual_migration_cancel_button": "No, gratias", "manual_migration_import_button": "Importar ora" }, @@ -3655,7 +3722,7 @@ "default_label_loading": "იტვირთება…", "header_top_sites": "რჩეული საიტები", "header_stories": "რჩეული სტატიები", - "header_highlights": "მნიშნველოვანი სიახლეები", + "header_highlights": "მნიშვნელოვანი საიტები", "header_visit_again": "ხელახლა ნახვა", "header_bookmarks": "ბოლოს ჩანიშნულები", "header_recommended_by": "რეკომენდებულია {provider}-ის მიერ", @@ -3691,7 +3758,7 @@ "section_info_privacy_notice": "პირადი მონაცემების დაცვა", "welcome_title": "მოგესალმებით ახალ ჩანართზე", "welcome_body": "Firefox ამ სივრცეს გამოიყენებს თქვენთვის ყველაზე საჭირო სანიშნეების, სტატიების, ვიდეოებისა და ბოლოს მონახულებული გვერდებისთვის, რომ ადვილად შეძლოთ მათზე დაბრუნება.", - "welcome_label": "რჩეული ვებგვერდების დადგენა", + "welcome_label": "მნიშვნელოვანი საიტების დადგენა", "time_label_less_than_minute": "<1წთ", "time_label_minute": "{number}წთ", "time_label_hour": "{number}სთ", @@ -3708,8 +3775,8 @@ "settings_pane_bookmarks_body": "ახლად შექმნილი სანიშნეები, ერთი ხელის გაწვდენაზე.", "settings_pane_visit_again_header": "ხელახლა ნახვა", "settings_pane_visit_again_body": "Firefox გაჩვენებთ მონახულებული გვერდების ისტორიიდან იმას, რისი გახსენებაც ან რაზე დაბრუნებაც გენდომებათ.", - "settings_pane_highlights_header": "მნიშნველოვანი სიახლეები", - "settings_pane_highlights_body2": "მარტივად დაუბრუნდით ბოლოს მონახულებულ, ან ჩანიშნულ საიტებს.", + "settings_pane_highlights_header": "მნიშვნელოვანი საიტები", + "settings_pane_highlights_body2": "მარტივად დაუბრუნდით ბოლოს მონახულებულ, ან ჩანიშნულ გვერდებს.", "settings_pane_highlights_options_bookmarks": "სანიშნეები", "settings_pane_highlights_options_visited": "მონახულებული საიტები", "settings_pane_snippets_header": "ცნობები", @@ -3717,8 +3784,8 @@ "settings_pane_done_button": "მზადაა", "edit_topsites_button_text": "ჩასწორება", "edit_topsites_button_label": "მოირგეთ რჩეული საიტების განყოფილება", - "edit_topsites_showmore_button": "მეტის ჩვენება", - "edit_topsites_showless_button": "ნაკლების ჩვენება", + "edit_topsites_showmore_button": "მეტის გამოჩენა", + "edit_topsites_showless_button": "ნაკლების გამოჩენა", "edit_topsites_done_button": "მზადაა", "edit_topsites_pin_button": "საიტის მიმაგრება", "edit_topsites_unpin_button": "მიმაგრების მოხსნა", @@ -4308,7 +4375,7 @@ "settings_pane_body2": "Изберете што ќе гледате на оваа страница.", "settings_pane_search_header": "Пребарување", "settings_pane_search_body": "Пребарајте низ Интернет од вашето ново јазиче.", - "settings_pane_topsites_header": "Врвни мрежни места", + "settings_pane_topsites_header": "Популарни мрежни места", "settings_pane_topsites_body": "Пристапете до мрежните места што ги посетувате најмногу.", "settings_pane_topsites_options_showmore": "Прикажи два реда", "settings_pane_bookmarks_header": "Скорешни обележувачи", @@ -4354,8 +4421,11 @@ "newtab_page_title": "പുതിയ ടാബ്", "default_label_loading": "ലോഡ്ചെയ്യുന്നു…", "header_top_sites": "മികച്ച സൈറ്റുകൾ", - "header_highlights": "ഹൈലൈറ്റുകൾ", "header_stories": "മികച്ച ലേഖനങ്ങൾ", + "header_highlights": "ഹൈലൈറ്റുകൾ", + "header_visit_again": "വീണ്ടും സന്ദർശിക്കുക", + "header_bookmarks": "അടുത്തിടെയുള്ള ബുക്ക്മാർക്കുകൾ", + "header_bookmarks_placeholder": "നിങ്ങൾക്ക് ഇതുവരെ ബുക്ക്മാർക്കുകൾ ഇല്ല.", "header_stories_from": "എവിടെ നിന്നും", "type_label_visited": "സന്ദർശിച്ചത്‌", "type_label_bookmarked": "അടയാളപ്പെടുത്തിയത്", @@ -4363,6 +4433,7 @@ "type_label_recommended": "ട്രെൻഡിംഗ്", "type_label_open": "തുറക്കുക", "type_label_topic": "വിഷയം", + "type_label_now": "ഇപ്പോൾ", "menu_action_bookmark": "അടയാളം", "menu_action_remove_bookmark": "അടയാളം മാറ്റുക", "menu_action_copy_address": "വിലാസം പകർത്തുക", @@ -4371,12 +4442,17 @@ "menu_action_open_private_window": "പുതിയ രസഹ്യജാലകത്തിൽ തുറക്കുക", "menu_action_dismiss": "പുറത്താക്കുക", "menu_action_delete": "ചരിത്രത്തിൽ നിന്ന് ഒഴിവാക്കുക", + "menu_action_pin": "പിൻ ചെയ്യുക", + "menu_action_unpin": "അൺപിൻ ചെയ്യുക", + "confirm_history_delete_p1": "നിങ്ങളുടെ ചരിത്രത്തിൽ നിന്ന് ഈ പേജിന്റെ എല്ലാ ഉദാഹരണങ്ങളും ഇല്ലാതാക്കാൻ നിങ്ങൾ താൽപ്പര്യപ്പെടുന്നുവെന്ന് തീർച്ചയാണോ?", + "confirm_history_delete_notice_p2": "ഈ പ്രവർത്തനം പഴയപടിയാക്കാനാവില്ല.", "menu_action_save_to_pocket": "പോക്കറ്റിലേയ്ക്ക് സംരക്ഷിയ്ക്കുക", "search_for_something_with": "തിരയാൻ {search_term} : എന്നത് ഉപയോഗിയ്ക്കുക", "search_button": "തിരയുക", "search_header": "{search_engine_name} തിരയുക", "search_web_placeholder": "ഇൻറർനെറ്റിൽ തിരയുക", "search_settings": "തിരയാനുള്ള രീതികൾ മാറ്റുക", + "section_info_option": "വിവരം", "welcome_title": "പുതിയ ജാലകത്തിലേക്കു സ്വാഗതം", "welcome_body": "നിങ്ങളുടെ ഏറ്റവും ശ്രദ്ധേയമായ അടയാളങ്ങൾ, ലേഖനങ്ങൾ, വീഡിയോകൾ, കൂടാതെ നിങ്ങൾ സമീപകാലത്ത് സന്ദർശിച്ച താളുകൾ എന്നിവ കാണിക്കുന്നതിനായി ഫയർഫോക്സ് ഈ ഇടം ഉപയോഗിക്കും, അതിനാൽ നിങ്ങൾക്ക് എളുപ്പത്തിൽ അവയിലേക്ക് തിരിച്ചു പോകാം.", "welcome_label": "താങ്കളുടെ ഹൈലൈറ്റ്സ് തിരിച്ചറിയുന്നു", @@ -4386,16 +4462,15 @@ "time_label_day": "{number} മിനിറ്റ്", "settings_pane_button_label": "നിങ്ങളുടെ പുതിയ ടാബ് താള് ഇഷ്ടാനുസൃതമാക്കുക", "settings_pane_header": "പുതിയ ടാബിന്റെ മുൻഗണനകൾ", - "settings_pane_body": "പുതിയ ടാബ് തുറക്കുമ്പോൾ എന്ത് കാണണമെന്ന് തീരുമാനിക്കുക.", "settings_pane_search_header": "തിരയുക", "settings_pane_search_body": "പുതിയ ടാബിൽ നിന്ന് ഇന്റർനെറ്റിൽ തിരയുക.", "settings_pane_topsites_header": "മുന്നേറിയ സൈറ്റുകൾ", "settings_pane_topsites_body": "നിങ്ങൾ കൂടുതൽ സന്ദർശിക്കുന്ന വെബ്‌സൈറ്റുകളിൽ പ്രവേശിക്കുക.", "settings_pane_topsites_options_showmore": "രണ്ടു വരികൾ കാണിയ്ക്കുക", + "settings_pane_bookmarks_header": "അടുത്തിടെയുള്ള ബുക്ക്മാർക്കുകൾ", + "settings_pane_bookmarks_body": "നിങ്ങളുടെ പുതിയതായി സൃഷ്ടിച്ച ബുക്ക്മാർക്കുകൾ ഒരു സ്ഥലത്ത്.", + "settings_pane_visit_again_header": "വീണ്ടും സന്ദർശിക്കുക", "settings_pane_highlights_header": "ഹൈലൈറ്റുകൾ", - "settings_pane_highlights_body": "നിങ്ങളുടെ സമീപകാല ബ്രൗസിംഗ് ചരിത്രവും പുതുതായി സൃഷ്ടിച്ച അടയാളങ്ങളും കാണുക.", - "settings_pane_pocketstories_header": "മികച്ച ലേഖനങ്ങൾ", - "settings_pane_pocketstories_body": "മോസില്ല‌ കുടുംബാംഗമായ പോക്കറ്റ്, വിട്ടുപോയേയ്ക്കാവുന്ന മികച്ച ലേഖനങ്ങൾ നിങ്ങളുടെ ശ്രദ്ധയിൽ എത്തിയ്ക്കുന്നു.", "settings_pane_done_button": "തീർന്നു", "edit_topsites_button_text": "തിരുത്തുക", "edit_topsites_button_label": "നിങ്ങളുടെ മുന്നേറിയ സൈറ്റുകളുടെ വിഭാഗം ഇഷ്ടാനുസൃതമാക്കുക", @@ -4418,8 +4493,8 @@ "pocket_read_more": "ജനപ്രിയ വിഷയങ്ങൾ:", "pocket_read_even_more": "കൂടുതൽ ലേഖനങ്ങൾ കാണുക", "pocket_feedback_header": "250 ലക്ഷം പേരാൽ തെരഞ്ഞെടുക്കപ്പെട്ട വെബിലെ ഏറ്റവും മികച്ചവയാണിവ.", - "pocket_feedback_body": "മോസില്ല‌ കുടുംബാംഗമായ പോക്കറ്റ്, വിട്ടുപോയേയ്ക്കാവുന്ന മികച്ച ലേഖനങ്ങൾ നിങ്ങളുടെ ശ്രദ്ധയിൽ എത്തിയ്ക്കുന്നു.", - "pocket_send_feedback": "പ്രതികരണം അയയ്ക്കുക" + "manual_migration_cancel_button": "വേണ്ട, നന്ദി", + "manual_migration_import_button": "ഇപ്പോൾ ഇറക്കുമതി ചെയ്യുക" }, "mr": { "newtab_page_title": "नवीन टॅब", @@ -4742,6 +4817,7 @@ "time_label_day": "{number} दिन", "settings_pane_button_label": "तपाईंको नयाँ ट्याब पृष्ठ अनुकूलन गर्नुहोस्", "settings_pane_header": "नयाँ ट्याब प्राथमिकताहरू", + "settings_pane_body2": "तपाईँले यो पृष्ठमा के देख्नुभयो छनौट गर्नुहोस् ।", "settings_pane_search_header": "खोजी गर्नुहोस्", "settings_pane_search_body": "तपाईंको नयाँ ट्याबबाट वेबमा खोज्नुहोस् ।", "settings_pane_topsites_header": "शीर्ष साइटहरू", @@ -6220,10 +6296,13 @@ "settings_pane_bookmarks_header": "ที่คั่นหน้าล่าสุด", "settings_pane_bookmarks_body": "ที่คั่นหน้าที่สร้างใหม่ของคุณในตำแหน่งที่ตั้งเดียวที่สะดวก", "settings_pane_visit_again_header": "เยี่ยมชมอีกครั้ง", + "settings_pane_visit_again_body": "Firefox จะแสดงประวัติการท่องเว็บที่คุณอาจต้องการให้จดจำหรือกลับไปเยี่ยมชมอีกครั้งที่นี่", "settings_pane_highlights_header": "รายการเด่น", + "settings_pane_highlights_body2": "ค้นหาทางของคุณกลับไปยังสิ่งที่น่าสนใจที่คุณได้เยี่ยมชมหรือเพิ่มที่คั่นหน้าไว้เมื่อเร็ว ๆ นี้", "settings_pane_highlights_options_bookmarks": "ที่คั่นหน้า", "settings_pane_highlights_options_visited": "ไซต์ที่เยี่ยมชมแล้ว", "settings_pane_snippets_header": "ส่วนย่อย", + "settings_pane_snippets_body": "อ่านบทความข่าวสารสั้น ๆ ที่น่าสนใจจาก Mozilla เกี่ยวกับ Firefox, วัฒนธรรมอินเทอร์เน็ต, และมีมแบบสุ่มในบางครั้ง", "settings_pane_done_button": "เสร็จสิ้น", "edit_topsites_button_text": "แก้ไข", "edit_topsites_button_label": "ปรับแต่งส่วนไซต์เด่นของคุณ", @@ -6246,7 +6325,9 @@ "pocket_read_more": "หัวข้อยอดนิยม:", "pocket_read_even_more": "ดูเรื่องราวเพิ่มเติม", "pocket_feedback_header": "ที่สุดของเว็บ จัดรายการโดยผู้คนกว่า 25 ล้านคน", - "highlights_empty_state": "เริ่มการท่องเว็บและเราจะแสดงบทความ, วิดีโอ และหน้าอื่น ๆ บางส่วนที่ยอดเยี่ยมที่คุณได้เยี่ยมชมหรือเพิ่มที่คั่นหน้าไว้เมื่อเร็ว ๆ นี้ที่นี่", + "pocket_description": "ค้นพบเนื้อหาคุณภาพสูงที่คุณอาจจะพลาดไปด้วยความช่วยเหลือจาก Pocket ซึ่งขณะนี้เป็นส่วนหนึ่งของ Mozilla", + "highlights_empty_state": "เริ่มการท่องเว็บและเราจะแสดงบทความ, วิดีโอ และหน้าอื่น ๆ บางส่วนที่ยอดเยี่ยมที่คุณได้เยี่ยมชมหรือเพิ่มที่คั่นหน้าไว้ล่าสุดที่นี่", + "topstories_empty_state": "คุณได้อ่านเรื่องราวครบทั้งหมดแล้ว คุณสามารถกลับมาตรวจดูเรื่องราวเด่นจาก {provider} ได้ภายหลัง อดใจรอไม่ได้งั้นหรือ? เลือกหัวข้อยอดนิยมเพื่อค้นหาเรื่องราวที่ยอดเยี่ยมจากเว็บต่าง ๆ", "manual_migration_explanation2": "ลอง Firefox ด้วยที่คั่นหน้า, ประวัติ และรหัสผ่านจากเบราว์เซอร์อื่น", "manual_migration_cancel_button": "ไม่ ขอบคุณ", "manual_migration_import_button": "นำเข้าตอนนี้" diff --git a/browser/extensions/activity-stream/install.rdf.in b/browser/extensions/activity-stream/install.rdf.in index 521cc92b6311..3d39f5ecd825 100644 --- a/browser/extensions/activity-stream/install.rdf.in +++ b/browser/extensions/activity-stream/install.rdf.in @@ -8,7 +8,7 @@ 2 true false - 2017.10.05.1066-43726d35 + 2017.10.05.1357-d2d5439b Activity Stream A rich visual history feed and a reimagined home page make it easier than ever to find exactly what you're looking for in Firefox. true diff --git a/browser/extensions/activity-stream/lib/HighlightsFeed.jsm b/browser/extensions/activity-stream/lib/HighlightsFeed.jsm index 5732e25c885f..62b615d39ced 100644 --- a/browser/extensions/activity-stream/lib/HighlightsFeed.jsm +++ b/browser/extensions/activity-stream/lib/HighlightsFeed.jsm @@ -21,11 +21,16 @@ XPCOMUtils.defineLazyModuleGetter(this, "NewTabUtils", "resource://gre/modules/NewTabUtils.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "Screenshots", "resource://activity-stream/lib/Screenshots.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "ProfileAge", + "resource://gre/modules/ProfileAge.jsm"); const HIGHLIGHTS_MAX_LENGTH = 9; const HIGHLIGHTS_UPDATE_TIME = 15 * 60 * 1000; // 15 minutes const MANY_EXTRA_LENGTH = HIGHLIGHTS_MAX_LENGTH * 5 + TOP_SITES_SHOWMORE_LENGTH; const SECTION_ID = "highlights"; +const BOOKMARKS_THRESHOLD = 4000; // 3 seconds to milliseconds. +// Some default seconds ago for Activity Stream recent requests +const ACTIVITY_STREAM_DEFAULT_RECENT = 5 * 24 * 60 * 60; this.HighlightsFeed = class HighlightsFeed { constructor() { @@ -33,15 +38,8 @@ this.HighlightsFeed = class HighlightsFeed { this.highlightsLength = 0; this.dedupe = new Dedupe(this._dedupeKey); this.linksCache = new LinksCache(NewTabUtils.activityStreamLinks, - "getHighlights", (oldLink, newLink) => { - // Migrate any pending images or images to the new link - for (const property of ["__fetchingScreenshot", "image"]) { - const oldValue = oldLink[property]; - if (oldValue) { - newLink[property] = oldValue; - } - } - }); + "getHighlights", ["image"]); + this._profileAge = 0; } _dedupeKey(site) { @@ -61,6 +59,20 @@ this.HighlightsFeed = class HighlightsFeed { SectionsManager.disableSection(SECTION_ID); } + /** + * Timeframe used to select recent bookmarks, in seconds. + * Looks back 5 days while also taking into account new profiles + * not to include default bookmarks. + */ + async _getBookmarksThreshold() { + if (this._profileAge === 0) { + // Value in milliseconds. + this._profileAge = await (new ProfileAge()).created; + } + const defaultsThreshold = Date.now() - this._profileAge - BOOKMARKS_THRESHOLD; + return Math.min(ACTIVITY_STREAM_DEFAULT_RECENT, defaultsThreshold / 1000); + } + async fetchHighlights(broadcast = false) { // We broadcast when we want to force an update, so get fresh links if (broadcast) { @@ -81,7 +93,10 @@ this.HighlightsFeed = class HighlightsFeed { // Request more than the expected length to allow for items being removed by // deduping against Top Sites or multiple history from the same domain, etc. - const manyPages = await this.linksCache.request({numItems: MANY_EXTRA_LENGTH}); + const manyPages = await this.linksCache.request({ + numItems: MANY_EXTRA_LENGTH, + bookmarkSecondsAgo: await this._getBookmarksThreshold() + }); // Remove adult highlights if we need to const checkedAdult = this.store.getState().Prefs.values.filterAdult ? @@ -117,9 +132,8 @@ this.HighlightsFeed = class HighlightsFeed { highlights.push(page); hosts.add(hostname); - // Remove any internal properties - delete page.__fetchingScreenshot; - delete page.__updateCache; + // Remove internal properties that might be updated after dispatch + delete page.__sharedCache; // Skip the rest if we have enough items if (highlights.length === HIGHLIGHTS_MAX_LENGTH) { @@ -139,7 +153,7 @@ this.HighlightsFeed = class HighlightsFeed { async fetchImage(page) { // Request a screenshot if we don't already have one pending const {preview_image_url: imageUrl, url} = page; - Screenshots.maybeGetAndSetScreenshot(page, imageUrl || url, "image", image => { + Screenshots.maybeCacheScreenshot(page, imageUrl || url, "image", image => { SectionsManager.updateSectionCard(SECTION_ID, url, {image}, true); }); } @@ -166,6 +180,9 @@ this.HighlightsFeed = class HighlightsFeed { break; case at.PLACES_BOOKMARK_ADDED: case at.PLACES_BOOKMARK_REMOVED: + this.linksCache.expire(); + this.fetchHighlights(false); + break; case at.TOP_SITES_UPDATED: this.fetchHighlights(false); break; diff --git a/browser/extensions/activity-stream/lib/LinksCache.jsm b/browser/extensions/activity-stream/lib/LinksCache.jsm index ce65ff7e1ab8..5fedf4ae9c48 100644 --- a/browser/extensions/activity-stream/lib/LinksCache.jsm +++ b/browser/extensions/activity-stream/lib/LinksCache.jsm @@ -19,19 +19,21 @@ this.LinksCache = class LinksCache { * * @param {object} linkObject Object containing the link property * @param {string} linkProperty Name of property on object to access - * @param {function} migrator Optional callback receiving the old and new link - * to allow custom migrating data from old to new. + * @param {array} properties Optional properties list to migrate to new links. * @param {function} shouldRefresh Optional callback receiving the old and new * options to refresh even when not expired. */ - constructor(linkObject, linkProperty, migrator = () => {}, shouldRefresh = () => {}) { + constructor(linkObject, linkProperty, properties = [], shouldRefresh = () => {}) { this.clear(); + // Allow getting links from both methods and array properties this.linkGetter = options => { const ret = linkObject[linkProperty]; return typeof ret === "function" ? ret.call(linkObject, options) : ret; }; - this.migrator = migrator; + + // Always migrate the shared cache data in addition to any custom properties + this.migrateProperties = ["__sharedCache", ...properties]; this.shouldRefresh = shouldRefresh; } @@ -78,37 +80,38 @@ this.LinksCache = class LinksCache { } } - // Make a shallow copy of each resulting link to allow direct edits - const copied = (await this.linkGetter(options)).map(link => link && - Object.assign({}, link)); + // Update the cache with migrated links without modifying source objects + resolve((await this.linkGetter(options)).map(link => { + // Keep original array hole positions + if (!link) { + return link; + } - // Migrate data to the new link if we have an old link - for (const newLink of copied) { - if (newLink) { - const oldLink = toMigrate.get(newLink.url); - if (oldLink) { - this.migrator(oldLink, newLink); + // Migrate data to the new link copy if we have an old link + const newLink = Object.assign({}, link); + const oldLink = toMigrate.get(newLink.url); + if (oldLink) { + for (const property of this.migrateProperties) { + const oldValue = oldLink[property]; + if (oldValue) { + newLink[property] = oldValue; + } } - - // Add a method that can be copied to cloned links that will update - // the original cached link's property with the current one - newLink.__updateCache = function(prop) { - const val = this[prop]; - if (val === undefined) { - delete newLink[prop]; - } else { - newLink[prop] = val; + } else { + // Share data among link copies and new links from future requests + newLink.__sharedCache = { + // Provide a helper to update the cached link + updateLink(property, value) { + newLink[property] = value; } }; } - } - - // Update cache with the copied links migrated - resolve(copied); + return newLink; + })); }); } - // Return the promise of the links array - return this.cache; + // Provide a shallow copy of the cached link objects for callers to modify + return (await this.cache).map(link => link && Object.assign({}, link)); } }; diff --git a/browser/extensions/activity-stream/lib/Screenshots.jsm b/browser/extensions/activity-stream/lib/Screenshots.jsm index 9054be0d60be..f4aad714a583 100644 --- a/browser/extensions/activity-stream/lib/Screenshots.jsm +++ b/browser/extensions/activity-stream/lib/Screenshots.jsm @@ -65,36 +65,31 @@ this.Screenshots = { /** * Conditionally get a screenshot for a link if there's no existing pending - * screenshot. Updates the link object's desired property with the result. + * screenshot. Updates the cached link's desired property with the result. * * @param link {object} Link object to update * @param url {string} Url to get a screenshot of * @param property {string} Name of property on object to set @ @param onScreenshot {function} Callback for when the screenshot loads */ - async maybeGetAndSetScreenshot(link, url, property, onScreenshot) { - // Make a link copy so we can stash internal properties to cache - const updateCache = link.__updateCache ? link.__updateCache.bind(link) : - () => {}; - - // Request a screenshot if we don't already have one pending - if (!link.__fetchingScreenshot) { - link.__fetchingScreenshot = this.getScreenshotForURL(url); - updateCache("__fetchingScreenshot"); - - // Trigger this callback only when first requesting - link.__fetchingScreenshot.then(onScreenshot).catch(); + async maybeCacheScreenshot(link, url, property, onScreenshot) { + // Nothing to do if we already have a pending screenshot + const cache = link.__sharedCache; + if (cache.fetchingScreenshot) { + return; } - // Clean up now that we got the screenshot - const screenshot = await link.__fetchingScreenshot; - delete link.__fetchingScreenshot; - updateCache("__fetchingScreenshot"); + // Save the promise to the cache so other links get it immediately + cache.fetchingScreenshot = this.getScreenshotForURL(url); - // Update the link so the screenshot is in the cache + // Clean up now that we got the screenshot + const screenshot = await cache.fetchingScreenshot; + delete cache.fetchingScreenshot; + + // Update the cache for future links and call back for existing content if (screenshot) { - link[property] = screenshot; - updateCache(property); + cache.updateLink(property, screenshot); + onScreenshot(screenshot); } } }; diff --git a/browser/extensions/activity-stream/lib/SectionsManager.jsm b/browser/extensions/activity-stream/lib/SectionsManager.jsm index 55d2da4e7268..79fd850a4397 100644 --- a/browser/extensions/activity-stream/lib/SectionsManager.jsm +++ b/browser/extensions/activity-stream/lib/SectionsManager.jsm @@ -6,8 +6,10 @@ const {utils: Cu} = Components; Cu.import("resource://gre/modules/EventEmitter.jsm"); Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); const {actionCreators: ac, actionTypes: at} = Cu.import("resource://activity-stream/common/Actions.jsm", {}); -const {Dedupe} = Cu.import("resource://activity-stream/common/Dedupe.jsm", {}); + +XPCOMUtils.defineLazyModuleGetter(this, "PlacesUtils", "resource://gre/modules/PlacesUtils.jsm"); /* * Generators for built in sections, keyed by the pref name for their feed. @@ -21,7 +23,7 @@ const BUILT_IN_SECTIONS = { titleString: {id: "header_recommended_by", values: {provider: options.provider_name}}, descString: {id: options.provider_description || "pocket_feedback_body"} }, - shouldHidePref: options.hidden, + shouldHidePref: options.hidden, eventSource: "TOP_STORIES", icon: options.provider_icon, title: {id: "header_recommended_by", values: {provider: options.provider_name}}, @@ -74,13 +76,21 @@ const SectionsManager = { for (const feedPrefName of Object.keys(BUILT_IN_SECTIONS)) { const optionsPrefName = `${feedPrefName}.options`; this.addBuiltInSection(feedPrefName, prefs[optionsPrefName]); + + this._dedupeConfiguration = []; + this.sections.forEach(section => { + if (section.dedupeFrom) { + this._dedupeConfiguration.push({ + id: section.id, + dedupeFrom: section.dedupeFrom + }); + } + }); } Object.keys(this.CONTEXT_MENU_PREFS).forEach(k => Services.prefs.addObserver(this.CONTEXT_MENU_PREFS[k], this)); - this.dedupe = new Dedupe(site => site && site.url); - this.initialized = true; this.emit(this.INIT); }, @@ -129,33 +139,28 @@ const SectionsManager = { }, updateSection(id, options, shouldBroadcast) { this.updateSectionContextMenuOptions(options); - if (this.sections.has(id)) { - const dedupedOptions = this.dedupeRows(id, options); - this.sections.set(id, Object.assign(this.sections.get(id), dedupedOptions)); - this.emit(this.UPDATE_SECTION, id, dedupedOptions, shouldBroadcast); - - // Update any sections that dedupe from the updated section - this.sections.forEach(section => { - if (section.dedupeFrom && section.dedupeFrom.includes(id)) { - this.updateSection(section.id, section, shouldBroadcast); - } - }); + const optionsWithDedupe = Object.assign({}, options, {dedupeConfigurations: this._dedupeConfiguration}); + this.sections.set(id, Object.assign(this.sections.get(id), options)); + this.emit(this.UPDATE_SECTION, id, optionsWithDedupe, shouldBroadcast); } }, - dedupeRows(id, options) { - const newOptions = Object.assign({}, options); - const dedupeFrom = this.sections.get(id).dedupeFrom; - if (dedupeFrom && dedupeFrom.length > 0 && options.rows) { - for (const sectionId of dedupeFrom) { - const section = this.sections.get(sectionId); - if (section && section.rows) { - const [, newRows] = this.dedupe.group(section.rows, options.rows); - newOptions.rows = newRows; - } + + updateBookmarkMetadata({url}) { + this.sections.forEach(section => { + if (section.rows) { + section.rows.forEach(card => { + if (card.url === url && card.description && card.title && card.image) { + PlacesUtils.history.update({ + url: card.url, + title: card.title, + description: card.description, + previewImageURL: card.image + }); + } + }); } - } - return newOptions; + }); }, /** @@ -292,6 +297,9 @@ class SectionsFeed { } break; } + case at.PLACES_BOOKMARK_ADDED: + SectionsManager.updateBookmarkMetadata(action.data); + break; case at.SECTION_DISABLE: SectionsManager.disableSection(action.data); break; diff --git a/browser/extensions/activity-stream/lib/TelemetryFeed.jsm b/browser/extensions/activity-stream/lib/TelemetryFeed.jsm index f83c82f03f8f..f0d6cdc4c103 100644 --- a/browser/extensions/activity-stream/lib/TelemetryFeed.jsm +++ b/browser/extensions/activity-stream/lib/TelemetryFeed.jsm @@ -28,7 +28,9 @@ const ACTIVITY_STREAM_ENDPOINT_PREF = "browser.newtabpage.activity-stream.teleme const USER_PREFS_ENCODING = { "showSearch": 1 << 0, "showTopSites": 1 << 1, - "feeds.section.topstories": 1 << 2 + "feeds.section.topstories": 1 << 2, + "feeds.section.highlights": 1 << 3, + "feeds.snippets": 1 << 4 }; const IMPRESSION_STATS_RESET_TIME = 60 * 60 * 1000; // 60 minutes diff --git a/browser/extensions/activity-stream/lib/TopSitesFeed.jsm b/browser/extensions/activity-stream/lib/TopSitesFeed.jsm index 04dafcd4cd85..70665c332e4e 100644 --- a/browser/extensions/activity-stream/lib/TopSitesFeed.jsm +++ b/browser/extensions/activity-stream/lib/TopSitesFeed.jsm @@ -33,11 +33,11 @@ this.TopSitesFeed = class TopSitesFeed { this._tippyTopProvider = new TippyTopProvider(); this.dedupe = new Dedupe(this._dedupeKey); this.frecentCache = new LinksCache(NewTabUtils.activityStreamLinks, - "getTopSites", this.getLinkMigrator(), (oldOptions, newOptions) => + "getTopSites", ["screenshot"], (oldOptions, newOptions) => // Refresh if no old options or requesting more items !(oldOptions.numItems >= newOptions.numItems)); - this.pinnedCache = new LinksCache(NewTabUtils.pinnedLinks, - "links", this.getLinkMigrator(["favicon", "faviconSize"])); + this.pinnedCache = new LinksCache(NewTabUtils.pinnedLinks, "links", + ["favicon", "faviconSize", "screenshot"]); } _dedupeKey(site) { return site && site.hostname; @@ -59,22 +59,6 @@ this.TopSitesFeed = class TopSitesFeed { } } - /** - * Make a cached link data migrator by copying over screenshots and others. - * - * @param others {array} Optional extra properties to copy - */ - getLinkMigrator(others = []) { - const properties = ["__fetchingScreenshot", "screenshot", ...others]; - return (oldLink, newLink) => { - for (const property of properties) { - const oldValue = oldLink[property]; - if (oldValue) { - newLink[property] = oldValue; - } - } - }; - } async getLinksWithDefaults(action) { // Get at least SHOWMORE amount so toggling between 1 and 2 rows has sites const numItems = Math.max(this.store.getState().Prefs.values.topSitesCount, @@ -107,8 +91,8 @@ this.TopSitesFeed = class TopSitesFeed { try { NewTabUtils.activityStreamProvider._faviconBytesToDataURI(await NewTabUtils.activityStreamProvider._addFavicons([copy])); - copy.__updateCache("favicon"); - copy.__updateCache("faviconSize"); + copy.__sharedCache.updateLink("favicon", copy.favicon); + copy.__sharedCache.updateLink("faviconSize", copy.faviconSize); } catch (e) { // Some issue with favicon, so just continue without one } @@ -134,9 +118,8 @@ this.TopSitesFeed = class TopSitesFeed { if (link) { this._fetchIcon(link); - // Remove any internal properties - delete link.__fetchingScreenshot; - delete link.__updateCache; + // Remove internal properties that might be updated after dispatch + delete link.__sharedCache; } } @@ -176,12 +159,11 @@ this.TopSitesFeed = class TopSitesFeed { (!link.favicon || link.faviconSize < MIN_FAVICON_SIZE) && !link.screenshot) { const {url} = link; - Screenshots.maybeGetAndSetScreenshot(link, url, "screenshot", screenshot => { - this.store.dispatch(ac.BroadcastToContent({ + await Screenshots.maybeCacheScreenshot(link, url, "screenshot", + screenshot => this.store.dispatch(ac.BroadcastToContent({ data: {screenshot, url}, type: at.SCREENSHOT_UPDATED - })); - }); + }))); } } diff --git a/browser/extensions/activity-stream/lib/TopStoriesFeed.jsm b/browser/extensions/activity-stream/lib/TopStoriesFeed.jsm index 387f45f9b016..22fa332d796a 100644 --- a/browser/extensions/activity-stream/lib/TopStoriesFeed.jsm +++ b/browser/extensions/activity-stream/lib/TopStoriesFeed.jsm @@ -232,7 +232,8 @@ this.TopStoriesFeed = class TopStoriesFeed { // Create a new array with a spoc inserted at index 2 // For now we're using the top scored spoc until we can support viewability based rotation - let rows = this.stories.slice(0, this.stories.length); + const position = SectionsManager.sections.get(SECTION_ID).order; + let rows = this.store.getState().Sections[position].rows.slice(0, this.stories.length); rows.splice(2, 0, this.spocs[0]); // Send a content update to the target tab @@ -282,14 +283,6 @@ this.TopStoriesFeed = class TopStoriesFeed { if (this.spocs) { this.spocs = this.spocs.filter(s => s.url !== action.data.url); } - - if (this.stories) { - const prevStoriesLength = this.stories.length; - this.stories = this.stories.filter(s => s.url !== action.data.url); - if (prevStoriesLength !== this.stories.length) { - SectionsManager.updateSection(SECTION_ID, {rows: this.stories}, true); - } - } break; } } diff --git a/browser/extensions/activity-stream/test/unit/common/Reducers.test.js b/browser/extensions/activity-stream/test/unit/common/Reducers.test.js index 8a84d2697fe2..2e1d69933f2b 100644 --- a/browser/extensions/activity-stream/test/unit/common/Reducers.test.js +++ b/browser/extensions/activity-stream/test/unit/common/Reducers.test.js @@ -356,6 +356,19 @@ describe("Reducers", () => { const updatedSection = newState.find(section => section.id === "foo_bar_2"); assert.propertyVal(updatedSection, "initialized", false); }); + it("should dedupe based on dedupeConfigurations", () => { + const site = {url: "foo.com"}; + const highlights = {rows: [site], id: "highlights"}; + const topstories = {rows: [site], id: "topstories"}; + const dedupeConfigurations = [{id: "topstories", dedupeFrom: ["highlights"]}]; + const action = {data: {dedupeConfigurations}, type: "SECTION_UPDATE"}; + const state = [highlights, topstories]; + + const nextState = Sections(state, action); + + assert.equal(nextState.find(s => s.id === "highlights").rows.length, 1); + assert.equal(nextState.find(s => s.id === "topstories").rows.length, 0); + }); 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_LINKS_DELETED, data: ["www.foo.bar"]}; diff --git a/browser/extensions/activity-stream/test/unit/lib/HighlightsFeed.test.js b/browser/extensions/activity-stream/test/unit/lib/HighlightsFeed.test.js index 61566df30a32..6f82276d8b11 100644 --- a/browser/extensions/activity-stream/test/unit/lib/HighlightsFeed.test.js +++ b/browser/extensions/activity-stream/test/unit/lib/HighlightsFeed.test.js @@ -22,6 +22,7 @@ describe("Highlights Feed", () => { let filterAdultStub; let sectionsManagerStub; let shortURLStub; + let profileAgeCreatedStub; const fetchHighlights = async() => { await feed.fetchHighlights(); @@ -42,11 +43,18 @@ describe("Highlights Feed", () => { }; fakeScreenshot = { getScreenshotForURL: sandbox.spy(() => Promise.resolve(FAKE_IMAGE)), - maybeGetAndSetScreenshot: Screenshots.maybeGetAndSetScreenshot + maybeCacheScreenshot: Screenshots.maybeCacheScreenshot }; filterAdultStub = sinon.stub().returns([]); shortURLStub = sinon.stub().callsFake(site => site.url.match(/\/([^/]+)/)[1]); + + const fakeProfileAgePromise = {}; + const fakeProfileAge = function() { return fakeProfileAgePromise; }; + profileAgeCreatedStub = sinon.stub().callsFake(() => Promise.resolve(42)); + sinon.stub(fakeProfileAgePromise, "created").get(profileAgeCreatedStub); + globals.set("NewTabUtils", fakeNewTabUtils); + globals.set("ProfileAge", fakeProfileAge); ({HighlightsFeed, HIGHLIGHTS_UPDATE_TIME, SECTION_ID} = injector({ "lib/FilterAdult.jsm": {filterAdult: filterAdultStub}, "lib/ShortURL.jsm": {shortURL: shortURLStub}, @@ -109,6 +117,7 @@ describe("Highlights Feed", () => { const subscribeCallback = feed.store.subscribe.firstCall.args[0]; await subscribeCallback(); await firstFetch; + await feed._getBookmarksThreshold(); assert.calledOnce(fakeNewTabUtils.activityStreamLinks.getHighlights); // If TopSites is initialised in the first place it shouldn't wait @@ -117,6 +126,7 @@ describe("Highlights Feed", () => { fakeNewTabUtils.activityStreamLinks.getHighlights.reset(); await feed.fetchHighlights(); assert.notCalled(feed.store.subscribe); + await feed._getBookmarksThreshold(); assert.calledOnce(fakeNewTabUtils.activityStreamLinks.getHighlights); }); it("should add hostname and hasImage to each link", async () => { @@ -215,8 +225,12 @@ describe("Highlights Feed", () => { describe("#fetchImage", () => { const FAKE_URL = "https://mozilla.org"; const FAKE_IMAGE_URL = "https://mozilla.org/preview.jpg"; + function fetchImage(page) { + return feed.fetchImage(Object.assign({__sharedCache: {updateLink() {}}}, + page)); + } it("should capture the image, if available", async () => { - await feed.fetchImage({ + await fetchImage({ preview_image_url: FAKE_IMAGE_URL, url: FAKE_URL }); @@ -225,13 +239,13 @@ describe("Highlights Feed", () => { assert.calledWith(fakeScreenshot.getScreenshotForURL, FAKE_IMAGE_URL); }); it("should fall back to capturing a screenshot", async () => { - await feed.fetchImage({url: FAKE_URL}); + await fetchImage({url: FAKE_URL}); assert.calledOnce(fakeScreenshot.getScreenshotForURL); assert.calledWith(fakeScreenshot.getScreenshotForURL, FAKE_URL); }); it("should call SectionsManager.updateSectionCard with the right arguments", async () => { - await feed.fetchImage({ + await fetchImage({ preview_image_url: FAKE_IMAGE_URL, url: FAKE_URL }); @@ -239,7 +253,7 @@ describe("Highlights Feed", () => { assert.calledOnce(sectionsManagerStub.updateSectionCard); assert.calledWith(sectionsManagerStub.updateSectionCard, "highlights", FAKE_URL, {image: FAKE_IMAGE}, true); }); - it("should update the card with the image", async () => { + it("should not update the card with the image", async () => { const card = { preview_image_url: FAKE_IMAGE_URL, url: FAKE_URL @@ -247,7 +261,29 @@ describe("Highlights Feed", () => { await feed.fetchImage(card); - assert.propertyVal(card, "image", FAKE_IMAGE); + assert.notProperty(card, "image"); + }); + }); + describe("#_getBookmarksThreshold", () => { + it("should have the correct default", () => { + assert.equal(feed._profileAge, 0); + }); + it("should not call ProfileAge if _profileAge is set", async () => { + feed._profileAge = 10; + + await feed._getBookmarksThreshold(); + + assert.notCalled(profileAgeCreatedStub); + }); + it("should call ProfileAge if _profileAge is not set", async () => { + await feed._getBookmarksThreshold(); + + assert.calledOnce(profileAgeCreatedStub); + }); + it("should set _profileAge", async () => { + await feed._getBookmarksThreshold(); + + assert.notEqual(feed._profileAge, 0); }); }); describe("#uninit", () => { diff --git a/browser/extensions/activity-stream/test/unit/lib/SectionsManager.test.js b/browser/extensions/activity-stream/test/unit/lib/SectionsManager.test.js index 11d328ab8b8d..b005880ce5fb 100644 --- a/browser/extensions/activity-stream/test/unit/lib/SectionsManager.test.js +++ b/browser/extensions/activity-stream/test/unit/lib/SectionsManager.test.js @@ -12,11 +12,14 @@ const FAKE_CARD_OPTIONS = {title: "Some fake title"}; describe("SectionsManager", () => { let globals; let fakeServices; + let fakePlacesUtils; beforeEach(() => { globals = new GlobalOverrider(); fakeServices = {prefs: {getBoolPref: sinon.spy(), addObserver: sinon.spy(), removeObserver: sinon.spy()}}; + fakePlacesUtils = {history: {update: sinon.stub()}}; globals.set("Services", fakeServices); + globals.set("PlacesUtils", fakePlacesUtils); }); afterEach(() => { @@ -132,10 +135,11 @@ describe("SectionsManager", () => { it("should emit an UPDATE_SECTION event with correct arguments", () => { SectionsManager.addSection(FAKE_ID, FAKE_OPTIONS); const spy = sinon.spy(); + const dedupeConfigurations = [{id: "topstories", dedupeFrom: ["highlights"]}]; SectionsManager.on(SectionsManager.UPDATE_SECTION, spy); SectionsManager.updateSection(FAKE_ID, {rows: FAKE_ROWS}, true); assert.calledOnce(spy); - assert.calledWith(spy, SectionsManager.UPDATE_SECTION, FAKE_ID, {rows: FAKE_ROWS}, true); + assert.calledWith(spy, SectionsManager.UPDATE_SECTION, FAKE_ID, {rows: FAKE_ROWS, dedupeConfigurations}, true); }); it("should do nothing if the section doesn't exist", () => { SectionsManager.removeSection(FAKE_ID); @@ -205,25 +209,33 @@ describe("SectionsManager", () => { assert.notCalled(spy); }); }); - describe("#dedupe", () => { - it("should dedupe stories from highlights", () => { - SectionsManager.init(); - // Add some rows to highlights - SectionsManager.updateSection("highlights", {rows: [{url: "https://highlight.com/abc"}, {url: "https://shared.com/def"}]}); - // Add some rows to top stories - SectionsManager.updateSection("topstories", {rows: [{url: "https://topstory.com/ghi"}, {url: "https://shared.com/def"}]}); - // Verify deduping - assert.deepEqual(SectionsManager.sections.get("topstories").rows, [{url: "https://topstory.com/ghi"}]); + describe("#updateBookmarkMetadata", () => { + let rows; + beforeEach(() => { + rows = [{ + url: "bar", + title: "title", + description: "description", + image: "image" + }]; + SectionsManager.addSection(FAKE_ID, {rows}); }); - it("should dedupe stories from highlights when updating highlights", () => { - SectionsManager.init(); - // Add some rows to top stories - SectionsManager.updateSection("topstories", {rows: [{url: "https://topstory.com/ghi"}, {url: "https://shared.com/def"}]}); - assert.deepEqual(SectionsManager.sections.get("topstories").rows, [{url: "https://topstory.com/ghi"}, {url: "https://shared.com/def"}]); - // Add some rows to highlights - SectionsManager.updateSection("highlights", {rows: [{url: "https://highlight.com/abc"}, {url: "https://shared.com/def"}]}); - // Verify deduping - assert.deepEqual(SectionsManager.sections.get("topstories").rows, [{url: "https://topstory.com/ghi"}]); + it("shouldn't call PlacesUtils if no story", () => { + SectionsManager.updateBookmarkMetadata({url: "foo"}); + + assert.notCalled(fakePlacesUtils.history.update); + }); + + it("should call PlacesUtils", () => { + SectionsManager.updateBookmarkMetadata({url: "bar"}); + + assert.calledOnce(fakePlacesUtils.history.update); + assert.calledWithExactly(fakePlacesUtils.history.update, { + url: "bar", + title: "title", + description: "description", + previewImageURL: "image" + }); }); }); }); @@ -426,5 +438,12 @@ describe("SectionsFeed", () => { assert.neverCalledWith(spy, "ACTION_DISPATCHED", action); } }); + it("should call updateBookmarkMetadata on PLACES_BOOKMARK_ADDED", () => { + const stub = sinon.stub(SectionsManager, "updateBookmarkMetadata"); + + feed.onAction({type: "PLACES_BOOKMARK_ADDED", data: {}}); + + assert.calledOnce(stub); + }); }); }); diff --git a/browser/extensions/activity-stream/test/unit/lib/TopSitesFeed.test.js b/browser/extensions/activity-stream/test/unit/lib/TopSitesFeed.test.js index 4cb86e6ca46c..82231b50da2a 100644 --- a/browser/extensions/activity-stream/test/unit/lib/TopSitesFeed.test.js +++ b/browser/extensions/activity-stream/test/unit/lib/TopSitesFeed.test.js @@ -61,7 +61,7 @@ describe("Top Sites Feed", () => { }; fakeScreenshot = { getScreenshotForURL: sandbox.spy(() => Promise.resolve(FAKE_SCREENSHOT)), - maybeGetAndSetScreenshot: Screenshots.maybeGetAndSetScreenshot + maybeCacheScreenshot: Screenshots.maybeCacheScreenshot }; filterAdultStub = sinon.stub().returns([]); shortURLStub = sinon.stub().callsFake(site => site.url); @@ -95,6 +95,10 @@ describe("Top Sites Feed", () => { clock.restore(); }); + function stubFaviconsToUseScreenshots() { + fakeNewTabUtils.activityStreamProvider._addFavicons = sandbox.stub(); + } + describe("#refreshDefaults", () => { it("should add defaults on PREFS_INITIAL_VALUES", () => { feed.onAction({type: at.PREFS_INITIAL_VALUES, data: {"default.sites": "https://foo.com"}}); @@ -136,7 +140,7 @@ describe("Top Sites Feed", () => { describe("general", () => { beforeEach(() => { - sandbox.stub(fakeScreenshot, "maybeGetAndSetScreenshot"); + sandbox.stub(fakeScreenshot, "maybeCacheScreenshot"); }); it("should get the links from NewTabUtils", async () => { const result = await feed.getLinksWithDefaults(); @@ -241,8 +245,7 @@ describe("Top Sites Feed", () => { assert.calledTwice(global.NewTabUtils.activityStreamLinks.getTopSites); }); it("should migrate frecent screenshot data without getting screenshots again", async () => { - // Don't add favicons so we fall back to screenshots - fakeNewTabUtils.activityStreamProvider._addFavicons = sandbox.stub(); + stubFaviconsToUseScreenshots(); await feed.getLinksWithDefaults(); const {callCount} = fakeScreenshot.getScreenshotForURL; feed.frecentCache.expire(); @@ -271,6 +274,36 @@ describe("Top Sites Feed", () => { const internal = Object.keys(result[0]).filter(key => key.startsWith("__")); assert.equal(internal.join(""), ""); }); + describe("concurrency", () => { + let resolvers; + beforeEach(() => { + stubFaviconsToUseScreenshots(); + resolvers = []; + fakeScreenshot.getScreenshotForURL = sandbox.spy(() => new Promise( + resolve => resolvers.push(resolve))); + }); + + const getTwice = () => Promise.all([feed.getLinksWithDefaults(), feed.getLinksWithDefaults()]); + const resolveAll = () => resolvers.forEach(resolve => resolve(FAKE_SCREENSHOT)); + + it("should call the backing data once", async () => { + await getTwice(); + + assert.calledOnce(global.NewTabUtils.activityStreamLinks.getTopSites); + }); + it("should get screenshots once per link", async () => { + await getTwice(); + + assert.callCount(fakeScreenshot.getScreenshotForURL, FAKE_LINKS.length); + }); + it("should dispatch once per link screenshot fetched", async () => { + await getTwice(); + + await resolveAll(); + + assert.callCount(feed.store.dispatch, FAKE_LINKS.length); + }); + }); }); describe("deduping", () => { beforeEach(() => { @@ -376,26 +409,26 @@ describe("Top Sites Feed", () => { assert.propertyVal(link, "screenshot", "reuse.png"); }); it("should reuse existing fetching screenshot on the link", async () => { - const link = {__fetchingScreenshot: Promise.resolve("fetching.png")}; + const link = {__sharedCache: {fetchingScreenshot: Promise.resolve("fetching.png")}}; await feed._fetchIcon(link); assert.notCalled(fakeScreenshot.getScreenshotForURL); - assert.propertyVal(link, "screenshot", "fetching.png"); }); it("should get a screenshot if the link is missing it", () => { - feed._fetchIcon(FAKE_LINKS[0]); + feed._fetchIcon(Object.assign({__sharedCache: {}}, FAKE_LINKS[0])); assert.calledOnce(fakeScreenshot.getScreenshotForURL); assert.calledWith(fakeScreenshot.getScreenshotForURL, FAKE_LINKS[0].url); }); - it("should update the link's cache if it can be updated", () => { - const link = {__updateCache: sandbox.stub()}; + it("should update the link's cache with a screenshot", async () => { + const updateLink = sandbox.stub(); + const link = {__sharedCache: {updateLink}}; - feed._fetchIcon(link); + await feed._fetchIcon(link); - assert.calledOnce(link.__updateCache); - assert.calledWith(link.__updateCache, "__fetchingScreenshot"); + assert.calledOnce(updateLink); + assert.calledWith(updateLink, "screenshot", FAKE_SCREENSHOT); }); it("should skip getting a screenshot if there is a tippy top icon", () => { feed._tippyTopProvider.processSite = site => { diff --git a/browser/extensions/activity-stream/test/unit/lib/TopStoriesFeed.test.js b/browser/extensions/activity-stream/test/unit/lib/TopStoriesFeed.test.js index 8bb75878329e..0644cd9b0d74 100644 --- a/browser/extensions/activity-stream/test/unit/lib/TopStoriesFeed.test.js +++ b/browser/extensions/activity-stream/test/unit/lib/TopStoriesFeed.test.js @@ -40,7 +40,7 @@ describe("Top Stories Feed", () => { enableSection: sinon.spy(), disableSection: sinon.spy(), updateSection: sinon.spy(), - sections: new Map([["topstories", {options: FAKE_OPTIONS}]]) + sections: new Map([["topstories", {order: 0, options: FAKE_OPTIONS}]]) }; class FakeUserDomainAffinityProvider {} @@ -377,7 +377,7 @@ describe("Top Stories Feed", () => { const response = { "settings": {"spocsPerNewTabs": 2}, - "recommendations": [{"id": "rec1"}, {"id": "rec2"}, {"id": "rec3"}], + "recommendations": [{"guid": "rec1"}, {"guid": "rec2"}, {"guid": "rec3"}], "spocs": [{"id": "spoc1"}, {"id": "spoc2"}] }; @@ -387,9 +387,12 @@ describe("Top Stories Feed", () => { fetchStub.resolves({ok: true, status: 200, json: () => Promise.resolve(response)}); await instance.fetchStories(); + instance.store.getState = () => ({Sections: [{rows: response.recommendations}]}); + instance.onAction({type: at.NEW_TAB_REHYDRATED, meta: {fromTarget: {}}}); assert.calledOnce(instance.store.dispatch); let action = instance.store.dispatch.firstCall.args[0]; + assert.equal(at.SECTION_UPDATE, action.type); assert.equal(true, action.meta.skipMain); assert.equal(action.data.rows[0].guid, "rec1"); @@ -429,6 +432,8 @@ describe("Top Stories Feed", () => { assert.notCalled(instance.store.dispatch); assert.equal(instance.contentUpdateQueue.length, 1); + instance.store.getState = () => ({Sections: [{rows: response.recommendations}]}); + await instance.fetchStories(); assert.equal(instance.contentUpdateQueue.length, 0); assert.calledOnce(instance.store.dispatch); @@ -538,15 +543,11 @@ describe("Top Stories Feed", () => { assert.equal(instance.topicsLastUpdated, 0); assert.equal(instance.affinityLastUpdated, 0); }); - it("should filter recs and spocs when link is blocked", () => { - instance.stories = [{"url": "not_blocked"}, {"url": "blocked"}]; + it("should filter spocs when link is blocked", () => { instance.spocs = [{"url": "not_blocked"}, {"url": "blocked"}]; instance.onAction({type: at.PLACES_LINK_BLOCKED, data: {url: "blocked"}}); - assert.deepEqual(instance.stories, [{"url": "not_blocked"}]); assert.deepEqual(instance.spocs, [{"url": "not_blocked"}]); - assert.calledOnce(sectionsManagerStub.updateSection); - assert.calledWith(sectionsManagerStub.updateSection, SECTION_ID, {rows: instance.stories}); }); }); });