Bug 1774473 - Pocket newtab recent saves section. r=gvn

Differential Revision: https://phabricator.services.mozilla.com/D150196
This commit is contained in:
scott 2022-06-24 20:41:32 +00:00
Родитель 406fcd5384
Коммит 301b474fd4
12 изменённых файлов: 694 добавлений и 46 удалений

Просмотреть файл

@ -1537,6 +1537,7 @@ pref("browser.newtabpage.activity-stream.discoverystream.descLines", 3);
pref("browser.newtabpage.activity-stream.discoverystream.readTime.enabled", true);
pref("browser.newtabpage.activity-stream.discoverystream.newSponsoredLabel.enabled", false);
pref("browser.newtabpage.activity-stream.discoverystream.essentialReadsHeader.enabled", false);
pref("browser.newtabpage.activity-stream.discoverystream.recentSaves.enabled", false);
pref("browser.newtabpage.activity-stream.discoverystream.editorsPicksHeader.enabled", false);
pref("browser.newtabpage.activity-stream.discoverystream.spoc-positions", "1,5,7,11,18,20");
pref("browser.newtabpage.activity-stream.discoverystream.widget-positions", "");

Просмотреть файл

@ -63,6 +63,10 @@ for (const type of [
"DISCOVERY_STREAM_PERSONALIZATION_INIT",
"DISCOVERY_STREAM_PERSONALIZATION_LAST_UPDATED",
"DISCOVERY_STREAM_PERSONALIZATION_TOGGLE",
"DISCOVERY_STREAM_POCKET_STATE_INIT",
"DISCOVERY_STREAM_POCKET_STATE_SET",
"DISCOVERY_STREAM_PREFS_SETUP",
"DISCOVERY_STREAM_RECENT_SAVES",
"DISCOVERY_STREAM_RETRY_FEED",
"DISCOVERY_STREAM_SPOCS_CAPS",
"DISCOVERY_STREAM_SPOCS_ENDPOINT",

Просмотреть файл

@ -81,6 +81,9 @@ const INITIAL_STATE = {
utmCampaign: undefined,
utmContent: undefined,
},
recentSavesData: [],
isUserLoggedIn: false,
recentSavesEnabled: false,
},
Personalization: {
lastUpdated: null,
@ -639,6 +642,21 @@ function DiscoveryStream(prevState = INITIAL_STATE.DiscoveryStream, action) {
...prevState,
isCollectionDismissible: action.data.value,
};
case at.DISCOVERY_STREAM_PREFS_SETUP:
return {
...prevState,
recentSavesEnabled: action.data.recentSavesEnabled,
};
case at.DISCOVERY_STREAM_RECENT_SAVES:
return {
...prevState,
recentSavesData: action.data.recentSaves,
};
case at.DISCOVERY_STREAM_POCKET_STATE_SET:
return {
...prevState,
isUserLoggedIn: action.data.isUserLoggedIn,
};
case at.HIDE_PRIVACY_INFO:
return {
...prevState,

Просмотреть файл

@ -10,8 +10,9 @@ import {
import { DSEmptyState } from "../DSEmptyState/DSEmptyState.jsx";
import { TopicsWidget } from "../TopicsWidget/TopicsWidget.jsx";
import { FluentOrText } from "../../FluentOrText/FluentOrText.jsx";
import { actionCreators as ac } from "common/Actions.jsm";
import React from "react";
import { actionCreators as ac, actionTypes as at } from "common/Actions.jsm";
import React, { useEffect, useState, useRef, useCallback } from "react";
import { connect, useSelector } from "react-redux";
const WIDGET_IDS = {
TOPICS: 1,
};
@ -40,7 +41,133 @@ export function GridContainer(props) {
);
}
export class CardGrid extends React.PureComponent {
export function IntersectionObserver({
children,
windowObj = window,
onIntersecting,
}) {
const intersectionElement = useRef(null);
useEffect(() => {
let observer;
if (!observer && onIntersecting && intersectionElement.current) {
observer = new windowObj.IntersectionObserver(entries => {
const entry = entries.find(e => e.isIntersecting);
if (entry) {
// Stop observing since element has been seen
if (observer && intersectionElement.current) {
observer.unobserve(intersectionElement.current);
}
onIntersecting();
}
});
observer.observe(intersectionElement.current);
}
// Cleanup
return () => observer?.disconnect();
}, [windowObj, onIntersecting]);
return <div ref={intersectionElement}>{children}</div>;
}
export function RecentSavesContainer({
className,
dispatch,
windowObj = window,
items = 3,
}) {
const { recentSavesData, isUserLoggedIn } = useSelector(
state => state.DiscoveryStream
);
const [visible, setVisible] = useState(false);
const onIntersecting = useCallback(() => setVisible(true), []);
useEffect(() => {
if (visible) {
dispatch(
ac.AlsoToMain({
type: at.DISCOVERY_STREAM_POCKET_STATE_INIT,
})
);
}
}, [visible, dispatch]);
// The user has not yet scrolled to this section,
// so wait before potentially requesting Pocket data.
if (!visible) {
return (
<IntersectionObserver
windowObj={windowObj}
onIntersecting={onIntersecting}
/>
);
}
// Intersection observer has finished, but we're not yet logged in.
if (visible && !isUserLoggedIn) {
return null;
}
function renderCard(rec, index) {
return (
<DSCard
key={`dscard-${rec?.id || index}`}
id={rec.id}
pos={index}
type="CARDGRID_RECENT_SAVES"
image_src={rec.image_src}
raw_image_src={rec.raw_image_src}
word_count={rec.word_count}
time_to_read={rec.time_to_read}
title={rec.title}
excerpt={rec.excerpt}
url={rec.url}
source={rec.domain}
isRecentSave={true}
dispatch={dispatch}
/>
);
}
const recentSavesCards = [];
// We fill the cards with a for loop over an inline map because
// we want empty placeholders if there are not enough cards.
for (let index = 0; index < items; index++) {
const recentSave = recentSavesData[index];
if (!recentSave) {
recentSavesCards.push(<PlaceholderDSCard key={`dscard-${index}`} />);
} else {
recentSavesCards.push(
renderCard(
{
id: recentSave.item_id || recentSave.resolved_id,
image_src: recentSave.top_image_url,
raw_image_src: recentSave.top_image_url,
word_count: recentSave.word_count,
time_to_read: recentSave.time_to_read,
title: recentSave.resolved_title,
url: recentSave.resolved_url,
domain: recentSave.domain_metadata?.name,
excerpt: recentSave.excerpt,
},
index
)
);
}
}
// We are visible and logged in.
return (
<GridContainer className={className} header="Recently Saved to your List">
{recentSavesCards}
</GridContainer>
);
}
export class _CardGrid extends React.PureComponent {
constructor(props) {
super(props);
this.state = { moreLoaded: false };
@ -68,6 +195,8 @@ export class CardGrid extends React.PureComponent {
renderCards() {
let { items } = this.props;
const { DiscoveryStream } = this.props;
const { recentSavesEnabled } = DiscoveryStream;
const {
hybridLayout,
hideCardBackground,
@ -191,17 +320,22 @@ export class CardGrid extends React.PureComponent {
}
}
let moreRecsHeader = "";
// For now this is English only.
if (essentialReadsHeader && editorsPicksHeader) {
if (recentSavesEnabled || (essentialReadsHeader && editorsPicksHeader)) {
let spliceAt = 6;
// For 4 card row layouts, second row is 8 cards, and regular it is 6 cards.
if (fourCardLayout) {
spliceAt = 8;
}
// If we have a custom header, ensure the more recs section also has a header.
moreRecsHeader = "More Recommendations";
// Put the first 2 rows into essentialReadsCards.
essentialReadsCards = [...cards.splice(0, spliceAt)];
// Put the rest into editorsPicksCards.
editorsPicksCards = [...cards.splice(0, cards.length)];
if (essentialReadsHeader && editorsPicksHeader) {
editorsPicksCards = [...cards.splice(0, cards.length)];
}
}
// Used for CSS overrides to default styling (eg: "hero")
@ -231,13 +365,21 @@ export class CardGrid extends React.PureComponent {
{essentialReadsCards}
</GridContainer>
)}
{recentSavesEnabled && (
<RecentSavesContainer
className={className}
dispatch={this.props.dispatch}
/>
)}
{editorsPicksCards?.length > 0 && (
<GridContainer className={className} header="Editors Picks">
{editorsPicksCards}
</GridContainer>
)}
{cards?.length > 0 && (
<GridContainer className={className}>{cards}</GridContainer>
<GridContainer className={className} header={moreRecsHeader}>
{cards}
</GridContainer>
)}
</>
);
@ -289,7 +431,7 @@ export class CardGrid extends React.PureComponent {
}
}
CardGrid.defaultProps = {
_CardGrid.defaultProps = {
border: `border`,
items: 4, // Number of stories to display
enable_video_playheads: false,
@ -297,3 +439,7 @@ CardGrid.defaultProps = {
saveToPocketCard: false,
loadMoreThreshold: 12,
};
export const CardGrid = connect(state => ({
DiscoveryStream: state.DiscoveryStream,
}))(_CardGrid);

Просмотреть файл

@ -85,6 +85,7 @@ export const DefaultMeta = ({
sponsor,
sponsored_by_override,
saveToPocketCard,
isRecentSave,
}) => (
<div className="meta">
<div className="info-wrap">
@ -418,6 +419,7 @@ export class _DSCard extends React.PureComponent {
titleLines = 3,
descLines = 3,
displayReadTime,
isRecentSave,
} = this.props;
const excerpt = !hideDescriptions ? this.props.excerpt : "";
@ -541,6 +543,7 @@ export class _DSCard extends React.PureComponent {
onMenuShow={this.onMenuShow}
saveToPocketCard={saveToPocketCard}
pocket_button_enabled={this.props.pocket_button_enabled}
isRecentSave={isRecentSave}
/>
</div>
</div>
@ -565,6 +568,7 @@ export class _DSCard extends React.PureComponent {
onMenuUpdate={this.onMenuUpdate}
onMenuShow={this.onMenuShow}
pocket_button_enabled={this.props.pocket_button_enabled}
isRecentSave={isRecentSave}
/>
)}
</div>

Просмотреть файл

@ -10,22 +10,28 @@ export class DSLinkMenu extends React.PureComponent {
render() {
const { index, dispatch } = this.props;
let pocketMenuOptions = [];
if (this.props.pocket_button_enabled) {
pocketMenuOptions = this.props.saveToPocketCard
? ["CheckDeleteFromPocket"]
: ["CheckSavedToPocket"];
}
const TOP_STORIES_CONTEXT_MENU_OPTIONS = [
"CheckBookmark",
"CheckArchiveFromPocket",
...pocketMenuOptions,
"Separator",
let TOP_STORIES_CONTEXT_MENU_OPTIONS = [
"OpenInNewWindow",
"OpenInPrivateWindow",
"Separator",
"BlockUrl",
...(this.props.showPrivacyInfo ? ["ShowPrivacyInfo"] : []),
];
if (!this.props.isRecentSave) {
if (this.props.pocket_button_enabled) {
pocketMenuOptions = this.props.saveToPocketCard
? ["CheckDeleteFromPocket"]
: ["CheckSavedToPocket"];
}
TOP_STORIES_CONTEXT_MENU_OPTIONS = [
"CheckBookmark",
"CheckArchiveFromPocket",
...pocketMenuOptions,
"Separator",
"OpenInNewWindow",
"OpenInPrivateWindow",
"Separator",
"BlockUrl",
...(this.props.showPrivacyInfo ? ["ShowPrivacyInfo"] : []),
];
}
const type = this.props.type || "DISCOVERY_STREAM";
const title = this.props.title || this.props.source;

Просмотреть файл

@ -103,7 +103,7 @@ const globalImportContext = typeof Window === "undefined" ? BACKGROUND_PROCESS :
const actionTypes = {};
for (const type of ["ABOUT_SPONSORED_TOP_SITES", "ADDONS_INFO_REQUEST", "ADDONS_INFO_RESPONSE", "ARCHIVE_FROM_POCKET", "AS_ROUTER_INITIALIZED", "AS_ROUTER_PREF_CHANGED", "AS_ROUTER_TARGETING_UPDATE", "AS_ROUTER_TELEMETRY_USER_EVENT", "BLOCK_URL", "BOOKMARK_URL", "CLEAR_PREF", "COPY_DOWNLOAD_LINK", "DELETE_BOOKMARK_BY_ID", "DELETE_FROM_POCKET", "DELETE_HISTORY_URL", "DIALOG_CANCEL", "DIALOG_OPEN", "DISABLE_SEARCH", "DISCOVERY_STREAM_COLLECTION_DISMISSIBLE_TOGGLE", "DISCOVERY_STREAM_CONFIG_CHANGE", "DISCOVERY_STREAM_CONFIG_RESET", "DISCOVERY_STREAM_CONFIG_RESET_DEFAULTS", "DISCOVERY_STREAM_CONFIG_SETUP", "DISCOVERY_STREAM_CONFIG_SET_VALUE", "DISCOVERY_STREAM_DEV_EXPIRE_CACHE", "DISCOVERY_STREAM_DEV_IDLE_DAILY", "DISCOVERY_STREAM_DEV_SYNC_RS", "DISCOVERY_STREAM_DEV_SYSTEM_TICK", "DISCOVERY_STREAM_EXPERIMENT_DATA", "DISCOVERY_STREAM_FEEDS_UPDATE", "DISCOVERY_STREAM_FEED_UPDATE", "DISCOVERY_STREAM_IMPRESSION_STATS", "DISCOVERY_STREAM_LAYOUT_RESET", "DISCOVERY_STREAM_LAYOUT_UPDATE", "DISCOVERY_STREAM_LINK_BLOCKED", "DISCOVERY_STREAM_LOADED_CONTENT", "DISCOVERY_STREAM_PERSONALIZATION_INIT", "DISCOVERY_STREAM_PERSONALIZATION_LAST_UPDATED", "DISCOVERY_STREAM_PERSONALIZATION_TOGGLE", "DISCOVERY_STREAM_RETRY_FEED", "DISCOVERY_STREAM_SPOCS_CAPS", "DISCOVERY_STREAM_SPOCS_ENDPOINT", "DISCOVERY_STREAM_SPOCS_PLACEMENTS", "DISCOVERY_STREAM_SPOCS_UPDATE", "DISCOVERY_STREAM_SPOC_BLOCKED", "DISCOVERY_STREAM_SPOC_IMPRESSION", "DOWNLOAD_CHANGED", "FAKE_FOCUS_SEARCH", "FILL_SEARCH_TERM", "HANDOFF_SEARCH_TO_AWESOMEBAR", "HIDE_PRIVACY_INFO", "INIT", "NEW_TAB_INIT", "NEW_TAB_INITIAL_STATE", "NEW_TAB_LOAD", "NEW_TAB_REHYDRATED", "NEW_TAB_STATE_REQUEST", "NEW_TAB_UNLOAD", "OPEN_DOWNLOAD_FILE", "OPEN_LINK", "OPEN_NEW_WINDOW", "OPEN_PRIVATE_WINDOW", "OPEN_WEBEXT_SETTINGS", "PARTNER_LINK_ATTRIBUTION", "PLACES_BOOKMARKS_REMOVED", "PLACES_BOOKMARK_ADDED", "PLACES_HISTORY_CLEARED", "PLACES_LINKS_CHANGED", "PLACES_LINKS_DELETED", "PLACES_LINK_BLOCKED", "PLACES_SAVED_TO_POCKET", "POCKET_CTA", "POCKET_LINK_DELETED_OR_ARCHIVED", "POCKET_LOGGED_IN", "POCKET_WAITING_FOR_SPOC", "PREFS_INITIAL_VALUES", "PREF_CHANGED", "PREVIEW_REQUEST", "PREVIEW_REQUEST_CANCEL", "PREVIEW_RESPONSE", "REMOVE_DOWNLOAD_FILE", "RICH_ICON_MISSING", "SAVE_SESSION_PERF_DATA", "SAVE_TO_POCKET", "SCREENSHOT_UPDATED", "SECTION_DEREGISTER", "SECTION_DISABLE", "SECTION_ENABLE", "SECTION_MOVE", "SECTION_OPTIONS_CHANGED", "SECTION_REGISTER", "SECTION_UPDATE", "SECTION_UPDATE_CARD", "SETTINGS_CLOSE", "SETTINGS_OPEN", "SET_PREF", "SHOW_DOWNLOAD_FILE", "SHOW_FIREFOX_ACCOUNTS", "SHOW_PRIVACY_INFO", "SHOW_SEARCH", "SKIPPED_SIGNIN", "SNIPPETS_BLOCKLIST_CLEARED", "SNIPPETS_BLOCKLIST_UPDATED", "SNIPPETS_DATA", "SNIPPETS_PREVIEW_MODE", "SNIPPETS_RESET", "SNIPPET_BLOCKED", "SUBMIT_EMAIL", "SUBMIT_SIGNIN", "SYSTEM_TICK", "TELEMETRY_IMPRESSION_STATS", "TELEMETRY_USER_EVENT", "TOP_SITES_CANCEL_EDIT", "TOP_SITES_CLOSE_SEARCH_SHORTCUTS_MODAL", "TOP_SITES_EDIT", "TOP_SITES_IMPRESSION_STATS", "TOP_SITES_INSERT", "TOP_SITES_OPEN_SEARCH_SHORTCUTS_MODAL", "TOP_SITES_PIN", "TOP_SITES_PREFS_UPDATED", "TOP_SITES_UNPIN", "TOP_SITES_UPDATED", "TOTAL_BOOKMARKS_REQUEST", "TOTAL_BOOKMARKS_RESPONSE", "UNINIT", "UPDATE_PINNED_SEARCH_SHORTCUTS", "UPDATE_SEARCH_SHORTCUTS", "UPDATE_SECTION_PREFS", "WEBEXT_CLICK", "WEBEXT_DISMISS"]) {
for (const type of ["ABOUT_SPONSORED_TOP_SITES", "ADDONS_INFO_REQUEST", "ADDONS_INFO_RESPONSE", "ARCHIVE_FROM_POCKET", "AS_ROUTER_INITIALIZED", "AS_ROUTER_PREF_CHANGED", "AS_ROUTER_TARGETING_UPDATE", "AS_ROUTER_TELEMETRY_USER_EVENT", "BLOCK_URL", "BOOKMARK_URL", "CLEAR_PREF", "COPY_DOWNLOAD_LINK", "DELETE_BOOKMARK_BY_ID", "DELETE_FROM_POCKET", "DELETE_HISTORY_URL", "DIALOG_CANCEL", "DIALOG_OPEN", "DISABLE_SEARCH", "DISCOVERY_STREAM_COLLECTION_DISMISSIBLE_TOGGLE", "DISCOVERY_STREAM_CONFIG_CHANGE", "DISCOVERY_STREAM_CONFIG_RESET", "DISCOVERY_STREAM_CONFIG_RESET_DEFAULTS", "DISCOVERY_STREAM_CONFIG_SETUP", "DISCOVERY_STREAM_CONFIG_SET_VALUE", "DISCOVERY_STREAM_DEV_EXPIRE_CACHE", "DISCOVERY_STREAM_DEV_IDLE_DAILY", "DISCOVERY_STREAM_DEV_SYNC_RS", "DISCOVERY_STREAM_DEV_SYSTEM_TICK", "DISCOVERY_STREAM_EXPERIMENT_DATA", "DISCOVERY_STREAM_FEEDS_UPDATE", "DISCOVERY_STREAM_FEED_UPDATE", "DISCOVERY_STREAM_IMPRESSION_STATS", "DISCOVERY_STREAM_LAYOUT_RESET", "DISCOVERY_STREAM_LAYOUT_UPDATE", "DISCOVERY_STREAM_LINK_BLOCKED", "DISCOVERY_STREAM_LOADED_CONTENT", "DISCOVERY_STREAM_PERSONALIZATION_INIT", "DISCOVERY_STREAM_PERSONALIZATION_LAST_UPDATED", "DISCOVERY_STREAM_PERSONALIZATION_TOGGLE", "DISCOVERY_STREAM_POCKET_STATE_INIT", "DISCOVERY_STREAM_POCKET_STATE_SET", "DISCOVERY_STREAM_PREFS_SETUP", "DISCOVERY_STREAM_RECENT_SAVES", "DISCOVERY_STREAM_RETRY_FEED", "DISCOVERY_STREAM_SPOCS_CAPS", "DISCOVERY_STREAM_SPOCS_ENDPOINT", "DISCOVERY_STREAM_SPOCS_PLACEMENTS", "DISCOVERY_STREAM_SPOCS_UPDATE", "DISCOVERY_STREAM_SPOC_BLOCKED", "DISCOVERY_STREAM_SPOC_IMPRESSION", "DOWNLOAD_CHANGED", "FAKE_FOCUS_SEARCH", "FILL_SEARCH_TERM", "HANDOFF_SEARCH_TO_AWESOMEBAR", "HIDE_PRIVACY_INFO", "INIT", "NEW_TAB_INIT", "NEW_TAB_INITIAL_STATE", "NEW_TAB_LOAD", "NEW_TAB_REHYDRATED", "NEW_TAB_STATE_REQUEST", "NEW_TAB_UNLOAD", "OPEN_DOWNLOAD_FILE", "OPEN_LINK", "OPEN_NEW_WINDOW", "OPEN_PRIVATE_WINDOW", "OPEN_WEBEXT_SETTINGS", "PARTNER_LINK_ATTRIBUTION", "PLACES_BOOKMARKS_REMOVED", "PLACES_BOOKMARK_ADDED", "PLACES_HISTORY_CLEARED", "PLACES_LINKS_CHANGED", "PLACES_LINKS_DELETED", "PLACES_LINK_BLOCKED", "PLACES_SAVED_TO_POCKET", "POCKET_CTA", "POCKET_LINK_DELETED_OR_ARCHIVED", "POCKET_LOGGED_IN", "POCKET_WAITING_FOR_SPOC", "PREFS_INITIAL_VALUES", "PREF_CHANGED", "PREVIEW_REQUEST", "PREVIEW_REQUEST_CANCEL", "PREVIEW_RESPONSE", "REMOVE_DOWNLOAD_FILE", "RICH_ICON_MISSING", "SAVE_SESSION_PERF_DATA", "SAVE_TO_POCKET", "SCREENSHOT_UPDATED", "SECTION_DEREGISTER", "SECTION_DISABLE", "SECTION_ENABLE", "SECTION_MOVE", "SECTION_OPTIONS_CHANGED", "SECTION_REGISTER", "SECTION_UPDATE", "SECTION_UPDATE_CARD", "SETTINGS_CLOSE", "SETTINGS_OPEN", "SET_PREF", "SHOW_DOWNLOAD_FILE", "SHOW_FIREFOX_ACCOUNTS", "SHOW_PRIVACY_INFO", "SHOW_SEARCH", "SKIPPED_SIGNIN", "SNIPPETS_BLOCKLIST_CLEARED", "SNIPPETS_BLOCKLIST_UPDATED", "SNIPPETS_DATA", "SNIPPETS_PREVIEW_MODE", "SNIPPETS_RESET", "SNIPPET_BLOCKED", "SUBMIT_EMAIL", "SUBMIT_SIGNIN", "SYSTEM_TICK", "TELEMETRY_IMPRESSION_STATS", "TELEMETRY_USER_EVENT", "TOP_SITES_CANCEL_EDIT", "TOP_SITES_CLOSE_SEARCH_SHORTCUTS_MODAL", "TOP_SITES_EDIT", "TOP_SITES_IMPRESSION_STATS", "TOP_SITES_INSERT", "TOP_SITES_OPEN_SEARCH_SHORTCUTS_MODAL", "TOP_SITES_PIN", "TOP_SITES_PREFS_UPDATED", "TOP_SITES_UNPIN", "TOP_SITES_UPDATED", "TOTAL_BOOKMARKS_REQUEST", "TOTAL_BOOKMARKS_RESPONSE", "UNINIT", "UPDATE_PINNED_SEARCH_SHORTCUTS", "UPDATE_SEARCH_SHORTCUTS", "UPDATE_SECTION_PREFS", "WEBEXT_CLICK", "WEBEXT_DISMISS"]) {
actionTypes[type] = type;
} // Helper function for creating routed actions between content and main
// Not intended to be used by consumers
@ -6864,12 +6864,16 @@ class DSLinkMenu extends (external_React_default()).PureComponent {
dispatch
} = this.props;
let pocketMenuOptions = [];
let TOP_STORIES_CONTEXT_MENU_OPTIONS = ["OpenInNewWindow", "OpenInPrivateWindow"];
if (this.props.pocket_button_enabled) {
pocketMenuOptions = this.props.saveToPocketCard ? ["CheckDeleteFromPocket"] : ["CheckSavedToPocket"];
if (!this.props.isRecentSave) {
if (this.props.pocket_button_enabled) {
pocketMenuOptions = this.props.saveToPocketCard ? ["CheckDeleteFromPocket"] : ["CheckSavedToPocket"];
}
TOP_STORIES_CONTEXT_MENU_OPTIONS = ["CheckBookmark", "CheckArchiveFromPocket", ...pocketMenuOptions, "Separator", "OpenInNewWindow", "OpenInPrivateWindow", "Separator", "BlockUrl", ...(this.props.showPrivacyInfo ? ["ShowPrivacyInfo"] : [])];
}
const TOP_STORIES_CONTEXT_MENU_OPTIONS = ["CheckBookmark", "CheckArchiveFromPocket", ...pocketMenuOptions, "Separator", "OpenInNewWindow", "OpenInPrivateWindow", "Separator", "BlockUrl", ...(this.props.showPrivacyInfo ? ["ShowPrivacyInfo"] : [])];
const type = this.props.type || "DISCOVERY_STREAM";
const title = this.props.title || this.props.source;
return /*#__PURE__*/external_React_default().createElement("div", {
@ -7485,7 +7489,8 @@ const DefaultMeta = ({
cta_variant,
sponsor,
sponsored_by_override,
saveToPocketCard
saveToPocketCard,
isRecentSave
}) => /*#__PURE__*/external_React_default().createElement("div", {
className: "meta"
}, /*#__PURE__*/external_React_default().createElement("div", {
@ -7779,7 +7784,8 @@ class _DSCard extends (external_React_default()).PureComponent {
imageGradient,
titleLines = 3,
descLines = 3,
displayReadTime
displayReadTime,
isRecentSave
} = this.props;
const excerpt = !hideDescriptions ? this.props.excerpt : "";
let timeToRead;
@ -7879,7 +7885,8 @@ class _DSCard extends (external_React_default()).PureComponent {
onMenuUpdate: this.onMenuUpdate,
onMenuShow: this.onMenuShow,
saveToPocketCard: saveToPocketCard,
pocket_button_enabled: this.props.pocket_button_enabled
pocket_button_enabled: this.props.pocket_button_enabled,
isRecentSave: isRecentSave
}))), !saveToPocketCard && /*#__PURE__*/external_React_default().createElement(DSLinkMenu, {
id: this.props.id,
index: this.props.pos,
@ -7896,7 +7903,8 @@ class _DSCard extends (external_React_default()).PureComponent {
hostRef: this.contextMenuButtonHostRef,
onMenuUpdate: this.onMenuUpdate,
onMenuShow: this.onMenuShow,
pocket_button_enabled: this.props.pocket_button_enabled
pocket_button_enabled: this.props.pocket_button_enabled,
isRecentSave: isRecentSave
}));
}
@ -8141,6 +8149,7 @@ const TopicsWidget = (0,external_ReactRedux_namespaceObject.connect)(state => ({
const WIDGET_IDS = {
TOPICS: 1
};
@ -8165,7 +8174,128 @@ function GridContainer(props) {
className: `ds-card-grid ${className}`
}, children));
}
class CardGrid extends (external_React_default()).PureComponent {
function CardGrid_IntersectionObserver({
children,
windowObj = window,
onIntersecting
}) {
const intersectionElement = (0,external_React_namespaceObject.useRef)(null);
(0,external_React_namespaceObject.useEffect)(() => {
let observer;
if (!observer && onIntersecting && intersectionElement.current) {
observer = new windowObj.IntersectionObserver(entries => {
const entry = entries.find(e => e.isIntersecting);
if (entry) {
// Stop observing since element has been seen
if (observer && intersectionElement.current) {
observer.unobserve(intersectionElement.current);
}
onIntersecting();
}
});
observer.observe(intersectionElement.current);
} // Cleanup
return () => {
var _observer;
return (_observer = observer) === null || _observer === void 0 ? void 0 : _observer.disconnect();
};
}, [windowObj, onIntersecting]);
return /*#__PURE__*/external_React_default().createElement("div", {
ref: intersectionElement
}, children);
}
function RecentSavesContainer({
className,
dispatch,
windowObj = window,
items = 3
}) {
const {
recentSavesData,
isUserLoggedIn
} = (0,external_ReactRedux_namespaceObject.useSelector)(state => state.DiscoveryStream);
const [visible, setVisible] = (0,external_React_namespaceObject.useState)(false);
const onIntersecting = (0,external_React_namespaceObject.useCallback)(() => setVisible(true), []);
(0,external_React_namespaceObject.useEffect)(() => {
if (visible) {
dispatch(actionCreators.AlsoToMain({
type: actionTypes.DISCOVERY_STREAM_POCKET_STATE_INIT
}));
}
}, [visible, dispatch]); // The user has not yet scrolled to this section,
// so wait before potentially requesting Pocket data.
if (!visible) {
return /*#__PURE__*/external_React_default().createElement(CardGrid_IntersectionObserver, {
windowObj: windowObj,
onIntersecting: onIntersecting
});
} // Intersection observer has finished, but we're not yet logged in.
if (visible && !isUserLoggedIn) {
return null;
}
function renderCard(rec, index) {
return /*#__PURE__*/external_React_default().createElement(DSCard, {
key: `dscard-${(rec === null || rec === void 0 ? void 0 : rec.id) || index}`,
id: rec.id,
pos: index,
type: "CARDGRID_RECENT_SAVES",
image_src: rec.image_src,
raw_image_src: rec.raw_image_src,
word_count: rec.word_count,
time_to_read: rec.time_to_read,
title: rec.title,
excerpt: rec.excerpt,
url: rec.url,
source: rec.domain,
isRecentSave: true,
dispatch: dispatch
});
}
const recentSavesCards = []; // We fill the cards with a for loop over an inline map because
// we want empty placeholders if there are not enough cards.
for (let index = 0; index < items; index++) {
const recentSave = recentSavesData[index];
if (!recentSave) {
recentSavesCards.push( /*#__PURE__*/external_React_default().createElement(PlaceholderDSCard, {
key: `dscard-${index}`
}));
} else {
var _recentSave$domain_me;
recentSavesCards.push(renderCard({
id: recentSave.item_id || recentSave.resolved_id,
image_src: recentSave.top_image_url,
raw_image_src: recentSave.top_image_url,
word_count: recentSave.word_count,
time_to_read: recentSave.time_to_read,
title: recentSave.resolved_title,
url: recentSave.resolved_url,
domain: (_recentSave$domain_me = recentSave.domain_metadata) === null || _recentSave$domain_me === void 0 ? void 0 : _recentSave$domain_me.name,
excerpt: recentSave.excerpt
}, index));
}
} // We are visible and logged in.
return /*#__PURE__*/external_React_default().createElement(GridContainer, {
className: className,
header: "Recently Saved to your List"
}, recentSavesCards);
}
class _CardGrid extends (external_React_default()).PureComponent {
constructor(props) {
super(props);
this.state = {
@ -8199,6 +8329,12 @@ class CardGrid extends (external_React_default()).PureComponent {
let {
items
} = this.props;
const {
DiscoveryStream
} = this.props;
const {
recentSavesEnabled
} = DiscoveryStream;
const {
hybridLayout,
hideCardBackground,
@ -8312,20 +8448,25 @@ class CardGrid extends (external_React_default()).PureComponent {
cards.splice(position.index, 1, widgetComponent);
}
}
} // For now this is English only.
}
let moreRecsHeader = ""; // For now this is English only.
if (essentialReadsHeader && editorsPicksHeader) {
if (recentSavesEnabled || essentialReadsHeader && editorsPicksHeader) {
let spliceAt = 6; // For 4 card row layouts, second row is 8 cards, and regular it is 6 cards.
if (fourCardLayout) {
spliceAt = 8;
} // Put the first 2 rows into essentialReadsCards.
} // If we have a custom header, ensure the more recs section also has a header.
moreRecsHeader = "More Recommendations"; // Put the first 2 rows into essentialReadsCards.
essentialReadsCards = [...cards.splice(0, spliceAt)]; // Put the rest into editorsPicksCards.
editorsPicksCards = [...cards.splice(0, cards.length)];
if (essentialReadsHeader && editorsPicksHeader) {
editorsPicksCards = [...cards.splice(0, cards.length)];
}
} // Used for CSS overrides to default styling (eg: "hero")
@ -8338,11 +8479,15 @@ class CardGrid extends (external_React_default()).PureComponent {
const className = `ds-card-grid-${this.props.border} ${variantClass} ${hybridLayoutClassName} ${hideCardBackgroundClass} ${fourCardLayoutClass} ${hideDescriptionsClassName} ${compactGridClassName}`;
return /*#__PURE__*/external_React_default().createElement((external_React_default()).Fragment, null, ((_essentialReadsCards = essentialReadsCards) === null || _essentialReadsCards === void 0 ? void 0 : _essentialReadsCards.length) > 0 && /*#__PURE__*/external_React_default().createElement(GridContainer, {
className: className
}, essentialReadsCards), ((_editorsPicksCards = editorsPicksCards) === null || _editorsPicksCards === void 0 ? void 0 : _editorsPicksCards.length) > 0 && /*#__PURE__*/external_React_default().createElement(GridContainer, {
}, essentialReadsCards), recentSavesEnabled && /*#__PURE__*/external_React_default().createElement(RecentSavesContainer, {
className: className,
dispatch: this.props.dispatch
}), ((_editorsPicksCards = editorsPicksCards) === null || _editorsPicksCards === void 0 ? void 0 : _editorsPicksCards.length) > 0 && /*#__PURE__*/external_React_default().createElement(GridContainer, {
className: className,
header: "Editor\u2019s Picks"
}, editorsPicksCards), (cards === null || cards === void 0 ? void 0 : cards.length) > 0 && /*#__PURE__*/external_React_default().createElement(GridContainer, {
className: className
className: className,
header: moreRecsHeader
}, cards));
}
@ -8379,7 +8524,7 @@ class CardGrid extends (external_React_default()).PureComponent {
}
}
CardGrid.defaultProps = {
_CardGrid.defaultProps = {
border: `border`,
items: 4,
// Number of stories to display
@ -8388,6 +8533,9 @@ CardGrid.defaultProps = {
saveToPocketCard: false,
loadMoreThreshold: 12
};
const CardGrid = (0,external_ReactRedux_namespaceObject.connect)(state => ({
DiscoveryStream: state.DiscoveryStream
}))(_CardGrid);
;// CONCATENATED MODULE: ./content-src/components/DiscoveryStreamComponents/DSDismiss/DSDismiss.jsx
/* 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,
@ -10335,7 +10483,10 @@ const INITIAL_STATE = {
utmSource: "pocket-newtab",
utmCampaign: undefined,
utmContent: undefined
}
},
recentSavesData: [],
isUserLoggedIn: false,
recentSavesEnabled: false
},
Personalization: {
lastUpdated: null,
@ -10980,6 +11131,21 @@ function DiscoveryStream(prevState = INITIAL_STATE.DiscoveryStream, action) {
isCollectionDismissible: action.data.value
};
case actionTypes.DISCOVERY_STREAM_PREFS_SETUP:
return { ...prevState,
recentSavesEnabled: action.data.recentSavesEnabled
};
case actionTypes.DISCOVERY_STREAM_RECENT_SAVES:
return { ...prevState,
recentSavesData: action.data.recentSaves
};
case actionTypes.DISCOVERY_STREAM_POCKET_STATE_SET:
return { ...prevState,
isUserLoggedIn: action.data.isUserLoggedIn
};
case actionTypes.HIDE_PRIVACY_INFO:
return { ...prevState,
isPrivacyInfoModalVisible: false

Просмотреть файл

@ -14,6 +14,11 @@ ChromeUtils.defineModuleGetter(
"RemoteSettings",
"resource://services-settings/remote-settings.js"
);
ChromeUtils.defineModuleGetter(
lazy,
"pktApi",
"chrome://pocket/content/pktApi.jsm"
);
const { setTimeout, clearTimeout } = ChromeUtils.import(
"resource://gre/modules/Timer.jsm"
);
@ -234,6 +239,18 @@ class DiscoveryStreamFeed {
},
})
);
const nimbusConfig = this.store.getState().Prefs.values?.pocketConfig || {};
this.store.dispatch(
ac.BroadcastToContent({
type: at.DISCOVERY_STREAM_PREFS_SETUP,
data: {
recentSavesEnabled: nimbusConfig.recentSavesEnabled,
},
meta: {
isStartup,
},
})
);
this.store.dispatch(
ac.BroadcastToContent({
type: at.DISCOVERY_STREAM_COLLECTION_DISMISSIBLE_TOGGLE,
@ -249,6 +266,45 @@ class DiscoveryStreamFeed {
);
}
async setupPocketState(target) {
let dispatch = action =>
this.store.dispatch(ac.OnlyToOneContent(action, target));
const isUserLoggedIn = lazy.pktApi.isUserLoggedIn();
dispatch({
type: at.DISCOVERY_STREAM_POCKET_STATE_SET,
data: {
isUserLoggedIn,
},
});
// If we're not logged in, don't bother fetching recent saves, we're done.
if (isUserLoggedIn) {
let recentSaves = await lazy.pktApi.getRecentSavesCache();
if (recentSaves) {
// We have cache, so we can use those.
dispatch({
type: at.DISCOVERY_STREAM_RECENT_SAVES,
data: {
recentSaves,
},
});
} else {
// We don't have cache, so fetch fresh stories.
lazy.pktApi.getRecentSaves({
success(data) {
dispatch({
type: at.DISCOVERY_STREAM_RECENT_SAVES,
data: {
recentSaves: data,
},
});
},
error(error) {},
});
}
}
}
uninitPrefs() {
// Reset in-memory cache
this._prefCache = {};
@ -1730,7 +1786,9 @@ class DiscoveryStreamFeed {
)
);
break;
case at.DISCOVERY_STREAM_POCKET_STATE_INIT:
this.setupPocketState(action.meta.fromTarget);
break;
case at.DISCOVERY_STREAM_CONFIG_RESET:
// This is a generic config reset likely related to an external feed pref.
this.configReset();

Просмотреть файл

@ -982,6 +982,27 @@ describe("Reducers", () => {
});
assert.deepEqual(state.config, { enabled: true });
});
it("should set recentSavesEnabled with DISCOVERY_STREAM_PREFS_SETUP", () => {
const state = DiscoveryStream(undefined, {
type: at.DISCOVERY_STREAM_PREFS_SETUP,
data: { recentSavesEnabled: true },
});
assert.isTrue(state.recentSavesEnabled);
});
it("should set recentSavesData with DISCOVERY_STREAM_RECENT_SAVES", () => {
const state = DiscoveryStream(undefined, {
type: at.DISCOVERY_STREAM_RECENT_SAVES,
data: { recentSaves: [1, 2, 3] },
});
assert.deepEqual(state.recentSavesData, [1, 2, 3]);
});
it("should set isUserLoggedIn with DISCOVERY_STREAM_POCKET_STATE_SET", () => {
const state = DiscoveryStream(undefined, {
type: at.DISCOVERY_STREAM_POCKET_STATE_SET,
data: { isUserLoggedIn: true },
});
assert.isTrue(state.isUserLoggedIn);
});
it("should set feeds as loaded with DISCOVERY_STREAM_FEEDS_UPDATE", () => {
const state = DiscoveryStream(undefined, {
type: at.DISCOVERY_STREAM_FEEDS_UPDATE,

Просмотреть файл

@ -1,5 +1,7 @@
import {
CardGrid,
_CardGrid as CardGrid,
IntersectionObserver,
RecentSavesContainer,
DSSubHeader,
GridContainer,
} from "content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid";
@ -8,18 +10,28 @@ import { INITIAL_STATE, reducers } from "common/Reducers.jsm";
import { Provider } from "react-redux";
import {
DSCard,
PlaceholderDSCard,
LastCardMessage,
} from "content-src/components/DiscoveryStreamComponents/DSCard/DSCard";
import { TopicsWidget } from "content-src/components/DiscoveryStreamComponents/TopicsWidget/TopicsWidget";
import { actionCreators as ac } from "common/Actions.jsm";
import { actionCreators as ac, actionTypes as at } from "common/Actions.jsm";
import React from "react";
import { shallow, mount } from "enzyme";
// Wrap this around any component that uses useSelector,
// or any mount that uses a child that uses redux.
function WrapWithProvider({ children, state = INITIAL_STATE }) {
let store = createStore(combineReducers(reducers), state);
return <Provider store={store}>{children}</Provider>;
}
describe("<CardGrid>", () => {
let wrapper;
beforeEach(() => {
wrapper = shallow(<CardGrid />);
wrapper = shallow(
<CardGrid DiscoveryStream={INITIAL_STATE.DiscoveryStream} />
);
});
it("should render an empty div", () => {
@ -69,9 +81,8 @@ describe("<CardGrid>", () => {
});
it("should render sub header in the middle of the card grid for both regular and compact", () => {
let store = createStore(combineReducers(reducers), INITIAL_STATE);
wrapper = mount(
<Provider store={store}>
<WrapWithProvider>
<CardGrid
essentialReadsHeader={true}
editorsPicksHeader={true}
@ -79,8 +90,9 @@ describe("<CardGrid>", () => {
data={{
recommendations: [{}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}],
}}
DiscoveryStream={INITIAL_STATE.DiscoveryStream}
/>
</Provider>
</WrapWithProvider>
);
assert.ok(wrapper.find(DSSubHeader).exists());
@ -89,7 +101,7 @@ describe("<CardGrid>", () => {
compact: true,
});
wrapper = mount(
<Provider store={store}>
<WrapWithProvider>
<CardGrid
essentialReadsHeader={true}
editorsPicksHeader={true}
@ -98,8 +110,9 @@ describe("<CardGrid>", () => {
data={{
recommendations: [{}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}],
}}
DiscoveryStream={INITIAL_STATE.DiscoveryStream}
/>
</Provider>
</WrapWithProvider>
);
assert.ok(wrapper.find(DSSubHeader).exists());
@ -184,3 +197,132 @@ describe("<CardGrid>", () => {
assert.ok(wrapper.find(TopicsWidget).exists());
});
});
// Build IntersectionObserver class with the arg `entries` for the intersect callback.
function buildIntersectionObserver(entries) {
return class {
constructor(callback) {
this.callback = callback;
}
observe() {
this.callback(entries);
}
unobserve() {}
disconnect() {}
};
}
describe("<IntersectionObserver>", () => {
let wrapper;
let fakeWindow;
let intersectEntries;
beforeEach(() => {
intersectEntries = [{ isIntersecting: true }];
fakeWindow = {
IntersectionObserver: buildIntersectionObserver(intersectEntries),
};
wrapper = mount(<IntersectionObserver windowObj={fakeWindow} />);
});
it("should render an empty div", () => {
assert.ok(wrapper.exists());
assert.equal(
wrapper
.children()
.at(0)
.type(),
"div"
);
});
it("should fire onIntersecting", () => {
const onIntersecting = sinon.stub();
wrapper = mount(
<IntersectionObserver
windowObj={fakeWindow}
onIntersecting={onIntersecting}
/>
);
assert.calledOnce(onIntersecting);
});
});
describe("<RecentSavesContainer>", () => {
let wrapper;
let fakeWindow;
let intersectEntries;
let dispatch;
beforeEach(() => {
dispatch = sinon.stub();
intersectEntries = [{ isIntersecting: false }];
fakeWindow = {
IntersectionObserver: buildIntersectionObserver(intersectEntries),
};
wrapper = mount(
<WrapWithProvider>
<RecentSavesContainer windowObj={fakeWindow} dispatch={dispatch} />
</WrapWithProvider>
).find(RecentSavesContainer);
});
it("should render an IntersectionObserver when not visible", () => {
assert.ok(wrapper.exists());
assert.ok(wrapper.find(IntersectionObserver).exists());
});
it("should render a nothing if visible until we log in", () => {
intersectEntries = [{ isIntersecting: true }];
fakeWindow = {
IntersectionObserver: buildIntersectionObserver(intersectEntries),
};
wrapper = mount(
<WrapWithProvider>
<RecentSavesContainer windowObj={fakeWindow} dispatch={dispatch} />
</WrapWithProvider>
).find(RecentSavesContainer);
assert.ok(!wrapper.find(IntersectionObserver).exists());
assert.calledOnce(dispatch);
assert.calledWith(
dispatch,
ac.AlsoToMain({
type: at.DISCOVERY_STREAM_POCKET_STATE_INIT,
})
);
});
it("should render a GridContainer if visible and logged in", () => {
intersectEntries = [{ isIntersecting: true }];
fakeWindow = {
IntersectionObserver: buildIntersectionObserver(intersectEntries),
};
wrapper = mount(
<WrapWithProvider
state={{
DiscoveryStream: {
isUserLoggedIn: true,
recentSavesData: [
{
resolved_id: "resolved_id",
top_image_url: "top_image_url",
title: "title",
resolved_url: "resolved_url",
domain: "domain",
excerpt: "excerpt",
},
],
},
}}
>
<RecentSavesContainer windowObj={fakeWindow} dispatch={dispatch} />
</WrapWithProvider>
).find(RecentSavesContainer);
assert.lengthOf(wrapper.find(GridContainer), 1);
assert.lengthOf(wrapper.find(PlaceholderDSCard), 2);
assert.lengthOf(wrapper.find(DSCard), 3);
});
});

Просмотреть файл

@ -31,6 +31,7 @@ describe("DiscoveryStreamFeed", () => {
let fetchStub;
let clock;
let fakeNewTabUtils;
let fakePktApi;
let globals;
const setPref = (name, value) => {
@ -109,6 +110,13 @@ describe("DiscoveryStreamFeed", () => {
},
};
globals.set("NewTabUtils", fakeNewTabUtils);
fakePktApi = {
isUserLoggedIn: () => false,
getRecentSavesCache: () => null,
getRecentSaves: () => null,
};
globals.set("pktApi", fakePktApi);
});
afterEach(() => {
@ -211,6 +219,63 @@ describe("DiscoveryStreamFeed", () => {
});
});
describe("#setupPocketState", () => {
it("should setup logged in state and recent saves with cache", async () => {
fakePktApi.isUserLoggedIn = () => true;
fakePktApi.getRecentSavesCache = () => [1, 2, 3];
sandbox.spy(feed.store, "dispatch");
await feed.setupPocketState({});
assert.calledTwice(feed.store.dispatch);
assert.calledWith(
feed.store.dispatch.firstCall,
ac.OnlyToOneContent(
{
type: at.DISCOVERY_STREAM_POCKET_STATE_SET,
data: { isUserLoggedIn: true },
},
{}
)
);
assert.calledWith(
feed.store.dispatch.secondCall,
ac.OnlyToOneContent(
{
type: at.DISCOVERY_STREAM_RECENT_SAVES,
data: { recentSaves: [1, 2, 3] },
},
{}
)
);
});
it("should setup logged in state and recent saves without cache", async () => {
fakePktApi.isUserLoggedIn = () => true;
fakePktApi.getRecentSaves = ({ success }) => success([1, 2, 3]);
sandbox.spy(feed.store, "dispatch");
await feed.setupPocketState({});
assert.calledTwice(feed.store.dispatch);
assert.calledWith(
feed.store.dispatch.firstCall,
ac.OnlyToOneContent(
{
type: at.DISCOVERY_STREAM_POCKET_STATE_SET,
data: { isUserLoggedIn: true },
},
{}
)
);
assert.calledWith(
feed.store.dispatch.secondCall,
ac.OnlyToOneContent(
{
type: at.DISCOVERY_STREAM_RECENT_SAVES,
data: { recentSaves: [1, 2, 3] },
},
{}
)
);
});
});
describe("#getOrCreateImpressionId", () => {
it("should create impression id in constructor", async () => {
assert.equal(feed._impressionId, FAKE_UUID);
@ -2002,6 +2067,17 @@ describe("DiscoveryStreamFeed", () => {
});
});
describe("#onAction: DISCOVERY_STREAM_POCKET_STATE_INIT", async () => {
it("should call setupPocketState", async () => {
sandbox.spy(feed, "setupPocketState");
feed.onAction({
type: at.DISCOVERY_STREAM_POCKET_STATE_INIT,
meta: { fromTarget: {} },
});
assert.calledOnce(feed.setupPocketState);
});
});
describe("#onAction: DISCOVERY_STREAM_CONFIG_RESET", async () => {
it("should call configReset", async () => {
sandbox.spy(feed, "configReset");

Просмотреть файл

@ -335,6 +335,12 @@ pocketNewtab:
description: >-
Updates the Pocket section header and title to say "Editors Picks", if used with
essentialReadsHeader, creates a second section 2 rows down for editorsPicksHeader.
recentSavesEnabled:
type: boolean
fallbackPref: >-
browser.newtabpage.activity-stream.discoverystream.recentSaves.enabled
description: >-
Updates the Pocket section with a new header and 1 row of recently saved Pocket stories.
readTime:
type: boolean
fallbackPref: >-