Bug 1382785 - Add Pocket, search delay, and bug fixes to Activity Stream r=Mardak

MozReview-Commit-ID: CQEN0Rzy6TX

--HG--
extra : rebase_source : 010c160c5689634056ffc81f6efb5d65961e14b8
This commit is contained in:
Ursula Sarracini 2017-07-20 16:59:59 -04:00
parent 19dd7600d0
commit 7bea00cd2b
28 changed files with 2060 additions and 180 deletions

View File

@ -31,6 +31,7 @@ for (const type of [
"DELETE_HISTORY_URL_CONFIRM", "DELETE_HISTORY_URL_CONFIRM",
"DIALOG_CANCEL", "DIALOG_CANCEL",
"DIALOG_OPEN", "DIALOG_OPEN",
"FEED_INIT",
"INIT", "INIT",
"LOCALE_UPDATED", "LOCALE_UPDATED",
"NEW_TAB_INITIAL_STATE", "NEW_TAB_INITIAL_STATE",
@ -50,7 +51,13 @@ for (const type of [
"PREF_CHANGED", "PREF_CHANGED",
"SAVE_TO_POCKET", "SAVE_TO_POCKET",
"SCREENSHOT_UPDATED", "SCREENSHOT_UPDATED",
"SECTION_DEREGISTER",
"SECTION_REGISTER",
"SECTION_ROWS_UPDATE",
"SET_PREF", "SET_PREF",
"SNIPPETS_DATA",
"SNIPPETS_RESET",
"SYSTEM_TICK",
"TELEMETRY_PERFORMANCE_EVENT", "TELEMETRY_PERFORMANCE_EVENT",
"TELEMETRY_UNDESIRED_EVENT", "TELEMETRY_UNDESIRED_EVENT",
"TELEMETRY_USER_EVENT", "TELEMETRY_USER_EVENT",

View File

@ -16,6 +16,7 @@ const INITIAL_STATE = {
// The version of the system-addon // The version of the system-addon
version: null version: null
}, },
Snippets: {initialized: false},
TopSites: { TopSites: {
// Have we received real data from history yet? // Have we received real data from history yet?
initialized: false, initialized: false,
@ -29,7 +30,8 @@ const INITIAL_STATE = {
Dialog: { Dialog: {
visible: false, visible: false,
data: {} data: {}
} },
Sections: []
}; };
function App(prevState = INITIAL_STATE.App, action) { function App(prevState = INITIAL_STATE.App, action) {
@ -105,6 +107,9 @@ function TopSites(prevState = INITIAL_STATE.TopSites, action) {
}); });
return hasMatch ? Object.assign({}, prevState, {rows: newRows}) : prevState; return hasMatch ? Object.assign({}, prevState, {rows: newRows}) : prevState;
case at.PLACES_BOOKMARK_ADDED: case at.PLACES_BOOKMARK_ADDED:
if (!action.data) {
return prevState;
}
newRows = prevState.rows.map(site => { newRows = prevState.rows.map(site => {
if (site && site.url === action.data.url) { if (site && site.url === action.data.url) {
const {bookmarkGuid, bookmarkTitle, lastModified} = action.data; const {bookmarkGuid, bookmarkTitle, lastModified} = action.data;
@ -114,6 +119,9 @@ function TopSites(prevState = INITIAL_STATE.TopSites, action) {
}); });
return Object.assign({}, prevState, {rows: newRows}); return Object.assign({}, prevState, {rows: newRows});
case at.PLACES_BOOKMARK_REMOVED: case at.PLACES_BOOKMARK_REMOVED:
if (!action.data) {
return prevState;
}
newRows = prevState.rows.map(site => { newRows = prevState.rows.map(site => {
if (site && site.url === action.data.url) { if (site && site.url === action.data.url) {
const newSite = Object.assign({}, site); const newSite = Object.assign({}, site);
@ -165,8 +173,58 @@ function Prefs(prevState = INITIAL_STATE.Prefs, action) {
} }
} }
function Sections(prevState = INITIAL_STATE.Sections, action) {
let hasMatch;
let newState;
switch (action.type) {
case at.SECTION_DEREGISTER:
return prevState.filter(section => section.id !== action.data);
case at.SECTION_REGISTER:
// If section exists in prevState, update it
newState = prevState.map(section => {
if (section && section.id === action.data.id) {
hasMatch = true;
return Object.assign({}, section, action.data);
}
return section;
});
// If section doesn't exist in prevState, create a new section object and
// append it to the sections state
if (!hasMatch) {
const initialized = action.data.rows && action.data.rows.length > 0;
newState.push(Object.assign({title: "", initialized, rows: []}, action.data));
}
return newState;
case at.SECTION_ROWS_UPDATE:
return prevState.map(section => {
if (section && section.id === action.data.id) {
return Object.assign({}, section, action.data);
}
return section;
});
case at.PLACES_LINK_DELETED:
case at.PLACES_LINK_BLOCKED:
return prevState.map(section =>
Object.assign({}, section, {rows: section.rows.filter(site => site.url !== action.data.url)}));
default:
return prevState;
}
}
function Snippets(prevState = INITIAL_STATE.Snippets, action) {
switch (action.type) {
case at.SNIPPETS_DATA:
return Object.assign({}, prevState, {initialized: true}, action.data);
case at.SNIPPETS_RESET:
return INITIAL_STATE.Snippets;
default:
return prevState;
}
}
this.INITIAL_STATE = INITIAL_STATE; this.INITIAL_STATE = INITIAL_STATE;
this.reducers = {TopSites, App, Prefs, Dialog};
this.reducers = {TopSites, App, Snippets, Prefs, Dialog, Sections};
this.insertPinned = insertPinned; this.insertPinned = insertPinned;
this.EXPORTED_SYMBOLS = ["reducers", "INITIAL_STATE", "insertPinned"]; this.EXPORTED_SYMBOLS = ["reducers", "INITIAL_STATE", "insertPinned"];

View File

@ -1,3 +1,4 @@
@charset "UTF-8";
html { html {
box-sizing: border-box; } box-sizing: border-box; }
@ -30,6 +31,8 @@ input {
vertical-align: middle; } vertical-align: middle; }
.icon.icon-spacer { .icon.icon-spacer {
margin-inline-end: 8px; } margin-inline-end: 8px; }
.icon.icon-small-spacer {
margin-inline-end: 6px; }
.icon.icon-bookmark { .icon.icon-bookmark {
background-image: url("assets/glyph-bookmark-16.svg"); } background-image: url("assets/glyph-bookmark-16.svg"); }
.icon.icon-bookmark-remove { .icon.icon-bookmark-remove {
@ -50,11 +53,19 @@ input {
background-image: url("assets/glyph-unpin-16.svg"); } background-image: url("assets/glyph-unpin-16.svg"); }
.icon.icon-pocket { .icon.icon-pocket {
background-image: url("assets/glyph-pocket-16.svg"); } background-image: url("assets/glyph-pocket-16.svg"); }
.icon.icon-historyItem {
background-image: url("assets/glyph-historyItem-16.svg"); }
.icon.icon-trending {
background-image: url("assets/glyph-trending-16.svg"); }
.icon.icon-now {
background-image: url("assets/glyph-now-16.svg"); }
.icon.icon-pin-small { .icon.icon-pin-small {
background-image: url("assets/glyph-pin-12.svg"); background-image: url("assets/glyph-pin-12.svg");
background-size: 12px; background-size: 12px;
height: 12px; height: 12px;
width: 12px; } width: 12px; }
.icon.icon-check {
background-image: url("chrome://browser/skin/check.svg"); }
html, html,
body, body,
@ -134,6 +145,19 @@ a {
color: #FFF; color: #FFF;
margin-inline-start: auto; } margin-inline-start: auto; }
#snippets-container {
display: none;
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: white;
height: 122px; }
#snippets {
max-width: 736px;
margin: 0 auto; }
.outer-wrapper { .outer-wrapper {
display: flex; display: flex;
flex-grow: 1; flex-grow: 1;
@ -149,7 +173,7 @@ main {
main { main {
width: 736px; } } width: 736px; } }
main section { main section {
margin-bottom: 41px; } margin-bottom: 40px; }
.section-title { .section-title {
color: #6E707E; color: #6E707E;
@ -205,10 +229,10 @@ main {
.top-sites-list .top-site-outer .context-menu-button:focus, .top-sites-list .top-site-outer .context-menu-button:active { .top-sites-list .top-site-outer .context-menu-button:focus, .top-sites-list .top-site-outer .context-menu-button:active {
transform: scale(1); transform: scale(1);
opacity: 1; } opacity: 1; }
.top-sites-list .top-site-outer:hover .tile, .top-sites-list .top-site-outer:active .tile, .top-sites-list .top-site-outer:focus .tile, .top-sites-list .top-site-outer.active .tile { .top-sites-list .top-site-outer:hover .tile, .top-sites-list .top-site-outer:focus .tile, .top-sites-list .top-site-outer.active .tile {
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.1), 0 0 0 5px rgba(0, 0, 0, 0.1); box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.1), 0 0 0 5px rgba(0, 0, 0, 0.1);
transition: box-shadow 150ms; } transition: box-shadow 150ms; }
.top-sites-list .top-site-outer:hover .context-menu-button, .top-sites-list .top-site-outer:active .context-menu-button, .top-sites-list .top-site-outer:focus .context-menu-button, .top-sites-list .top-site-outer.active .context-menu-button { .top-sites-list .top-site-outer:hover .context-menu-button, .top-sites-list .top-site-outer:focus .context-menu-button, .top-sites-list .top-site-outer.active .context-menu-button {
transform: scale(1); transform: scale(1);
opacity: 1; } opacity: 1; }
.top-sites-list .top-site-outer .tile { .top-sites-list .top-site-outer .tile {
@ -258,6 +282,117 @@ main {
.top-sites-list .top-site-outer .title.pinned span { .top-sites-list .top-site-outer .title.pinned span {
padding: 0 13px; } padding: 0 13px; }
.sections-list .section-top-bar {
position: relative;
height: 16px;
margin-bottom: 18px; }
.sections-list .section-top-bar .section-title {
float: left; }
.sections-list .section-top-bar .section-info-option {
float: right; }
.sections-list .section-top-bar .info-option-icon {
background-image: url("assets/glyph-info-option-12.svg");
background-size: 12px 12px;
background-repeat: no-repeat;
background-position: center;
height: 16px;
width: 16px;
display: inline-block; }
.sections-list .section-top-bar .section-info-option div {
visibility: hidden;
opacity: 0;
transition: visibility 0.2s, opacity 0.2s ease-out;
transition-delay: 0.5s; }
.sections-list .section-top-bar .section-info-option:hover div {
visibility: visible;
opacity: 1;
transition: visibility 0.2s, opacity 0.2s ease-out; }
.sections-list .section-top-bar .info-option {
z-index: 9999;
position: absolute;
background: #FFF;
border: solid 1px rgba(0, 0, 0, 0.1);
border-radius: 3px;
font-size: 13px;
color: #0C0C0D;
line-height: 120%;
width: 320px;
right: 0;
top: 34px;
margin-top: -4px;
margin-right: -4px;
padding: 24px;
-moz-user-select: none; }
.sections-list .section-top-bar .info-option-header {
font-size: 15px;
font-weight: 600; }
.sections-list .section-top-bar .info-option-body {
margin: 0;
margin-top: 12px; }
.sections-list .section-top-bar .info-option-link {
display: block;
margin-top: 12px;
color: #0A84FF; }
.sections-list .section-list {
width: 768px;
clear: both;
margin: 0; }
.sections-list .section-empty-state {
width: 100%;
height: 266px;
display: flex;
border: solid 1px rgba(0, 0, 0, 0.1);
border-radius: 3px; }
.sections-list .section-empty-state .empty-state {
margin: auto;
max-width: 350px; }
.sections-list .section-empty-state .empty-state .empty-state-icon {
background-size: 50px 50px;
background-repeat: no-repeat;
background-position: center;
fill: rgba(160, 160, 160, 0.4);
-moz-context-properties: fill;
height: 50px;
width: 50px;
margin: 0 auto;
display: block; }
.sections-list .section-empty-state .empty-state .empty-state-message {
margin-bottom: 0;
font-size: 13px;
font-weight: 300;
color: #A0A0A0;
text-align: center; }
.topic {
font-size: 13px;
color: #BFC0C7;
min-width: 780px; }
.topic ul {
display: inline;
padding-left: 12px; }
.topic ul li {
display: inline; }
.topic ul li::after {
content: '•';
padding-left: 8px;
padding-right: 8px; }
.topic ul li:last-child::after {
content: none; }
.topic .topic-link {
color: #008EA4; }
.topic .topic-read-more {
float: right;
margin-right: 40px;
color: #008EA4; }
.topic .topic-read-more-logo {
padding-right: 10px;
margin-left: 5px;
background-image: url("assets/topic-show-more-12.svg");
background-repeat: no-repeat;
background-position-y: 2px; }
.search-wrapper { .search-wrapper {
cursor: default; cursor: default;
display: flex; display: flex;
@ -516,3 +651,109 @@ main {
border-radius: 3px; border-radius: 3px;
font-size: 14px; font-size: 14px;
z-index: 11002; } z-index: 11002; }
.card-outer {
background: #FFF;
display: inline-block;
margin-inline-end: 32px;
margin-bottom: 16px;
width: 224px;
border-radius: 3px;
border-color: rgba(0, 0, 0, 0.1);
height: 266px;
position: relative; }
.card-outer .context-menu-button {
cursor: pointer;
position: absolute;
top: -13.5px;
offset-inline-end: -13.5px;
width: 27px;
height: 27px;
background-color: #FFF;
background-image: url("assets/glyph-more-16.svg");
background-position: 65%;
background-repeat: no-repeat;
background-clip: padding-box;
border: 1px solid rgba(0, 0, 0, 0.2);
border-radius: 100%;
box-shadow: 0 2px 0 rgba(0, 0, 0, 0.1);
transform: scale(0.25);
opacity: 0;
transition-property: transform, opacity;
transition-duration: 200ms;
z-index: 399; }
.card-outer .context-menu-button:focus, .card-outer .context-menu-button:active {
transform: scale(1);
opacity: 1; }
.card-outer .card {
height: 100%;
border-radius: 3px; }
.card-outer > a {
display: block;
color: inherit;
height: 100%;
outline: none;
position: absolute; }
.card-outer > a.active .card, .card-outer > a:focus .card {
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.1), 0 0 0 5px rgba(0, 0, 0, 0.1);
transition: box-shadow 150ms; }
.card-outer:hover, .card-outer:focus, .card-outer.active {
outline: none;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.1), 0 0 0 5px rgba(0, 0, 0, 0.1);
transition: box-shadow 150ms; }
.card-outer:hover .context-menu-button, .card-outer:focus .context-menu-button, .card-outer.active .context-menu-button {
transform: scale(1);
opacity: 1; }
.card-outer .card-preview-image {
position: relative;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
height: 122px;
border-bottom-color: rgba(0, 0, 0, 0.1);
border-bottom-style: solid;
border-bottom-width: 1px;
border-radius: 3px 3px 0 0; }
.card-outer .card-details {
padding: 10px 16px 12px; }
.card-outer .card-text {
overflow: hidden;
max-height: 78px; }
.card-outer .card-text.full-height {
max-height: 200px; }
.card-outer .card-host-name {
color: #858585;
font-size: 10px;
padding-bottom: 4px;
text-transform: uppercase; }
.card-outer .card-title {
margin: 0 0 2px;
font-size: inherit;
word-wrap: break-word; }
.card-outer .card-description {
font-size: 12px;
margin: 0;
word-wrap: break-word;
overflow: hidden;
line-height: 18px;
max-height: 34px; }
.card-outer .card-context {
padding: 16px 16px 14px 14px;
position: absolute;
bottom: 0;
left: 0;
right: 0;
color: #A0A0A0;
font-size: 11px;
display: flex;
align-items: center; }
.card-outer .card-context-icon {
opacity: 0.5;
font-size: 13px;
margin-inline-end: 6px;
display: block; }
.card-outer .card-context-label {
flex-grow: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap; }

View File

@ -8,6 +8,10 @@
</head> </head>
<body class="activity-stream"> <body class="activity-stream">
<div id="root"></div> <div id="root"></div>
<div id="snippets-container">
<div id="topSection"></div> <!-- TODO: placeholder for v4 snippets. It should be removed when we switch to v5 -->
<div id="snippets"></div>
</div>
<script src="chrome://browser/content/contentSearchUI.js"></script> <script src="chrome://browser/content/contentSearchUI.js"></script>
<script src="resource://activity-stream/vendor/react.js"></script> <script src="resource://activity-stream/vendor/react.js"></script>
<script src="resource://activity-stream/vendor/react-dom.js"></script> <script src="resource://activity-stream/vendor/react-dom.js"></script>

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="#4d4d4d" d="M365,190a4,4,0,1,1,4-4A4,4,0,0,1,365,190Zm0-6a2,2,0,1,0,2,2A2,2,0,0,0,365,184Z" transform="translate(-357 -178)"/>
</svg>

After

Width:  |  Height:  |  Size: 450 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12"><path fill="#999" d="M6 0a6 6 0 1 0 6 6 6 6 0 0 0-6-6zm.7 10.26a1.13 1.13 0 0 1-.78.28 1.13 1.13 0 0 1-.78-.28 1 1 0 0 1 0-1.42 1.13 1.13 0 0 1 .78-.28 1.13 1.13 0 0 1 .78.28 1 1 0 0 1 0 1.42zM8.55 5a3 3 0 0 1-.62.81l-.67.63a1.58 1.58 0 0 0-.4.57 2.24 2.24 0 0 0-.12.74H5.06a3.82 3.82 0 0 1 .19-1.35 2.11 2.11 0 0 1 .63-.86 4.17 4.17 0 0 0 .66-.67 1.09 1.09 0 0 0 .23-.67.73.73 0 0 0-.77-.86.71.71 0 0 0-.57.26 1.1 1.1 0 0 0-.23.7h-2A2.36 2.36 0 0 1 4 2.47a2.94 2.94 0 0 1 2-.65 3.06 3.06 0 0 1 2 .6 2.12 2.12 0 0 1 .72 1.72 2 2 0 0 1-.17.86z"/></svg>

After

Width:  |  Height:  |  Size: 612 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="#4d4d4d" d="M8 0a8 8 0 1 0 8 8 8.009 8.009 0 0 0-8-8zm0 14a6 6 0 1 1 6-6 6.007 6.007 0 0 1-6 6zm3.5-6H8V4.5a.5.5 0 0 0-1 0v4a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 0-1z"/>
</svg>

After

Width:  |  Height:  |  Size: 479 B

View File

@ -2,5 +2,5 @@
- License, v. 2.0. If a copy of the MPL was not distributed with this - 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/. --> - 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"> <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"/> <path fill="#4d4d4d" d="M8 15a8 8 0 0 1-8-8V3a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v4a8 8 0 0 1-8 8zm3.985-10.032a.99.99 0 0 0-.725.319L7.978 8.57 4.755 5.336A.984.984 0 0 0 4 4.968a1 1 0 0 0-.714 1.7l-.016.011 3.293 3.306.707.707a1 1 0 0 0 1.414 0l.707-.707L12.7 6.679a1 1 0 0 0-.715-1.711z"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 597 B

After

Width:  |  Height:  |  Size: 593 B

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="16px" height="16px" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="Symbols" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Context-/-Pocket-Trending" fill="#999999">
<path d="M12.164765,5.74981818 C12.4404792,5.74981818 12.5976221,6.06981818 12.4233364,6.28509091 C10.7404792,8.37236364 4.26619353,15.6829091 4.15905067,15.744 C5.70047924,12.3301818 7.1276221,8.976 7.1276221,8.976 L4.3276221,8.976 C4.09905067,8.976 3.9376221,8.74472727 4.02333638,8.52654545 C4.70047924,6.77672727 6.86190781,1.32945455 7.30476495,0.216727273 C7.35333638,0.0916363636 7.46190781,0.0174545455 7.59476495,0.016 C8.32476495,0.0130909091 10.7904792,0.00290909091 12.5790507,0 C12.844765,0 12.9976221,0.305454545 12.8433364,0.525090909 L9.17190781,5.74981818 L12.164765,5.74981818 Z" id="Fill-1"></path>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 985 B

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="12px" height="12px" viewBox="0 0 12 12" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 43.2 (39069) - http://www.bohemiancoding.com/sketch -->
<title>Icon / &gt;</title>
<desc>Created with Sketch.</desc>
<defs></defs>
<g id="Symbols" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" stroke-linecap="round" stroke-linejoin="round">
<g id="Icon-/-&gt;" stroke-width="2" stroke="#008EA4">
<polyline id="Path-2" points="4 2 8 6 4 10"></polyline>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 644 B

View File

@ -1025,6 +1025,7 @@
"header_stories": "Top Stories", "header_stories": "Top Stories",
"header_visit_again": "Visit Again", "header_visit_again": "Visit Again",
"header_bookmarks": "Recent Bookmarks", "header_bookmarks": "Recent Bookmarks",
"header_recommended_by": "Recommended by {provider}",
"header_bookmarks_placeholder": "You dont have any bookmarks yet.", "header_bookmarks_placeholder": "You dont have any bookmarks yet.",
"header_stories_from": "from", "header_stories_from": "from",
"type_label_visited": "Visited", "type_label_visited": "Visited",
@ -1051,6 +1052,7 @@
"search_header": "{search_engine_name} Search", "search_header": "{search_engine_name} Search",
"search_web_placeholder": "Search the Web", "search_web_placeholder": "Search the Web",
"search_settings": "Change Search Settings", "search_settings": "Change Search Settings",
"section_info_option": "Info",
"welcome_title": "Welcome to new tab", "welcome_title": "Welcome to new tab",
"welcome_body": "Firefox will use this space to show your most relevant bookmarks, articles, videos, and pages youve recently visited, so you can get back to them easily.", "welcome_body": "Firefox will use this space to show your most relevant bookmarks, articles, videos, and pages youve recently visited, so you can get back to them easily.",
"welcome_label": "Identifying your Highlights", "welcome_label": "Identifying your Highlights",
@ -1095,7 +1097,8 @@
"pocket_read_even_more": "View More Stories", "pocket_read_even_more": "View More Stories",
"pocket_feedback_header": "The best of the web, curated by over 25 million people.", "pocket_feedback_header": "The best of the web, curated by over 25 million people.",
"pocket_feedback_body": "Pocket, a part of the Mozilla family, will help connect you to high-quality content that you may not have found otherwise.", "pocket_feedback_body": "Pocket, a part of the Mozilla family, will help connect you to high-quality content that you may not have found otherwise.",
"pocket_send_feedback": "Send Feedback" "pocket_send_feedback": "Send Feedback",
"empty_state_topstories": "Youve caught up. Check back later for more top stories from Pocket. Cant wait? Select a popular topic to find more great stories from around the web."
}, },
"en-ZA": {}, "en-ZA": {},
"eo": { "eo": {

View File

@ -14,11 +14,34 @@ const {NewTabInit} = Cu.import("resource://activity-stream/lib/NewTabInit.jsm",
const {PlacesFeed} = Cu.import("resource://activity-stream/lib/PlacesFeed.jsm", {}); const {PlacesFeed} = Cu.import("resource://activity-stream/lib/PlacesFeed.jsm", {});
const {PrefsFeed} = Cu.import("resource://activity-stream/lib/PrefsFeed.jsm", {}); const {PrefsFeed} = Cu.import("resource://activity-stream/lib/PrefsFeed.jsm", {});
const {Store} = Cu.import("resource://activity-stream/lib/Store.jsm", {}); const {Store} = Cu.import("resource://activity-stream/lib/Store.jsm", {});
const {SnippetsFeed} = Cu.import("resource://activity-stream/lib/SnippetsFeed.jsm", {});
const {SystemTickFeed} = Cu.import("resource://activity-stream/lib/SystemTickFeed.jsm", {});
const {TelemetryFeed} = Cu.import("resource://activity-stream/lib/TelemetryFeed.jsm", {}); const {TelemetryFeed} = Cu.import("resource://activity-stream/lib/TelemetryFeed.jsm", {});
const {TopSitesFeed} = Cu.import("resource://activity-stream/lib/TopSitesFeed.jsm", {}); const {TopSitesFeed} = Cu.import("resource://activity-stream/lib/TopSitesFeed.jsm", {});
const {TopStoriesFeed} = Cu.import("resource://activity-stream/lib/TopStoriesFeed.jsm", {});
const REASON_ADDON_UNINSTALL = 6; const REASON_ADDON_UNINSTALL = 6;
// Sections, keyed by section id
const SECTIONS = new Map([
["topstories", {
feed: TopStoriesFeed,
prefTitle: "Fetches content recommendations from a configurable content provider",
showByDefault: false
}]
]);
const SECTION_FEEDS_CONFIG = Array.from(SECTIONS.entries()).map(entry => {
const id = entry[0];
const {feed: Feed, prefTitle, showByDefault: value} = entry[1];
return {
name: `section.${id}`,
factory: () => new Feed(),
title: prefTitle || `${id} section feed`,
value
};
});
const PREFS_CONFIG = new Map([ const PREFS_CONFIG = new Map([
["default.sites", { ["default.sites", {
title: "Comma-separated list of default top sites to fill in behind visited sites", title: "Comma-separated list of default top sites to fill in behind visited sites",
@ -45,11 +68,24 @@ const PREFS_CONFIG = new Map([
["telemetry.ping.endpoint", { ["telemetry.ping.endpoint", {
title: "Telemetry server endpoint", title: "Telemetry server endpoint",
value: "https://tiles.services.mozilla.com/v4/links/activity-stream" value: "https://tiles.services.mozilla.com/v4/links/activity-stream"
}],
["feeds.section.topstories.options", {
title: "Configuration options for top stories feed",
value: `{
"stories_endpoint": "https://getpocket.com/v3/firefox/global-recs?consumer_key=$apiKey",
"topics_endpoint": "https://getpocket.com/v3/firefox/trending-topics?consumer_key=$apiKey",
"read_more_endpoint": "https://getpocket.com/explore/trending?src=ff_new_tab",
"learn_more_endpoint": "https://getpocket.com/firefox_learnmore?src=ff_newtab",
"survey_link": "https://www.surveymonkey.com/r/newtabffx",
"api_key_pref": "extensions.pocket.oAuthConsumerKey",
"provider_name": "Pocket",
"provider_icon": "pocket"
}`
}] }]
]); ]);
const FEEDS_CONFIG = new Map(); const FEEDS_CONFIG = new Map();
for (const {name, factory, title, value} of [ for (const {name, factory, title, value} of SECTION_FEEDS_CONFIG.concat([
{ {
name: "localization", name: "localization",
factory: () => new LocalizationFeed(), factory: () => new LocalizationFeed(),
@ -74,6 +110,18 @@ for (const {name, factory, title, value} of [
title: "Preferences", title: "Preferences",
value: true value: true
}, },
{
name: "snippets",
factory: () => new SnippetsFeed(),
title: "Gets snippets data",
value: false
},
{
name: "systemtick",
factory: () => new SystemTickFeed(),
title: "Produces system tick events to periodically check for data expiry",
value: true
},
{ {
name: "telemetry", name: "telemetry",
factory: () => new TelemetryFeed(), factory: () => new TelemetryFeed(),
@ -86,7 +134,7 @@ for (const {name, factory, title, value} of [
title: "Queries places and gets metadata for Top Sites section", title: "Queries places and gets metadata for Top Sites section",
value: true value: true
} }
]) { ])) {
const pref = `feeds.${name}`; const pref = `feeds.${name}`;
FEEDS_CONFIG.set(pref, factory); FEEDS_CONFIG.set(pref, factory);
PREFS_CONFIG.set(pref, {title, value}); PREFS_CONFIG.set(pref, {title, value});
@ -135,4 +183,4 @@ this.ActivityStream = class ActivityStream {
}; };
this.PREFS_CONFIG = PREFS_CONFIG; this.PREFS_CONFIG = PREFS_CONFIG;
this.EXPORTED_SYMBOLS = ["ActivityStream"]; this.EXPORTED_SYMBOLS = ["ActivityStream", "SECTIONS"];

View File

@ -13,6 +13,8 @@ XPCOMUtils.defineLazyModuleGetter(this, "NewTabUtils",
"resource://gre/modules/NewTabUtils.jsm"); "resource://gre/modules/NewTabUtils.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "PlacesUtils", XPCOMUtils.defineLazyModuleGetter(this, "PlacesUtils",
"resource://gre/modules/PlacesUtils.jsm"); "resource://gre/modules/PlacesUtils.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "Pocket",
"chrome://pocket/content/Pocket.jsm");
const LINK_BLOCKED_EVENT = "newtab-linkBlocked"; const LINK_BLOCKED_EVENT = "newtab-linkBlocked";
@ -205,6 +207,9 @@ class PlacesFeed {
case at.DELETE_HISTORY_URL: case at.DELETE_HISTORY_URL:
NewTabUtils.activityStreamLinks.deleteHistoryEntry(action.data); NewTabUtils.activityStreamLinks.deleteHistoryEntry(action.data);
break; break;
case at.SAVE_TO_POCKET:
Pocket.savePage(action._target.browser, action.data.site.url, action.data.site.title);
break;
} }
} }
} }

View File

@ -0,0 +1,58 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";
const {utils: Cu} = Components;
Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/Console.jsm");
const {actionTypes: at, actionCreators: ac} = Cu.import("resource://activity-stream/common/Actions.jsm", {});
// Url to fetch snippets, in the urlFormatter service format.
const SNIPPETS_URL_PREF = "browser.aboutHomeSnippets.updateUrl";
// Should be bumped up if the snippets content format changes.
const STARTPAGE_VERSION = 4;
this.SnippetsFeed = class SnippetsFeed {
constructor() {
this._onUrlChange = this._onUrlChange.bind(this);
}
get snippetsURL() {
const updateURL = Services
.prefs.getStringPref(SNIPPETS_URL_PREF)
.replace("%STARTPAGE_VERSION%", STARTPAGE_VERSION);
return Services.urlFormatter.formatURL(updateURL);
}
init() {
const data = {
snippetsURL: this.snippetsURL,
version: STARTPAGE_VERSION
};
this.store.dispatch(ac.BroadcastToContent({type: at.SNIPPETS_DATA, data}));
Services.prefs.addObserver(SNIPPETS_URL_PREF, this._onUrlChange);
}
uninit() {
this.store.dispatch({type: at.SNIPPETS_RESET});
Services.prefs.removeObserver(SNIPPETS_URL_PREF, this._onUrlChange);
}
_onUrlChange() {
this.store.dispatch(ac.BroadcastToContent({
type: at.SNIPPETS_DATA,
data: {snippetsURL: this.snippetsURL}
}));
}
onAction(action) {
switch (action.type) {
case at.INIT:
this.init();
break;
case at.FEED_INIT:
if (action.data === "feeds.snippets") { this.init(); }
break;
}
}
};
this.EXPORTED_SYMBOLS = ["SnippetsFeed"];

View File

@ -9,6 +9,7 @@ const {ActivityStreamMessageChannel} = Cu.import("resource://activity-stream/lib
const {Prefs} = Cu.import("resource://activity-stream/lib/ActivityStreamPrefs.jsm", {}); const {Prefs} = Cu.import("resource://activity-stream/lib/ActivityStreamPrefs.jsm", {});
const {reducers} = Cu.import("resource://activity-stream/common/Reducers.jsm", {}); const {reducers} = Cu.import("resource://activity-stream/common/Reducers.jsm", {});
const {redux} = Cu.import("resource://activity-stream/vendor/Redux.jsm", {}); const {redux} = Cu.import("resource://activity-stream/vendor/Redux.jsm", {});
const {actionTypes: at} = Cu.import("resource://activity-stream/common/Actions.jsm", {});
/** /**
* Store - This has a similar structure to a redux store, but includes some extra * Store - This has a similar structure to a redux store, but includes some extra
@ -91,6 +92,7 @@ this.Store = class Store {
if (this._feedFactories.has(name)) { if (this._feedFactories.has(name)) {
if (value) { if (value) {
this.initFeed(name); this.initFeed(name);
this.dispatch({type: at.FEED_INIT, data: name});
} else { } else {
this.uninitFeed(name); this.uninitFeed(name);
} }

View File

@ -0,0 +1,35 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";
const {utils: Cu} = Components;
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
const {actionTypes: at} = Cu.import("resource://activity-stream/common/Actions.jsm", {});
XPCOMUtils.defineLazyModuleGetter(this, "setInterval", "resource://gre/modules/Timer.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "clearInterval", "resource://gre/modules/Timer.jsm");
// Frequency at which SYSTEM_TICK events are fired
const SYSTEM_TICK_INTERVAL = 5 * 60 * 1000;
this.SystemTickFeed = class SystemTickFeed {
init() {
this.intervalId = setInterval(() => this.store.dispatch({type: at.SYSTEM_TICK}), SYSTEM_TICK_INTERVAL);
}
onAction(action) {
switch (action.type) {
case at.INIT:
this.init();
break;
case at.UNINIT:
clearInterval(this.intervalId);
break;
}
}
};
this.SYSTEM_TICK_INTERVAL = SYSTEM_TICK_INTERVAL;
this.EXPORTED_SYMBOLS = ["SystemTickFeed", "SYSTEM_TICK_INTERVAL"];

View File

@ -0,0 +1,187 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";
const {utils: Cu} = Components;
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/NewTabUtils.jsm");
Cu.importGlobalProperties(["fetch"]);
const {actionCreators: ac, actionTypes: at} = Cu.import("resource://activity-stream/common/Actions.jsm", {});
const {Prefs} = Cu.import("resource://activity-stream/lib/ActivityStreamPrefs.jsm", {});
const STORIES_UPDATE_TIME = 30 * 60 * 1000; // 30 minutes
const TOPICS_UPDATE_TIME = 3 * 60 * 60 * 1000; // 3 hours
const SECTION_ID = "TopStories";
this.TopStoriesFeed = class TopStoriesFeed {
constructor() {
this.storiesLastUpdated = 0;
this.topicsLastUpdated = 0;
}
init() {
try {
const prefs = new Prefs();
const options = JSON.parse(prefs.get("feeds.section.topstories.options"));
const apiKey = this._getApiKeyFromPref(options.api_key_pref);
this.stories_endpoint = this._produceUrlWithApiKey(options.stories_endpoint, apiKey);
this.topics_endpoint = this._produceUrlWithApiKey(options.topics_endpoint, apiKey);
this.read_more_endpoint = options.read_more_endpoint;
// TODO https://github.com/mozilla/activity-stream/issues/2902
const sectionOptions = {
id: SECTION_ID,
icon: options.provider_icon,
title: {id: "header_recommended_by", values: {provider: options.provider_name}},
rows: [],
maxCards: 3,
contextMenuOptions: ["SaveToPocket", "Separator", "CheckBookmark", "Separator", "OpenInNewWindow", "OpenInPrivateWindow", "Separator", "BlockUrl"],
infoOption: {
header: {id: "pocket_feedback_header"},
body: {id: "pocket_feedback_body"},
link: {
href: options.survey_link,
id: "pocket_send_feedback"
}
},
emptyState: {
message: {id: "empty_state_topstories"},
icon: "check"
}
};
this.store.dispatch(ac.BroadcastToContent({type: at.SECTION_REGISTER, data: sectionOptions}));
this.fetchStories();
this.fetchTopics();
} catch (e) {
Cu.reportError(`Problem initializing top stories feed: ${e.message}`);
}
}
uninit() {
this.store.dispatch(ac.BroadcastToContent({type: at.SECTION_DEREGISTER, data: SECTION_ID}));
}
async fetchStories() {
if (this.stories_endpoint) {
const stories = await fetch(this.stories_endpoint)
.then(response => {
if (response.ok) {
return response.text();
}
throw new Error(`Stories endpoint returned unexpected status: ${response.status}`);
})
.then(body => {
let items = JSON.parse(body).list;
items = items
.filter(s => !NewTabUtils.blockedLinks.isBlocked(s.dedupe_url))
.map(s => ({
"guid": s.id,
"type": "trending",
"title": s.title,
"description": s.excerpt,
"image": this._normalizeUrl(s.image_src),
"url": s.dedupe_url,
"lastVisitDate": s.published_timestamp
}));
return items;
})
.catch(error => Cu.reportError(`Failed to fetch content: ${error.message}`));
if (stories) {
this.dispatchUpdateEvent(this.storiesLastUpdated,
{"type": at.SECTION_ROWS_UPDATE, "data": {"id": SECTION_ID, "rows": stories}});
this.storiesLastUpdated = Date.now();
}
}
}
async fetchTopics() {
if (this.topics_endpoint) {
const topics = await fetch(this.topics_endpoint)
.then(response => {
if (response.ok) {
return response.text();
}
throw new Error(`Topics endpoint returned unexpected status: ${response.status}`);
})
.then(body => JSON.parse(body).topics)
.catch(error => Cu.reportError(`Failed to fetch topics: ${error.message}`));
if (topics) {
this.dispatchUpdateEvent(this.topicsLastUpdated,
{"type": at.SECTION_ROWS_UPDATE, "data": {"id": SECTION_ID, "topics": topics, "read_more_endpoint": this.read_more_endpoint}});
this.topicsLastUpdated = Date.now();
}
}
}
dispatchUpdateEvent(lastUpdated, evt) {
if (lastUpdated === 0) {
this.store.dispatch(ac.BroadcastToContent(evt));
} else {
this.store.dispatch(evt);
}
}
_getApiKeyFromPref(apiKeyPref) {
if (!apiKeyPref) {
return apiKeyPref;
}
return new Prefs().get(apiKeyPref) || Services.prefs.getCharPref(apiKeyPref);
}
_produceUrlWithApiKey(url, apiKey) {
if (!url) {
return url;
}
if (url.includes("$apiKey") && !apiKey) {
throw new Error(`An API key was specified but none configured: ${url}`);
}
return url.replace("$apiKey", apiKey);
}
// Need to remove parenthesis from image URLs as React will otherwise
// fail to render them properly as part of the card template.
_normalizeUrl(url) {
if (url) {
return url.replace(/\(/g, "%28").replace(/\)/g, "%29");
}
return url;
}
onAction(action) {
switch (action.type) {
case at.INIT:
this.init();
break;
case at.SYSTEM_TICK:
if (Date.now() - this.storiesLastUpdated >= STORIES_UPDATE_TIME) {
this.fetchStories();
}
if (Date.now() - this.topicsLastUpdated >= TOPICS_UPDATE_TIME) {
this.fetchTopics();
}
break;
case at.UNINIT:
this.uninit();
break;
case at.FEED_INIT:
if (action.data === "feeds.section.topstories") {
this.init();
}
break;
}
}
};
this.STORIES_UPDATE_TIME = STORIES_UPDATE_TIME;
this.TOPICS_UPDATE_TIME = TOPICS_UPDATE_TIME;
this.SECTION_ID = SECTION_ID;
this.EXPORTED_SYMBOLS = ["TopStoriesFeed", "STORIES_UPDATE_TIME", "TOPICS_UPDATE_TIME", "SECTION_ID"];

View File

@ -1,5 +1,4 @@
[DEFAULT] [DEFAULT]
skip-if=!nightly_build
support-files = support-files =
blue_page.html blue_page.html

View File

@ -1,3 +0,0 @@
{
"activity_stream": true
}

View File

@ -1,5 +1,6 @@
const {reducers, INITIAL_STATE, insertPinned} = require("common/Reducers.jsm"); const {reducers, INITIAL_STATE, insertPinned} = require("common/Reducers.jsm");
const {TopSites, App, Prefs, Dialog} = reducers; const {TopSites, App, Snippets, Prefs, Dialog, Sections} = reducers;
const {actionTypes: at} = require("common/Actions.jsm"); const {actionTypes: at} = require("common/Actions.jsm");
describe("Reducers", () => { describe("Reducers", () => {
@ -77,6 +78,10 @@ describe("Reducers", () => {
// old row is unchanged // old row is unchanged
assert.equal(nextState.rows[0], oldState.rows[0]); assert.equal(nextState.rows[0], oldState.rows[0]);
}); });
it("should not update state for empty action.data on PLACES_BOOKMARK_ADDED", () => {
const nextState = TopSites(undefined, {type: at.PLACES_BOOKMARK_ADDED});
assert.equal(nextState, INITIAL_STATE.TopSites);
});
it("should remove a bookmark on PLACES_BOOKMARK_REMOVED", () => { it("should remove a bookmark on PLACES_BOOKMARK_REMOVED", () => {
const oldState = { const oldState = {
rows: [{url: "foo.com"}, { rows: [{url: "foo.com"}, {
@ -98,6 +103,10 @@ describe("Reducers", () => {
// old row is unchanged // old row is unchanged
assert.deepEqual(nextState.rows[0], oldState.rows[0]); assert.deepEqual(nextState.rows[0], oldState.rows[0]);
}); });
it("should not update state for empty action.data on PLACES_BOOKMARK_REMOVED", () => {
const nextState = TopSites(undefined, {type: at.PLACES_BOOKMARK_REMOVED});
assert.equal(nextState, INITIAL_STATE.TopSites);
});
it("should remove a link on PLACES_LINK_BLOCKED and PLACES_LINK_DELETED", () => { it("should remove a link on PLACES_LINK_BLOCKED and PLACES_LINK_DELETED", () => {
const events = [at.PLACES_LINK_BLOCKED, at.PLACES_LINK_DELETED]; const events = [at.PLACES_LINK_BLOCKED, at.PLACES_LINK_DELETED];
events.forEach(event => { events.forEach(event => {
@ -179,6 +188,70 @@ describe("Reducers", () => {
assert.deepEqual(INITIAL_STATE.Dialog, nextState); assert.deepEqual(INITIAL_STATE.Dialog, nextState);
}); });
}); });
describe("Sections", () => {
let oldState;
beforeEach(() => {
oldState = new Array(5).fill(null).map((v, i) => ({
id: `foo_bar_${i}`,
title: `Foo Bar ${i}`,
initialized: false,
rows: [{url: "www.foo.bar"}, {url: "www.other.url"}]
}));
});
it("should return INITIAL_STATE by default", () => {
assert.equal(INITIAL_STATE.Sections, Sections(undefined, {type: "non_existent"}));
});
it("should remove the correct section on SECTION_DEREGISTER", () => {
const newState = Sections(oldState, {type: at.SECTION_DEREGISTER, data: "foo_bar_2"});
assert.lengthOf(newState, 4);
const expectedNewState = oldState.splice(2, 1) && oldState;
assert.deepEqual(newState, expectedNewState);
});
it("should add a section on SECTION_REGISTER if it doesn't already exist", () => {
const action = {type: at.SECTION_REGISTER, data: {id: "foo_bar_5", title: "Foo Bar 5"}};
const newState = Sections(oldState, action);
assert.lengthOf(newState, 6);
const insertedSection = newState.find(section => section.id === "foo_bar_5");
assert.propertyVal(insertedSection, "title", action.data.title);
});
it("should set newSection.rows === [] if no rows are provided on SECTION_REGISTER", () => {
const action = {type: at.SECTION_REGISTER, data: {id: "foo_bar_5", title: "Foo Bar 5"}};
const newState = Sections(oldState, action);
const insertedSection = newState.find(section => section.id === "foo_bar_5");
assert.deepEqual(insertedSection.rows, []);
});
it("should update a section on SECTION_REGISTER if it already exists", () => {
const NEW_TITLE = "New Title";
const action = {type: at.SECTION_REGISTER, data: {id: "foo_bar_2", title: NEW_TITLE}};
const newState = Sections(oldState, action);
assert.lengthOf(newState, 5);
const updatedSection = newState.find(section => section.id === "foo_bar_2");
assert.ok(updatedSection && updatedSection.title === NEW_TITLE);
});
it("should have no effect on SECTION_ROWS_UPDATE if the id doesn't exist", () => {
const action = {type: at.SECTION_ROWS_UPDATE, data: {id: "fake_id", data: "fake_data"}};
const newState = Sections(oldState, action);
assert.deepEqual(oldState, newState);
});
it("should update the section rows with the correct data on SECTION_ROWS_UPDATE", () => {
const FAKE_DATA = ["some", "fake", "data"];
const action = {type: at.SECTION_ROWS_UPDATE, data: {id: "foo_bar_2", rows: FAKE_DATA}};
const newState = Sections(oldState, action);
const updatedSection = newState.find(section => section.id === "foo_bar_2");
assert.equal(updatedSection.rows, FAKE_DATA);
});
it("should remove blocked and deleted urls from all rows in all sections", () => {
const blockAction = {type: at.PLACES_LINK_BLOCKED, data: {url: "www.foo.bar"}};
const deleteAction = {type: at.PLACES_LINK_DELETED, data: {url: "www.foo.bar"}};
const newBlockState = Sections(oldState, blockAction);
const newDeleteState = Sections(oldState, deleteAction);
newBlockState.concat(newDeleteState).forEach(section => {
assert.deepEqual(section.rows, [{url: "www.other.url"}]);
});
});
});
describe("#insertPinned", () => { describe("#insertPinned", () => {
let links; let links;
@ -244,4 +317,23 @@ describe("Reducers", () => {
assert.equal(links.length, result.length); assert.equal(links.length, result.length);
}); });
}); });
describe("Snippets", () => {
it("should return INITIAL_STATE by default", () => {
assert.equal(Snippets(undefined, {type: "some_action"}), INITIAL_STATE.Snippets);
});
it("should set initialized to true on a SNIPPETS_DATA action", () => {
const state = Snippets(undefined, {type: at.SNIPPETS_DATA, data: {}});
assert.isTrue(state.initialized);
});
it("should set the snippet data on a SNIPPETS_DATA action", () => {
const data = {snippetsURL: "foo.com", version: 4};
const state = Snippets(undefined, {type: at.SNIPPETS_DATA, data});
assert.propertyVal(state, "snippetsURL", data.snippetsURL);
assert.propertyVal(state, "version", data.version);
});
it("should reset to the initial state on a SNIPPETS_RESET action", () => {
const state = Snippets({initalized: true, foo: "bar"}, {type: at.SNIPPETS_RESET});
assert.equal(state, INITIAL_STATE.Snippets);
});
});
}); });

View File

@ -6,17 +6,21 @@ describe("ActivityStream", () => {
let sandbox; let sandbox;
let as; let as;
let ActivityStream; let ActivityStream;
let SECTIONS;
function Fake() {} function Fake() {}
beforeEach(() => { beforeEach(() => {
sandbox = sinon.sandbox.create(); sandbox = sinon.sandbox.create();
({ActivityStream} = injector({ ({ActivityStream, SECTIONS} = injector({
"lib/LocalizationFeed.jsm": {LocalizationFeed: Fake}, "lib/LocalizationFeed.jsm": {LocalizationFeed: Fake},
"lib/NewTabInit.jsm": {NewTabInit: Fake}, "lib/NewTabInit.jsm": {NewTabInit: Fake},
"lib/PlacesFeed.jsm": {PlacesFeed: Fake}, "lib/PlacesFeed.jsm": {PlacesFeed: Fake},
"lib/TelemetryFeed.jsm": {TelemetryFeed: Fake}, "lib/TelemetryFeed.jsm": {TelemetryFeed: Fake},
"lib/TopSitesFeed.jsm": {TopSitesFeed: Fake}, "lib/TopSitesFeed.jsm": {TopSitesFeed: Fake},
"lib/PrefsFeed.jsm": {PrefsFeed: Fake} "lib/PrefsFeed.jsm": {PrefsFeed: Fake},
"lib/SnippetsFeed.jsm": {SnippetsFeed: Fake},
"lib/TopStoriesFeed.jsm": {TopStoriesFeed: Fake},
"lib/SystemTickFeed.jsm": {SystemTickFeed: Fake}
})); }));
as = new ActivityStream(); as = new ActivityStream();
sandbox.stub(as.store, "init"); sandbox.stub(as.store, "init");
@ -106,5 +110,21 @@ describe("ActivityStream", () => {
const feed = as.feeds.get("feeds.prefs")(); const feed = as.feeds.get("feeds.prefs")();
assert.instanceOf(feed, Fake); assert.instanceOf(feed, Fake);
}); });
it("should create a section feed for each section in SECTIONS", () => {
// If new sections are added, their feeds will have to be added to the
// list of injected feeds above for this test to pass
SECTIONS.forEach((value, key) => {
const feed = as.feeds.get(`feeds.section.${key}`)();
assert.instanceOf(feed, Fake);
});
});
it("should create a Snippets feed", () => {
const feed = as.feeds.get("feeds.snippets")();
assert.instanceOf(feed, Fake);
});
it("should create a SystemTick feed", () => {
const feed = as.feeds.get("feeds.systemtick")();
assert.instanceOf(feed, Fake);
});
}); });
}); });

View File

@ -28,6 +28,7 @@ describe("PlacesFeed", () => {
history: {addObserver: sandbox.spy(), removeObserver: sandbox.spy()}, history: {addObserver: sandbox.spy(), removeObserver: sandbox.spy()},
bookmarks: {TYPE_BOOKMARK, addObserver: sandbox.spy(), removeObserver: sandbox.spy()} bookmarks: {TYPE_BOOKMARK, addObserver: sandbox.spy(), removeObserver: sandbox.spy()}
}); });
globals.set("Pocket", {savePage: sandbox.spy()});
global.Components.classes["@mozilla.org/browser/nav-history-service;1"] = { global.Components.classes["@mozilla.org/browser/nav-history-service;1"] = {
getService() { getService() {
return global.PlacesUtils.history; return global.PlacesUtils.history;
@ -98,6 +99,10 @@ describe("PlacesFeed", () => {
feed.onAction({type: at.DELETE_HISTORY_URL, data: "guava.com"}); feed.onAction({type: at.DELETE_HISTORY_URL, data: "guava.com"});
assert.calledWith(global.NewTabUtils.activityStreamLinks.deleteHistoryEntry, "guava.com"); assert.calledWith(global.NewTabUtils.activityStreamLinks.deleteHistoryEntry, "guava.com");
}); });
it("should save to Pocket on SAVE_TO_POCKET", () => {
feed.onAction({type: at.SAVE_TO_POCKET, data: {site: {url: "raspberry.com", title: "raspberry"}}, _target: {browser: {}}});
assert.calledWith(global.Pocket.savePage, {}, "raspberry.com", "raspberry");
});
}); });
describe("#observe", () => { describe("#observe", () => {

View File

@ -0,0 +1,60 @@
const {SnippetsFeed} = require("lib/SnippetsFeed.jsm");
const {actionTypes: at, actionCreators: ac} = require("common/Actions.jsm");
describe("SnippetsFeed", () => {
let sandbox;
beforeEach(() => {
sandbox = sinon.sandbox.create();
});
afterEach(() => {
sandbox.restore();
});
it("should dispatch the right version and snippetsURL on INIT", () => {
const url = "foo.com/%STARTPAGE_VERSION%";
sandbox.stub(global.Services.prefs, "getStringPref").returns(url);
const feed = new SnippetsFeed();
feed.store = {dispatch: sandbox.stub()};
feed.onAction({type: at.INIT});
assert.calledWith(feed.store.dispatch, ac.BroadcastToContent({
type: at.SNIPPETS_DATA,
data: {
snippetsURL: "foo.com/4",
version: 4
}
}));
});
it("should call .init when a FEED_INIT happens for feeds.snippets", () => {
const feed = new SnippetsFeed();
sandbox.stub(feed, "init");
feed.store = {dispatch: sandbox.stub()};
feed.onAction({type: at.FEED_INIT, data: "feeds.snippets"});
assert.calledOnce(feed.init);
});
it("should dispatch a SNIPPETS_RESET on uninit", () => {
const feed = new SnippetsFeed();
feed.store = {dispatch: sandbox.stub()};
feed.uninit();
assert.calledWith(feed.store.dispatch, {type: at.SNIPPETS_RESET});
});
describe("_onUrlChange", () => {
it("should dispatch a new snippetsURL", () => {
const url = "boo.com/%STARTPAGE_VERSION%";
sandbox.stub(global.Services.prefs, "getStringPref").returns(url);
const feed = new SnippetsFeed();
feed.store = {dispatch: sandbox.stub()};
feed._onUrlChange();
assert.calledWith(feed.store.dispatch, ac.BroadcastToContent({
type: at.SNIPPETS_DATA,
data: {snippetsURL: "boo.com/4"}
}));
});
});
});

View File

@ -0,0 +1,41 @@
"use strict";
const injector = require("inject!lib/SystemTickFeed.jsm");
const {actionTypes: at} = require("common/Actions.jsm");
describe("System Tick Feed", () => {
let SystemTickFeed;
let SYSTEM_TICK_INTERVAL;
let instance;
let clock;
beforeEach(() => {
clock = sinon.useFakeTimers();
({SystemTickFeed, SYSTEM_TICK_INTERVAL} = injector({}));
instance = new SystemTickFeed();
instance.store = {getState() { return {}; }, dispatch() {}};
});
afterEach(() => {
clock.restore();
});
it("should create a SystemTickFeed", () => {
assert.instanceOf(instance, SystemTickFeed);
});
it("should fire SYSTEM_TICK events at configured interval", () => {
let expectation = sinon.mock(instance.store).expects("dispatch")
.twice()
.withExactArgs({type: at.SYSTEM_TICK});
instance.onAction({type: at.INIT});
clock.tick(SYSTEM_TICK_INTERVAL * 2);
expectation.verify();
});
it("should not fire SYSTEM_TICK events after UNINIT", () => {
let expectation = sinon.mock(instance.store).expects("dispatch")
.never();
instance.onAction({type: at.UNINIT});
clock.tick(SYSTEM_TICK_INTERVAL * 2);
expectation.verify();
});
});

View File

@ -0,0 +1,257 @@
"use strict";
const injector = require("inject!lib/TopStoriesFeed.jsm");
const {FakePrefs} = require("test/unit/utils");
const {actionCreators: ac, actionTypes: at} = require("common/Actions.jsm");
const {GlobalOverrider} = require("test/unit/utils");
describe("Top Stories Feed", () => {
let TopStoriesFeed;
let STORIES_UPDATE_TIME;
let TOPICS_UPDATE_TIME;
let SECTION_ID;
let instance;
let clock;
let globals;
beforeEach(() => {
FakePrefs.prototype.prefs["feeds.section.topstories.options"] = `{
"stories_endpoint": "https://somedomain.org/stories?key=$apiKey",
"topics_endpoint": "https://somedomain.org/topics?key=$apiKey",
"survey_link": "https://www.surveymonkey.com/r/newtabffx",
"api_key_pref": "apiKeyPref",
"provider_name": "test-provider",
"provider_icon": "provider-icon"
}`;
FakePrefs.prototype.prefs.apiKeyPref = "test-api-key";
globals = new GlobalOverrider();
clock = sinon.useFakeTimers();
({TopStoriesFeed, STORIES_UPDATE_TIME, TOPICS_UPDATE_TIME, SECTION_ID} = injector({"lib/ActivityStreamPrefs.jsm": {Prefs: FakePrefs}}));
instance = new TopStoriesFeed();
instance.store = {getState() { return {}; }, dispatch: sinon.spy()};
});
afterEach(() => {
globals.restore();
clock.restore();
});
describe("#init", () => {
it("should create a TopStoriesFeed", () => {
assert.instanceOf(instance, TopStoriesFeed);
});
it("should initialize endpoints based on prefs", () => {
instance.onAction({type: at.INIT});
assert.equal("https://somedomain.org/stories?key=test-api-key", instance.stories_endpoint);
assert.equal("https://somedomain.org/topics?key=test-api-key", instance.topics_endpoint);
});
it("should register section", () => {
const expectedSectionOptions = {
id: SECTION_ID,
icon: "provider-icon",
title: {id: "header_recommended_by", values: {provider: "test-provider"}},
rows: [],
maxCards: 3,
contextMenuOptions: ["SaveToPocket", "Separator", "CheckBookmark", "Separator", "OpenInNewWindow", "OpenInPrivateWindow", "Separator", "BlockUrl"],
infoOption: {
header: {id: "pocket_feedback_header"},
body: {id: "pocket_feedback_body"},
link: {
href: "https://www.surveymonkey.com/r/newtabffx",
id: "pocket_send_feedback"
}
},
emptyState: {
message: {id: "empty_state_topstories"},
icon: "check"
}
};
instance.onAction({type: at.INIT});
assert.calledOnce(instance.store.dispatch);
assert.propertyVal(instance.store.dispatch.firstCall.args[0], "type", at.SECTION_REGISTER);
assert.calledWith(instance.store.dispatch, ac.BroadcastToContent({
type: at.SECTION_REGISTER,
data: expectedSectionOptions
}));
});
it("should fetch stories on init", () => {
instance.fetchStories = sinon.spy();
instance.fetchTopics = sinon.spy();
instance.onAction({type: at.INIT});
assert.calledOnce(instance.fetchStories);
});
it("should fetch topics on init", () => {
instance.fetchStories = sinon.spy();
instance.fetchTopics = sinon.spy();
instance.onAction({type: at.INIT});
assert.calledOnce(instance.fetchTopics);
});
it("should not fetch if endpoint not configured", () => {
let fetchStub = globals.sandbox.stub();
globals.set("fetch", fetchStub);
FakePrefs.prototype.prefs["feeds.section.topstories.options"] = "{}";
instance.init();
assert.notCalled(fetchStub);
});
it("should report error for invalid configuration", () => {
globals.sandbox.spy(global.Components.utils, "reportError");
FakePrefs.prototype.prefs["feeds.section.topstories.options"] = "invalid";
instance.init();
assert.called(Components.utils.reportError);
});
it("should report error for missing api key", () => {
let fakeServices = {prefs: {getCharPref: sinon.spy()}};
globals.set("Services", fakeServices);
globals.sandbox.spy(global.Components.utils, "reportError");
FakePrefs.prototype.prefs["feeds.section.topstories.options"] = `{
"stories_endpoint": "https://somedomain.org/stories?key=$apiKey",
"topics_endpoint": "https://somedomain.org/topics?key=$apiKey"
}`;
instance.init();
assert.called(Components.utils.reportError);
});
it("should deregister section", () => {
instance.onAction({type: at.UNINIT});
assert.calledOnce(instance.store.dispatch);
assert.calledWith(instance.store.dispatch, ac.BroadcastToContent({
type: at.SECTION_DEREGISTER,
data: SECTION_ID
}));
});
it("should initialize on FEED_INIT", () => {
instance.init = sinon.spy();
instance.onAction({type: at.FEED_INIT, data: "feeds.section.topstories"});
assert.calledOnce(instance.init);
});
});
describe("#fetch", () => {
it("should fetch stories and send event", async () => {
let fetchStub = globals.sandbox.stub();
globals.set("fetch", fetchStub);
globals.set("NewTabUtils", {blockedLinks: {isBlocked: globals.sandbox.spy()}});
const response = `{"list": [{"id" : "1",
"title": "title",
"excerpt": "description",
"image_src": "image-url",
"dedupe_url": "rec-url",
"published_timestamp" : "123"
}]}`;
const stories = [{
"guid": "1",
"type": "trending",
"title": "title",
"description": "description",
"image": "image-url",
"url": "rec-url",
"lastVisitDate": "123"
}];
instance.stories_endpoint = "stories-endpoint";
fetchStub.resolves({ok: true, status: 200, text: () => response});
await instance.fetchStories();
assert.calledOnce(fetchStub);
assert.calledWithExactly(fetchStub, instance.stories_endpoint);
assert.calledOnce(instance.store.dispatch);
assert.propertyVal(instance.store.dispatch.firstCall.args[0], "type", at.SECTION_ROWS_UPDATE);
assert.deepEqual(instance.store.dispatch.firstCall.args[0].data.id, SECTION_ID);
assert.deepEqual(instance.store.dispatch.firstCall.args[0].data.rows, stories);
});
it("should dispatch events", () => {
instance.dispatchUpdateEvent(123, {});
assert.calledOnce(instance.store.dispatch);
});
it("should report error for unexpected stories response", async () => {
let fetchStub = globals.sandbox.stub();
globals.set("fetch", fetchStub);
globals.sandbox.spy(global.Components.utils, "reportError");
instance.stories_endpoint = "stories-endpoint";
fetchStub.resolves({ok: false, status: 400});
await instance.fetchStories();
assert.calledOnce(fetchStub);
assert.calledWithExactly(fetchStub, instance.stories_endpoint);
assert.notCalled(instance.store.dispatch);
assert.called(Components.utils.reportError);
});
it("should exclude blocked (dismissed) URLs", async () => {
let fetchStub = globals.sandbox.stub();
globals.set("fetch", fetchStub);
globals.set("NewTabUtils", {blockedLinks: {isBlocked: url => url === "blocked"}});
const response = `{"list": [{"dedupe_url" : "blocked"}, {"dedupe_url" : "not_blocked"}]}`;
instance.stories_endpoint = "stories-endpoint";
fetchStub.resolves({ok: true, status: 200, text: () => response});
await instance.fetchStories();
assert.calledOnce(instance.store.dispatch);
assert.propertyVal(instance.store.dispatch.firstCall.args[0], "type", at.SECTION_ROWS_UPDATE);
assert.equal(instance.store.dispatch.firstCall.args[0].data.rows.length, 1);
assert.equal(instance.store.dispatch.firstCall.args[0].data.rows[0].url, "not_blocked");
});
it("should fetch topics and send event", async () => {
let fetchStub = globals.sandbox.stub();
globals.set("fetch", fetchStub);
const response = `{"topics": [{"name" : "topic1", "url" : "url-topic1"}, {"name" : "topic2", "url" : "url-topic2"}]}`;
const topics = [{
"name": "topic1",
"url": "url-topic1"
}, {
"name": "topic2",
"url": "url-topic2"
}];
instance.topics_endpoint = "topics-endpoint";
fetchStub.resolves({ok: true, status: 200, text: () => response});
await instance.fetchTopics();
assert.calledOnce(fetchStub);
assert.calledWithExactly(fetchStub, instance.topics_endpoint);
assert.calledOnce(instance.store.dispatch);
assert.propertyVal(instance.store.dispatch.firstCall.args[0], "type", at.SECTION_ROWS_UPDATE);
assert.deepEqual(instance.store.dispatch.firstCall.args[0].data.id, SECTION_ID);
assert.deepEqual(instance.store.dispatch.firstCall.args[0].data.topics, topics);
});
it("should report error for unexpected topics response", async () => {
let fetchStub = globals.sandbox.stub();
globals.set("fetch", fetchStub);
globals.sandbox.spy(global.Components.utils, "reportError");
instance.topics_endpoint = "topics-endpoint";
fetchStub.resolves({ok: false, status: 400});
await instance.fetchTopics();
assert.calledOnce(fetchStub);
assert.calledWithExactly(fetchStub, instance.topics_endpoint);
assert.notCalled(instance.store.dispatch);
assert.called(Components.utils.reportError);
});
});
describe("#update", () => {
it("should fetch stories after update interval", () => {
instance.fetchStories = sinon.spy();
instance.fetchTopics = sinon.spy();
instance.onAction({type: at.SYSTEM_TICK});
assert.notCalled(instance.fetchStories);
clock.tick(STORIES_UPDATE_TIME);
instance.onAction({type: at.SYSTEM_TICK});
assert.calledOnce(instance.fetchStories);
});
it("should fetch topics after update interval", () => {
instance.fetchStories = sinon.spy();
instance.fetchTopics = sinon.spy();
instance.onAction({type: at.SYSTEM_TICK});
assert.notCalled(instance.fetchTopics);
clock.tick(TOPICS_UPDATE_TIME);
instance.onAction({type: at.SYSTEM_TICK});
assert.calledOnce(instance.fetchTopics);
});
});
});

View File

@ -24,6 +24,16 @@ describe("initStore", () => {
callback(message); callback(message);
assert.calledWith(store.dispatch, message.data); assert.calledWith(store.dispatch, message.data);
}); });
it("should log errors from failed messages", () => {
const callback = global.addMessageListener.firstCall.args[1];
globals.sandbox.stub(global.console, "error");
globals.sandbox.stub(store, "dispatch").throws(Error("failed"));
const message = {name: initStore.INCOMING_MESSAGE_NAME, data: {type: "FOO"}};
callback(message);
assert.calledOnce(global.console.error);
});
it("should replace the state if a MERGE_STORE_ACTION is dispatched", () => { it("should replace the state if a MERGE_STORE_ACTION is dispatched", () => {
store.dispatch({type: initStore.MERGE_STORE_ACTION, data: {number: 42}}); store.dispatch({type: initStore.MERGE_STORE_ACTION, data: {number: 42}});
assert.deepEqual(store.getState(), {number: 42}); assert.deepEqual(store.getState(), {number: 42});

View File

@ -29,6 +29,7 @@ overrider.set({
Preferences: FakePrefs, Preferences: FakePrefs,
Services: { Services: {
locale: {getRequestedLocale() {}}, locale: {getRequestedLocale() {}},
urlFormatter: {formatURL: str => str},
mm: { mm: {
addMessageListener: (msg, cb) => cb(), addMessageListener: (msg, cb) => cb(),
removeMessageListener() {} removeMessageListener() {}
@ -39,6 +40,8 @@ overrider.set({
removeObserver() {} removeObserver() {}
}, },
prefs: { prefs: {
addObserver() {},
removeObserver() {},
getStringPref() {}, getStringPref() {},
getDefaultBranch() { getDefaultBranch() {
return { return {