feat(sections): Add section ordering, and custom sections to the pref pane (#3216)
Move TopStories config out of TopStoriesFeed and PreferencesPane and into SectionsManager.jsm. Underlying work for #3194.
This commit is contained in:
Родитель
36e9a79e13
Коммит
d24ebe2968
|
@ -1,66 +1,65 @@
|
|||
# Sections in Activity Stream
|
||||
|
||||
Each section in Activity Stream displays data from a corresponding section feed
|
||||
in a standardised `Section` UI component. Section feeds are responsible for
|
||||
registering/deregistering their UI component and supplying rows data (the sites
|
||||
for the section to display).
|
||||
|
||||
Activity Stream loads sections from the `SECTIONS` map in `ActivityStream.jsm`.
|
||||
Configuration objects must be keyed by a unique section id and have the
|
||||
properties `{feed, showByDefault}`, where `feed` is the section feed class.
|
||||
in a standardised `Section` UI component. Each section feed is responsible for
|
||||
listening to events and updating the section options such as the title, icon,
|
||||
and rows (the cards for the section to display).
|
||||
|
||||
The `Section` UI component displays the rows provided by the section feed. If no
|
||||
rows are available it displays an empty state consisting of an icon and a
|
||||
message. Optionally, the section may have a info option menu that is displayed
|
||||
when users hover over the info icon.
|
||||
|
||||
On load, `SectionsManager` and `SectionsFeed` in `SectionsManager.jsm` add the
|
||||
sections configured in the `BUILT_IN_SECTIONS` map to the state. These sections
|
||||
are initially disabled, so aren't visible. The section's feed may use the
|
||||
methods provided by the `SectionsManager` to enable its section and update its
|
||||
properties.
|
||||
|
||||
The section configuration in `BUILT_IN_SECTIONS` constists of a generator
|
||||
function keyed by the pref name for the section feed. The generator function
|
||||
takes an `options` argument as the only parameter, which is passed the object
|
||||
stored as serialised JSON in the pref `{feed_pref_name}.options`, or the empty
|
||||
object if this doesn't exist. The generator returns a section configuration
|
||||
object which may have the following properties:
|
||||
|
||||
Property | Type | Description
|
||||
--- | --- | ---
|
||||
id | String | Non-optional unique id.
|
||||
title | Localisation object | Has property `id`, the string localisation id, and optionaly a `values` object to fill in placeholders.
|
||||
icon | String | Icon id. New icons should be added in icons.scss.
|
||||
maxRows | Integer | Maximum number of rows of cards to display. Should be >= 1.
|
||||
contextMenuOptions | Array of strings | The menu options to provide in the card context menus.
|
||||
shouldHidePref | Boolean | If true, will the section preference in the preferences pane will not be shown.
|
||||
pref | Object | Configures the section preference to show in the preferences pane. Has properties `titleString` and `descString`.
|
||||
emptyState | Object | Configures the empty state of the section. Has properties `message` and `icon`.
|
||||
infoOption | Object | Configures the info option. Has properties `header`, `body`, and `link`.
|
||||
|
||||
## Section feeds
|
||||
|
||||
Each section feed is given the pref
|
||||
`{Activity Stream pref branch}.feeds.section.{section_id}`. This pref turns the
|
||||
section feed on and off.
|
||||
Each section feed should be controlled by the pref `feeds.section.{section_id}`.
|
||||
|
||||
### Registering the section
|
||||
### Enabling the section
|
||||
|
||||
The section feed must listen for the events `INIT` (dispatched
|
||||
when Activity Stream is initialised) and `FEED_INIT` (dispatched when a feed is
|
||||
re-enabled having been turned off, with the feed id as the `data`) and respond
|
||||
by dispatching a `SECTION_REGISTER` action to enable the section's UI component.
|
||||
The action's `data` object should have the following properties:
|
||||
The section feed must listen for the events `INIT` (dispatched when Activity
|
||||
Stream is initialised) and `FEED_INIT` (dispatched when a feed is re-enabled
|
||||
having been turned off, with the feed id as the `data`). On these events it must
|
||||
call `SectionsManager.enableSection(id)`. Care should be taken that this happens
|
||||
only once `SectionsManager` has also initialised; the feed can use the method
|
||||
`SectionsManager.onceInitialized()`.
|
||||
|
||||
```js
|
||||
{
|
||||
id, // Unique section id
|
||||
icon, // Section icon id - new icons should be added to icons.scss
|
||||
title: {id, values}, // Title localisation id and placeholder values
|
||||
maxCards, // Max number of site cards to dispay
|
||||
contextMenuOptions, // Default context-menu options for cards
|
||||
infoOption: { // Info option dialog
|
||||
header: {id, values}, // Header localisation id and values
|
||||
body: {id, values}, // Body localisation id and values
|
||||
link: {href, id, values}, // Link href, localisation id and values
|
||||
},
|
||||
emptyState: { // State if no cards are visible
|
||||
message: {id, values}, // Message localisation id and values
|
||||
icon // Icon id - new icons should be added to icons.scss
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Deregistering the section
|
||||
### Disabling the section
|
||||
|
||||
The section feed must have an `uninit` method. This is called when the section
|
||||
feed is disabled by turning the section's pref off.
|
||||
|
||||
In `uninit` the feed must broadcast a `SECTION_DEREGISTER` action with the
|
||||
section id as the data. This will remove the section's UI component from every
|
||||
existing Activity Stream page.
|
||||
feed is disabled by turning the section's pref off. In `uninit` the feed must
|
||||
call `SectionsManager.disableSection(id)`. This will remove the section's UI
|
||||
component from every existing Activity Stream page.
|
||||
|
||||
### Updating the section rows
|
||||
|
||||
The section feed can dispatch a `SECTION_ROWS_UPDATE` action to update its rows.
|
||||
The action's data object must be passed the section's `id` and an array `rows`
|
||||
of sites to display. Each site object must have the following properties:
|
||||
The section feed can call `SectionsManager.updateSection(id, options)` to update
|
||||
section options. The `rows` array property of `options` stores the cards of
|
||||
sites to display. Each card object may have the following properties:
|
||||
|
||||
```js
|
||||
{
|
||||
|
@ -71,6 +70,3 @@ of sites to display. Each site object must have the following properties:
|
|||
url // Site url
|
||||
}
|
||||
```
|
||||
|
||||
Optionally, rows can also be passed with the `SECTION_REGISTER` action if the
|
||||
feed already has rows to display.
|
||||
|
|
|
@ -56,8 +56,10 @@ for (const type of [
|
|||
"SAVE_TO_POCKET",
|
||||
"SCREENSHOT_UPDATED",
|
||||
"SECTION_DEREGISTER",
|
||||
"SECTION_DISABLE",
|
||||
"SECTION_ENABLE",
|
||||
"SECTION_REGISTER",
|
||||
"SECTION_ROWS_UPDATE",
|
||||
"SECTION_UPDATE",
|
||||
"SET_PREF",
|
||||
"SNIPPETS_DATA",
|
||||
"SNIPPETS_RESET",
|
||||
|
|
|
@ -189,14 +189,27 @@ function Sections(prevState = INITIAL_STATE.Sections, action) {
|
|||
}
|
||||
return section;
|
||||
});
|
||||
// If section doesn't exist in prevState, create a new section object and
|
||||
// append it to the sections state
|
||||
|
||||
// Invariant: Sections array sorted in increasing order of property `order`.
|
||||
// If section doesn't exist in prevState, create a new section object. If
|
||||
// the section has an order, insert it at the correct place in the array.
|
||||
// Otherwise, prepend it and set the order to be minimal.
|
||||
if (!hasMatch) {
|
||||
const initialized = action.data.rows && action.data.rows.length > 0;
|
||||
newState.push(Object.assign({title: "", initialized, rows: []}, action.data));
|
||||
let order;
|
||||
let index;
|
||||
if (prevState.length > 0) {
|
||||
order = action.data.order || prevState[0].order - 1;
|
||||
index = newState.findIndex(section => section.order >= order);
|
||||
} else {
|
||||
order = action.data.order || 1;
|
||||
index = 0;
|
||||
}
|
||||
const section = Object.assign({title: "", initialized, rows: [], order, enabled: false}, action.data);
|
||||
newState.splice(index, 0, section);
|
||||
}
|
||||
return newState;
|
||||
case at.SECTION_ROWS_UPDATE:
|
||||
case at.SECTION_UPDATE:
|
||||
return prevState.map(section => {
|
||||
if (section && section.id === action.data.id) {
|
||||
return Object.assign({}, section, action.data);
|
||||
|
|
|
@ -1,15 +1,20 @@
|
|||
const React = require("react");
|
||||
const {connect} = require("react-redux");
|
||||
const {injectIntl, FormattedMessage} = require("react-intl");
|
||||
const {actionCreators: ac} = require("common/Actions.jsm");
|
||||
const {actionCreators: ac, actionTypes: at} = require("common/Actions.jsm");
|
||||
|
||||
const getFormattedMessage = message =>
|
||||
(typeof message === "string" ? <span>{message}</span> : <FormattedMessage {...message} />);
|
||||
|
||||
const PreferencesInput = props => (
|
||||
<section>
|
||||
<input type="checkbox" id={props.prefName} name={props.prefName} checked={props.value} onChange={props.onChange} className={props.className} />
|
||||
<label htmlFor={props.prefName}>
|
||||
<FormattedMessage id={props.titleStringId} values={props.titleStringValues} />
|
||||
{getFormattedMessage(props.titleString)}
|
||||
</label>
|
||||
{props.descStringId && <p className="prefs-input-description"><FormattedMessage id={props.descStringId} /></p>}
|
||||
{props.descString && <p className="prefs-input-description">
|
||||
{getFormattedMessage(props.descString)}
|
||||
</p>}
|
||||
</section>
|
||||
);
|
||||
|
||||
|
@ -18,18 +23,9 @@ class PreferencesPane extends React.Component {
|
|||
super(props);
|
||||
this.state = {visible: false};
|
||||
this.handleClickOutside = this.handleClickOutside.bind(this);
|
||||
this.handleChange = this.handleChange.bind(this);
|
||||
this.handlePrefChange = this.handlePrefChange.bind(this);
|
||||
this.handleSectionChange = this.handleSectionChange.bind(this);
|
||||
this.togglePane = this.togglePane.bind(this);
|
||||
|
||||
// TODO This is temporary until sections register their PreferenceInput component automatically
|
||||
const optionJSON = props.Prefs.values["feeds.section.topstories.options"];
|
||||
if (optionJSON) {
|
||||
try {
|
||||
this.topStoriesOptions = JSON.parse(optionJSON);
|
||||
} catch (e) {
|
||||
console.error("Problem parsing feeds.section.topstories.options", e); // eslint-disable-line no-console
|
||||
}
|
||||
}
|
||||
}
|
||||
componentDidMount() {
|
||||
document.addEventListener("click", this.handleClickOutside);
|
||||
|
@ -43,10 +39,16 @@ class PreferencesPane extends React.Component {
|
|||
this.togglePane();
|
||||
}
|
||||
}
|
||||
handleChange(event) {
|
||||
handlePrefChange(event) {
|
||||
const target = event.target;
|
||||
this.props.dispatch(ac.SetPref(target.name, target.checked));
|
||||
}
|
||||
handleSectionChange(event) {
|
||||
const target = event.target;
|
||||
const id = target.name;
|
||||
const type = target.checked ? at.SECTION_ENABLE : at.SECTION_DISABLE;
|
||||
this.props.dispatch(ac.SendToMain({type, data: id}));
|
||||
}
|
||||
togglePane() {
|
||||
this.setState({visible: !this.state.visible});
|
||||
const event = this.state.visible ? "CLOSE_NEWTAB_PREFS" : "OPEN_NEWTAB_PREFS";
|
||||
|
@ -55,6 +57,7 @@ class PreferencesPane extends React.Component {
|
|||
render() {
|
||||
const props = this.props;
|
||||
const prefs = props.Prefs.values;
|
||||
const sections = props.Sections;
|
||||
const isVisible = this.state.visible;
|
||||
return (
|
||||
<div className="prefs-pane-wrapper" ref="wrapper">
|
||||
|
@ -70,17 +73,19 @@ class PreferencesPane extends React.Component {
|
|||
<h1><FormattedMessage id="settings_pane_header" /></h1>
|
||||
<p><FormattedMessage id="settings_pane_body" /></p>
|
||||
|
||||
<PreferencesInput className="showSearch" prefName="showSearch" value={prefs.showSearch} onChange={this.handleChange}
|
||||
titleStringId="settings_pane_search_header" descStringId="settings_pane_search_body" />
|
||||
<PreferencesInput className="showSearch" prefName="showSearch" value={prefs.showSearch} onChange={this.handlePrefChange}
|
||||
titleString={{id: "settings_pane_search_header"}} descString={{id: "settings_pane_search_body"}} />
|
||||
|
||||
<PreferencesInput className="showTopSites" prefName="showTopSites" value={prefs.showTopSites} onChange={this.handleChange}
|
||||
titleStringId="settings_pane_topsites_header" descStringId="settings_pane_topsites_body" />
|
||||
<PreferencesInput className="showTopSites" prefName="showTopSites" value={prefs.showTopSites} onChange={this.handlePrefChange}
|
||||
titleString={{id: "settings_pane_topsites_header"}} descString={{id: "settings_pane_topsites_body"}} />
|
||||
|
||||
{sections
|
||||
.filter(section => !section.shouldHidePref)
|
||||
.map(({id, title, enabled, pref}) =>
|
||||
<PreferencesInput key={id} className="showSection" prefName={(pref && pref.feed) || id}
|
||||
value={enabled} onChange={(pref && pref.feed) ? this.handlePrefChange : this.handleSectionChange}
|
||||
titleString={(pref && pref.titleString) || title} descString={pref && pref.descString} />)}
|
||||
|
||||
{this.topStoriesOptions && !this.topStoriesOptions.hidden &&
|
||||
<PreferencesInput className="showTopStories" prefName="feeds.section.topstories"
|
||||
value={prefs["feeds.section.topstories"]} onChange={this.handleChange}
|
||||
titleStringId="header_recommended_by" titleStringValues={{provider: this.topStoriesOptions.provider_name}}
|
||||
descStringId={this.topStoriesOptions.provider_description} />}
|
||||
</div>
|
||||
<section className="actions">
|
||||
<button className="done" onClick={this.togglePane}>
|
||||
|
@ -93,6 +98,6 @@ class PreferencesPane extends React.Component {
|
|||
}
|
||||
}
|
||||
|
||||
module.exports = connect(state => ({Prefs: state.Prefs}))(injectIntl(PreferencesPane));
|
||||
module.exports = connect(state => ({Prefs: state.Prefs, Sections: state.Sections}))(injectIntl(PreferencesPane));
|
||||
module.exports.PreferencesPane = PreferencesPane;
|
||||
module.exports.PreferencesInput = PreferencesInput;
|
||||
|
|
|
@ -106,7 +106,7 @@ class Section extends React.Component {
|
|||
const {id, eventSource, title, icon, rows, infoOption, emptyState, dispatch, maxRows, contextMenuOptions, intl} = this.props;
|
||||
const maxCards = 3 * maxRows;
|
||||
const initialized = rows && rows.length > 0;
|
||||
const shouldShowTopics = (id === "TopStories" &&
|
||||
const shouldShowTopics = (id === "topstories" &&
|
||||
this.props.topics &&
|
||||
this.props.topics.length > 0 &&
|
||||
this.props.read_more_endpoint);
|
||||
|
@ -182,7 +182,9 @@ class Sections extends React.Component {
|
|||
const sections = this.props.Sections;
|
||||
return (
|
||||
<div className="sections-list">
|
||||
{sections.map(section => <SectionIntl key={section.id} {...section} dispatch={this.props.dispatch} />)}
|
||||
{sections
|
||||
.filter(section => section.enabled)
|
||||
.map(section => <SectionIntl key={section.id} {...section} dispatch={this.props.dispatch} />)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -7,21 +7,85 @@ const {utils: Cu} = Components;
|
|||
const {actionCreators: ac, actionTypes: at} = Cu.import("resource://activity-stream/common/Actions.jsm", {});
|
||||
Cu.import("resource://gre/modules/EventEmitter.jsm");
|
||||
|
||||
/*
|
||||
* Generators for built in sections, keyed by the pref name for their feed.
|
||||
* Built in sections may depend on options stored as serialised JSON in the pref
|
||||
* `${feed_pref_name}.options`.
|
||||
*/
|
||||
const BUILT_IN_SECTIONS = {
|
||||
"feeds.section.topstories": options => ({
|
||||
id: "topstories",
|
||||
pref: {
|
||||
titleString: {id: "header_recommended_by", values: {provider: options.provider_name}},
|
||||
descString: {id: options.provider_description}
|
||||
},
|
||||
shouldHidePref: options.hidden,
|
||||
eventSource: "TOP_STORIES",
|
||||
icon: options.provider_icon,
|
||||
title: {id: "header_recommended_by", values: {provider: options.provider_name}},
|
||||
maxRows: 1,
|
||||
contextMenuOptions: ["CheckBookmark", "SaveToPocket", "Separator", "OpenInNewWindow", "OpenInPrivateWindow", "Separator", "BlockUrl"],
|
||||
infoOption: {
|
||||
header: {id: "pocket_feedback_header"},
|
||||
body: {id: options.provider_description},
|
||||
link: {href: options.survey_link, id: "pocket_send_feedback"}
|
||||
},
|
||||
emptyState: {
|
||||
message: {id: "topstories_empty_state", values: {provider: options.provider_name}},
|
||||
icon: "check"
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
const SectionsManager = {
|
||||
ACTIONS_TO_PROXY: ["SYSTEM_TICK", "NEW_TAB_LOAD"],
|
||||
initialized: false,
|
||||
sections: new Set(),
|
||||
sections: new Map(),
|
||||
init(prefs = {}) {
|
||||
for (const feedPrefName of Object.keys(BUILT_IN_SECTIONS)) {
|
||||
const optionsPrefName = `${feedPrefName}.options`;
|
||||
this.addBuiltInSection(feedPrefName, prefs[optionsPrefName]);
|
||||
}
|
||||
this.initialized = true;
|
||||
this.emit(this.INIT);
|
||||
},
|
||||
addBuiltInSection(feedPrefName, optionsPrefValue = "{}") {
|
||||
let options;
|
||||
try {
|
||||
options = JSON.parse(optionsPrefValue);
|
||||
} catch (e) {
|
||||
options = {};
|
||||
Cu.reportError("Problem parsing options pref", e);
|
||||
}
|
||||
const section = BUILT_IN_SECTIONS[feedPrefName](options);
|
||||
section.pref.feed = feedPrefName;
|
||||
this.addSection(section.id, Object.assign(section, {options}));
|
||||
},
|
||||
addSection(id, options) {
|
||||
this.sections.add(id);
|
||||
this.sections.set(id, options);
|
||||
this.emit(this.ADD_SECTION, id, options);
|
||||
},
|
||||
removeSection(id) {
|
||||
this.emit(this.REMOVE_SECTION, id);
|
||||
this.sections.delete(id);
|
||||
},
|
||||
updateRows(id, rows, shouldBroadcast) {
|
||||
enableSection(id) {
|
||||
this.updateSection(id, {enabled: true}, true);
|
||||
},
|
||||
disableSection(id) {
|
||||
this.updateSection(id, {enabled: false, rows: []}, true);
|
||||
},
|
||||
updateSection(id, options, shouldBroadcast) {
|
||||
if (this.sections.has(id)) {
|
||||
this.emit(this.UPDATE_ROWS, id, rows, shouldBroadcast);
|
||||
this.sections.set(id, Object.assign(this.sections.get(id), options));
|
||||
this.emit(this.UPDATE_SECTION, id, options, shouldBroadcast);
|
||||
}
|
||||
},
|
||||
onceInitialized(callback) {
|
||||
if (this.initialized) {
|
||||
callback();
|
||||
} else {
|
||||
this.once(this.INIT, callback);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@ -30,7 +94,7 @@ for (const action of [
|
|||
"ACTION_DISPATCHED",
|
||||
"ADD_SECTION",
|
||||
"REMOVE_SECTION",
|
||||
"UPDATE_ROWS",
|
||||
"UPDATE_SECTION",
|
||||
"INIT",
|
||||
"UNINIT"
|
||||
]) {
|
||||
|
@ -41,17 +105,20 @@ EventEmitter.decorate(SectionsManager);
|
|||
|
||||
class SectionsFeed {
|
||||
constructor() {
|
||||
this.init = this.init.bind(this);
|
||||
this.onAddSection = this.onAddSection.bind(this);
|
||||
this.onRemoveSection = this.onRemoveSection.bind(this);
|
||||
this.onUpdateRows = this.onUpdateRows.bind(this);
|
||||
this.onUpdateSection = this.onUpdateSection.bind(this);
|
||||
SectionsManager.onceInitialized(this.init);
|
||||
}
|
||||
|
||||
init() {
|
||||
SectionsManager.on(SectionsManager.ADD_SECTION, this.onAddSection);
|
||||
SectionsManager.on(SectionsManager.REMOVE_SECTION, this.onRemoveSection);
|
||||
SectionsManager.on(SectionsManager.UPDATE_ROWS, this.onUpdateRows);
|
||||
SectionsManager.initialized = true;
|
||||
SectionsManager.emit(SectionsManager.INIT);
|
||||
SectionsManager.on(SectionsManager.UPDATE_SECTION, this.onUpdateSection);
|
||||
// Catch any sections that have already been added
|
||||
SectionsManager.sections.forEach((section, id) =>
|
||||
this.onAddSection(SectionsManager.ADD_SECTION, id, section));
|
||||
}
|
||||
|
||||
uninit() {
|
||||
|
@ -59,7 +126,7 @@ class SectionsFeed {
|
|||
SectionsManager.emit(SectionsManager.UNINIT);
|
||||
SectionsManager.off(SectionsManager.ADD_SECTION, this.onAddSection);
|
||||
SectionsManager.off(SectionsManager.REMOVE_SECTION, this.onRemoveSection);
|
||||
SectionsManager.off(SectionsManager.UPDATE_ROWS, this.onUpdateRows);
|
||||
SectionsManager.off(SectionsManager.UPDATE_SECTION, this.onUpdateSection);
|
||||
}
|
||||
|
||||
onAddSection(event, id, options) {
|
||||
|
@ -72,17 +139,29 @@ class SectionsFeed {
|
|||
this.store.dispatch(ac.BroadcastToContent({type: at.SECTION_DEREGISTER, data: id}));
|
||||
}
|
||||
|
||||
onUpdateRows(event, id, rows, shouldBroadcast = false) {
|
||||
if (rows) {
|
||||
const action = {type: at.SECTION_ROWS_UPDATE, data: {id, rows}};
|
||||
onUpdateSection(event, id, options, shouldBroadcast = false) {
|
||||
if (options) {
|
||||
const action = {type: at.SECTION_UPDATE, data: Object.assign(options, {id})};
|
||||
this.store.dispatch(shouldBroadcast ? ac.BroadcastToContent(action) : action);
|
||||
}
|
||||
}
|
||||
|
||||
onAction(action) {
|
||||
switch (action.type) {
|
||||
case at.INIT:
|
||||
this.init();
|
||||
// Wait for pref values, as some sections have options stored in prefs
|
||||
case at.PREFS_INITIAL_VALUES:
|
||||
SectionsManager.init(action.data);
|
||||
break;
|
||||
case at.PREF_CHANGED:
|
||||
if (action.data && action.data.name.match(/^feeds.section.(\S+).options$/i)) {
|
||||
SectionsManager.addBuiltInSection(action.data.name.slice(0, -8), action.data.value);
|
||||
}
|
||||
break;
|
||||
case at.SECTION_DISABLE:
|
||||
SectionsManager.disableSection(action.data);
|
||||
break;
|
||||
case at.SECTION_ENABLE:
|
||||
SectionsManager.enableSection(action.data);
|
||||
break;
|
||||
}
|
||||
if (SectionsManager.ACTIONS_TO_PROXY.includes(action.type) && SectionsManager.sections.size > 0) {
|
||||
|
|
|
@ -9,26 +9,30 @@ 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 {actionTypes: at} = Cu.import("resource://activity-stream/common/Actions.jsm", {});
|
||||
const {Prefs} = Cu.import("resource://activity-stream/lib/ActivityStreamPrefs.jsm", {});
|
||||
const {shortURL} = Cu.import("resource://activity-stream/lib/ShortURL.jsm", {});
|
||||
const {SectionsManager} = Cu.import("resource://activity-stream/lib/SectionsManager.jsm", {});
|
||||
|
||||
const STORIES_UPDATE_TIME = 30 * 60 * 1000; // 30 minutes
|
||||
const TOPICS_UPDATE_TIME = 3 * 60 * 60 * 1000; // 3 hours
|
||||
const STORIES_NOW_THRESHOLD = 24 * 60 * 60 * 1000; // 24 hours
|
||||
const SECTION_ID = "TopStories";
|
||||
const SECTION_ID = "topstories";
|
||||
const FEED_PREF = "feeds.section.topstories";
|
||||
const SECTION_OPTIONS_PREF = "feeds.section.topstories.options";
|
||||
|
||||
this.TopStoriesFeed = class TopStoriesFeed {
|
||||
|
||||
init() {
|
||||
try {
|
||||
this.storiesLastUpdated = 0;
|
||||
this.topicsLastUpdated = 0;
|
||||
this.storiesLastUpdated = 0;
|
||||
this.topicsLastUpdated = 0;
|
||||
|
||||
const prefs = new Prefs();
|
||||
const options = JSON.parse(prefs.get(SECTION_OPTIONS_PREF));
|
||||
SectionsManager.onceInitialized(this.parseOptions.bind(this));
|
||||
}
|
||||
|
||||
parseOptions() {
|
||||
SectionsManager.enableSection(SECTION_ID);
|
||||
const options = SectionsManager.sections.get(SECTION_ID).options;
|
||||
try {
|
||||
const apiKey = this._getApiKeyFromPref(options.api_key_pref);
|
||||
this.stories_endpoint = this._produceFinalEndpointUrl(options.stories_endpoint, apiKey);
|
||||
this.topics_endpoint = this._produceFinalEndpointUrl(options.topics_endpoint, apiKey);
|
||||
|
@ -36,30 +40,6 @@ this.TopStoriesFeed = class TopStoriesFeed {
|
|||
this.read_more_endpoint = options.read_more_endpoint;
|
||||
this.stories_referrer = options.stories_referrer;
|
||||
|
||||
// TODO https://github.com/mozilla/activity-stream/issues/2902
|
||||
const sectionOptions = {
|
||||
id: SECTION_ID,
|
||||
eventSource: "TOP_STORIES",
|
||||
icon: options.provider_icon,
|
||||
title: {id: "header_recommended_by", values: {provider: options.provider_name}},
|
||||
rows: [],
|
||||
maxRows: 1,
|
||||
contextMenuOptions: ["CheckBookmark", "SaveToPocket", "Separator", "OpenInNewWindow", "OpenInPrivateWindow", "Separator", "BlockUrl"],
|
||||
infoOption: {
|
||||
header: {id: "pocket_feedback_header"},
|
||||
body: {id: options.provider_description},
|
||||
link: {
|
||||
href: options.survey_link,
|
||||
id: "pocket_send_feedback"
|
||||
}
|
||||
},
|
||||
emptyState: {
|
||||
message: {id: "topstories_empty_state", values: {provider: options.provider_name}},
|
||||
icon: "check"
|
||||
}
|
||||
};
|
||||
this.store.dispatch(ac.BroadcastToContent({type: at.SECTION_REGISTER, data: sectionOptions}));
|
||||
|
||||
this.fetchStories();
|
||||
this.fetchTopics();
|
||||
} catch (e) {
|
||||
|
@ -68,7 +48,7 @@ this.TopStoriesFeed = class TopStoriesFeed {
|
|||
}
|
||||
|
||||
uninit() {
|
||||
this.store.dispatch(ac.BroadcastToContent({type: at.SECTION_DEREGISTER, data: SECTION_ID}));
|
||||
SectionsManager.disableSection(SECTION_ID);
|
||||
}
|
||||
|
||||
async fetchStories() {
|
||||
|
@ -100,8 +80,7 @@ this.TopStoriesFeed = class TopStoriesFeed {
|
|||
.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.dispatchUpdateEvent(this.storiesLastUpdated, {rows: stories});
|
||||
this.storiesLastUpdated = Date.now();
|
||||
}
|
||||
}
|
||||
|
@ -120,19 +99,14 @@ this.TopStoriesFeed = class TopStoriesFeed {
|
|||
.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.dispatchUpdateEvent(this.topicsLastUpdated, {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);
|
||||
}
|
||||
dispatchUpdateEvent(lastUpdated, data) {
|
||||
SectionsManager.updateSection(SECTION_ID, data, lastUpdated === 0);
|
||||
}
|
||||
|
||||
_getApiKeyFromPref(apiKeyPref) {
|
||||
|
@ -191,11 +165,6 @@ this.TopStoriesFeed = class TopStoriesFeed {
|
|||
this.init();
|
||||
}
|
||||
break;
|
||||
case at.PREF_CHANGED:
|
||||
if (action.data.name === SECTION_OPTIONS_PREF) {
|
||||
this.init();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@ -204,5 +173,4 @@ this.STORIES_UPDATE_TIME = STORIES_UPDATE_TIME;
|
|||
this.TOPICS_UPDATE_TIME = TOPICS_UPDATE_TIME;
|
||||
this.SECTION_ID = SECTION_ID;
|
||||
this.FEED_PREF = FEED_PREF;
|
||||
this.SECTION_OPTIONS_PREF = SECTION_OPTIONS_PREF;
|
||||
this.EXPORTED_SYMBOLS = ["TopStoriesFeed", "STORIES_UPDATE_TIME", "TOPICS_UPDATE_TIME", "SECTION_ID", "FEED_PREF", "SECTION_OPTIONS_PREF"];
|
||||
this.EXPORTED_SYMBOLS = ["TopStoriesFeed", "STORIES_UPDATE_TIME", "TOPICS_UPDATE_TIME", "SECTION_ID", "FEED_PREF"];
|
||||
|
|
|
@ -210,7 +210,8 @@ describe("Reducers", () => {
|
|||
id: `foo_bar_${i}`,
|
||||
title: `Foo Bar ${i}`,
|
||||
initialized: false,
|
||||
rows: [{url: "www.foo.bar"}, {url: "www.other.url"}]
|
||||
rows: [{url: "www.foo.bar"}, {url: "www.other.url"}],
|
||||
order: i
|
||||
}));
|
||||
});
|
||||
|
||||
|
@ -230,6 +231,29 @@ describe("Reducers", () => {
|
|||
const insertedSection = newState.find(section => section.id === "foo_bar_5");
|
||||
assert.propertyVal(insertedSection, "title", action.data.title);
|
||||
});
|
||||
it("should ensure sections are sorted by property `order` (increasing) on SECTION_REGISTER", () => {
|
||||
let newState = [];
|
||||
const state = Object.assign([], oldState);
|
||||
state.forEach(section => {
|
||||
Object.assign(section, {order: 5 - section.order});
|
||||
const action = {type: at.SECTION_REGISTER, data: section};
|
||||
newState = Sections(newState, action);
|
||||
});
|
||||
// Should have been inserted into newState in reverse order
|
||||
assert.deepEqual(newState.map(section => section.id), state.map(section => section.id).reverse());
|
||||
const newSection = {id: "new_section", order: 2.5};
|
||||
const action = {type: at.SECTION_REGISTER, data: newSection};
|
||||
newState = Sections(newState, action);
|
||||
// Should have been inserted at index 2, between second and third section
|
||||
assert.equal(newState[2].id, newSection.id);
|
||||
});
|
||||
it("should insert sections without an `order` at the top on SECTION_REGISTER", () => {
|
||||
const newSection = {id: "new_section"};
|
||||
const action = {type: at.SECTION_REGISTER, data: newSection};
|
||||
const newState = Sections(oldState, action);
|
||||
assert.equal(newState[0].id, newSection.id);
|
||||
assert.ok(newState[0].order < newState[1].order);
|
||||
});
|
||||
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);
|
||||
|
@ -244,17 +268,17 @@ describe("Reducers", () => {
|
|||
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"}};
|
||||
it("should have no effect on SECTION_UPDATE if the id doesn't exist", () => {
|
||||
const action = {type: at.SECTION_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}};
|
||||
it("should update the section with the correct data on SECTION_UPDATE", () => {
|
||||
const FAKE_DATA = {rows: ["some", "fake", "data"], foo: "bar"};
|
||||
const action = {type: at.SECTION_UPDATE, data: Object.assign(FAKE_DATA, {id: "foo_bar_2"})};
|
||||
const newState = Sections(oldState, action);
|
||||
const updatedSection = newState.find(section => section.id === "foo_bar_2");
|
||||
assert.equal(updatedSection.rows, FAKE_DATA);
|
||||
assert.include(updatedSection, 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"}};
|
||||
|
|
|
@ -6,13 +6,13 @@ const {PreferencesPane, PreferencesInput} = require("content-src/components/Pref
|
|||
const {actionCreators: ac} = require("common/Actions.jsm");
|
||||
|
||||
describe("<PreferencesInput>", () => {
|
||||
const testStringIds = {
|
||||
titleStringId: "settings_pane_search_header",
|
||||
descStringId: "settings_pane_search_body"
|
||||
const testStrings = {
|
||||
titleString: {id: "settings_pane_search_header"},
|
||||
descString: {id: "settings_pane_search_body"}
|
||||
};
|
||||
let wrapper;
|
||||
beforeEach(() => {
|
||||
wrapper = shallow(<PreferencesInput prefName="foo" {...testStringIds} />);
|
||||
wrapper = shallow(<PreferencesInput prefName="foo" {...testStrings} />);
|
||||
});
|
||||
|
||||
it("should set the name and id on the input and the 'for' attribute on the label to props.prefName", () => {
|
||||
|
@ -23,17 +23,17 @@ describe("<PreferencesInput>", () => {
|
|||
assert.propertyVal(labelProps, "htmlFor", "foo");
|
||||
});
|
||||
it("should set the checked value of the input to props.value", () => {
|
||||
wrapper = shallow(<PreferencesInput prefName="foo" value={true} {...testStringIds} />);
|
||||
wrapper = shallow(<PreferencesInput prefName="foo" value={true} {...testStrings} />);
|
||||
assert.propertyVal(wrapper.find("input").props(), "checked", true);
|
||||
});
|
||||
it("should render a FormattedString in the label with id=titleStringId", () => {
|
||||
assert.propertyVal(wrapper.find("label").find(FormattedMessage).props(), "id", testStringIds.titleStringId);
|
||||
it("should render a FormattedString in the label with id=titleString", () => {
|
||||
assert.propertyVal(wrapper.find("label").find(FormattedMessage).props(), "id", testStrings.titleString.id);
|
||||
});
|
||||
it("should render a FormattedString in the description with id=descStringId", () => {
|
||||
assert.propertyVal(wrapper.find(".prefs-input-description").find(FormattedMessage).props(), "id", testStringIds.descStringId);
|
||||
it("should render a FormattedString in the description with id=descString", () => {
|
||||
assert.propertyVal(wrapper.find(".prefs-input-description").find(FormattedMessage).props(), "id", testStrings.descString.id);
|
||||
});
|
||||
it("should not render the description element if no descStringId is given", () => {
|
||||
wrapper = shallow(<PreferencesInput prefName="foo" titleStringId="bar" />);
|
||||
wrapper = shallow(<PreferencesInput prefName="foo" titleString="bar" />);
|
||||
assert.lengthOf(wrapper.find(".prefs-input-description"), 0);
|
||||
});
|
||||
});
|
||||
|
@ -44,7 +44,12 @@ describe("<PreferencesPane>", () => {
|
|||
beforeEach(() => {
|
||||
dispatch = sinon.spy();
|
||||
const fakePrefs = {values: {showSearch: true, showTopSites: true}};
|
||||
wrapper = shallowWithIntl(<PreferencesPane dispatch={dispatch} Prefs={fakePrefs} />);
|
||||
const fakeSections = [
|
||||
{id: "section1", shouldHidePref: false, enabled: true, pref: {title: "fake_title", feed: "section1_feed"}},
|
||||
{id: "section2", shouldHidePref: false, enabled: false, pref: {}},
|
||||
{id: "section3", shouldHidePref: true, enabled: true}
|
||||
];
|
||||
wrapper = shallowWithIntl(<PreferencesPane dispatch={dispatch} Prefs={fakePrefs} Sections={fakeSections} />);
|
||||
});
|
||||
it("should hide the sidebar and show a settings icon by default", () => {
|
||||
assert.isTrue(wrapper.find(".sidebar").hasClass("hidden"));
|
||||
|
@ -77,10 +82,23 @@ describe("<PreferencesPane>", () => {
|
|||
wrapper.find("button.done").simulate("click");
|
||||
assert.isTrue(wrapper.find(".sidebar").hasClass("hidden"));
|
||||
});
|
||||
it("should dispatch a SetPref action when a PreferencesInput is clicked", () => {
|
||||
it("should dispatch a SetPref action when a non-section PreferencesInput is clicked", () => {
|
||||
const showSearchWrapper = wrapper.find(".showSearch");
|
||||
showSearchWrapper.simulate("change", {target: {name: "showSearch", checked: false}});
|
||||
assert.calledOnce(dispatch);
|
||||
assert.calledWith(dispatch, ac.SetPref("showSearch", false));
|
||||
});
|
||||
it("should show PreferencesInputs for a section if and only if shouldHidePref is false", () => {
|
||||
const sectionsWrapper = wrapper.find(".showSection");
|
||||
assert.equal(sectionsWrapper.length, 2);
|
||||
assert.ok(sectionsWrapper.containsMatchingElement(<PreferencesInput prefName="section1_feed" />));
|
||||
assert.ok(sectionsWrapper.containsMatchingElement(<PreferencesInput prefName="section2" />));
|
||||
assert.notOk(sectionsWrapper.containsMatchingElement(<PreferencesInput prefName="section3" />));
|
||||
});
|
||||
it("should set the value prop of a section PreferencesInput to equal section.enabled", () => {
|
||||
const section1 = wrapper.findWhere(prefInput => prefInput.props().prefName === "section1_feed");
|
||||
const section2 = wrapper.findWhere(prefInput => prefInput.props().prefName === "section2");
|
||||
assert.equal(section1.props().value, true);
|
||||
assert.equal(section2.props().value, false);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -13,6 +13,7 @@ describe("<Sections>", () => {
|
|||
id: `foo_bar_${i}`,
|
||||
title: `Foo Bar ${i}`,
|
||||
initialized: false,
|
||||
enabled: !!(i % 2),
|
||||
rows: []
|
||||
}));
|
||||
wrapper = shallow(<Sections Sections={FAKE_SECTIONS} />);
|
||||
|
@ -20,10 +21,13 @@ describe("<Sections>", () => {
|
|||
it("should render a Sections element", () => {
|
||||
assert.ok(wrapper.exists());
|
||||
});
|
||||
it("should render a Section for each one passed in props.Sections", () => {
|
||||
it("should render a Section for each one passed in props.Sections with .enabled === true", () => {
|
||||
const sectionElems = wrapper.find(SectionIntl);
|
||||
assert.lengthOf(sectionElems, 5);
|
||||
sectionElems.forEach((section, i) => assert.equal(section.props().id, FAKE_SECTIONS[i].id));
|
||||
assert.lengthOf(sectionElems, 2);
|
||||
sectionElems.forEach((section, i) => {
|
||||
assert.equal(section.props().id, FAKE_SECTIONS[2 * i + 1].id);
|
||||
assert.equal(section.props().enabled, true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -102,7 +106,7 @@ describe("<Section>", () => {
|
|||
|
||||
it("should render topics component for non-empty topics", () => {
|
||||
let TOP_STORIES_SECTION = {
|
||||
id: "TopStories",
|
||||
id: "topstories",
|
||||
title: "TopStories",
|
||||
rows: [{guid: 1, link: "http://localhost", isDefault: true}],
|
||||
topics: [],
|
||||
|
|
|
@ -1,20 +1,52 @@
|
|||
"use strict";
|
||||
const {SectionsFeed, SectionsManager} = require("lib/SectionsManager.jsm");
|
||||
const {EventEmitter} = require("test/unit/utils");
|
||||
const {EventEmitter, GlobalOverrider} = require("test/unit/utils");
|
||||
const {MAIN_MESSAGE_TYPE, CONTENT_MESSAGE_TYPE} = require("common/Actions.jsm");
|
||||
|
||||
const FAKE_ID = "FAKE_ID";
|
||||
const FAKE_OPTIONS = {icon: "FAKE_ICON", title: "FAKE_TITLE"};
|
||||
const FAKE_ROWS = [{url: "1"}, {url: "2"}, {"url": "3"}];
|
||||
|
||||
let globals;
|
||||
|
||||
beforeEach(() => {
|
||||
globals = new GlobalOverrider();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globals.restore();
|
||||
// Redecorate SectionsManager to remove any listeners that have been added
|
||||
EventEmitter.decorate(SectionsManager);
|
||||
SectionsManager.init();
|
||||
});
|
||||
|
||||
describe("SectionsManager", () => {
|
||||
it("should be initialised with .initialized == false", () => {
|
||||
assert.notOk(SectionsManager.initialized);
|
||||
describe("#init", () => {
|
||||
it("should initialise the sections map with the built in sections", () => {
|
||||
SectionsManager.sections.clear();
|
||||
SectionsManager.initialized = false;
|
||||
SectionsManager.init();
|
||||
assert.equal(SectionsManager.sections.size, 1);
|
||||
assert.ok(SectionsManager.sections.has("topstories"));
|
||||
});
|
||||
it("should set .initialized to true", () => {
|
||||
SectionsManager.sections.clear();
|
||||
SectionsManager.initialized = false;
|
||||
SectionsManager.init();
|
||||
assert.ok(SectionsManager.initialized);
|
||||
});
|
||||
});
|
||||
describe("#addBuiltInSection", () => {
|
||||
it("should not report an error if options is undefined", () => {
|
||||
globals.sandbox.spy(global.Components.utils, "reportError");
|
||||
SectionsManager.addBuiltInSection("feeds.section.topstories", undefined);
|
||||
assert.notCalled(Components.utils.reportError);
|
||||
});
|
||||
it("should report an error if options is malformed", () => {
|
||||
globals.sandbox.spy(global.Components.utils, "reportError");
|
||||
SectionsManager.addBuiltInSection("feeds.section.topstories", "invalid");
|
||||
assert.calledOnce(Components.utils.reportError);
|
||||
});
|
||||
});
|
||||
describe("#addSection", () => {
|
||||
it("should add the id to sections and emit an ADD_SECTION event", () => {
|
||||
|
@ -38,29 +70,67 @@ describe("SectionsManager", () => {
|
|||
assert.calledWith(spy, SectionsManager.REMOVE_SECTION, FAKE_ID);
|
||||
});
|
||||
});
|
||||
describe("#updateRows", () => {
|
||||
it("should emit an UPDATE_ROWS event with correct arguments", () => {
|
||||
describe("#enableSection", () => {
|
||||
it("should call updateSection with {enabled: true}", () => {
|
||||
sinon.spy(SectionsManager, "updateSection");
|
||||
SectionsManager.addSection(FAKE_ID, FAKE_OPTIONS);
|
||||
SectionsManager.enableSection(FAKE_ID);
|
||||
assert.calledOnce(SectionsManager.updateSection);
|
||||
assert.calledWith(SectionsManager.updateSection, FAKE_ID, {enabled: true}, true);
|
||||
SectionsManager.updateSection.restore();
|
||||
});
|
||||
});
|
||||
describe("#disableSection", () => {
|
||||
it("should call updateSection with {enabled: false, rows: []}", () => {
|
||||
sinon.spy(SectionsManager, "updateSection");
|
||||
SectionsManager.addSection(FAKE_ID, FAKE_OPTIONS);
|
||||
SectionsManager.disableSection(FAKE_ID);
|
||||
assert.calledOnce(SectionsManager.updateSection);
|
||||
assert.calledWith(SectionsManager.updateSection, FAKE_ID, {enabled: false, rows: []}, true);
|
||||
SectionsManager.updateSection.restore();
|
||||
});
|
||||
});
|
||||
describe("#updateSection", () => {
|
||||
it("should emit an UPDATE_SECTION event with correct arguments", () => {
|
||||
SectionsManager.addSection(FAKE_ID, FAKE_OPTIONS);
|
||||
const spy = sinon.spy();
|
||||
SectionsManager.on(SectionsManager.UPDATE_ROWS, spy);
|
||||
SectionsManager.updateRows(FAKE_ID, FAKE_ROWS, true);
|
||||
SectionsManager.on(SectionsManager.UPDATE_SECTION, spy);
|
||||
SectionsManager.updateSection(FAKE_ID, {rows: FAKE_ROWS}, true);
|
||||
assert.calledOnce(spy);
|
||||
assert.calledWith(spy, SectionsManager.UPDATE_ROWS, FAKE_ID, FAKE_ROWS, true);
|
||||
assert.calledWith(spy, SectionsManager.UPDATE_SECTION, FAKE_ID, {rows: FAKE_ROWS}, true);
|
||||
});
|
||||
it("should do nothing if the section doesn't exist", () => {
|
||||
SectionsManager.removeSection(FAKE_ID);
|
||||
const spy = sinon.spy();
|
||||
SectionsManager.on(SectionsManager.UPDATE_ROWS, spy);
|
||||
SectionsManager.updateRows(FAKE_ID, FAKE_ROWS, true);
|
||||
SectionsManager.on(SectionsManager.UPDATE_SECTION, spy);
|
||||
SectionsManager.updateSection(FAKE_ID, {rows: FAKE_ROWS}, true);
|
||||
assert.notCalled(spy);
|
||||
});
|
||||
});
|
||||
describe("#onceInitialized", () => {
|
||||
it("should call the callback immediately if SectionsManager is initialised", () => {
|
||||
SectionsManager.initialized = true;
|
||||
const callback = sinon.spy();
|
||||
SectionsManager.onceInitialized(callback);
|
||||
assert.calledOnce(callback);
|
||||
});
|
||||
it("should bind the callback to .once(INIT) if SectionsManager is not initialised", () => {
|
||||
SectionsManager.initialized = false;
|
||||
sinon.spy(SectionsManager, "once");
|
||||
const callback = () => {};
|
||||
SectionsManager.onceInitialized(callback);
|
||||
assert.calledOnce(SectionsManager.once);
|
||||
assert.calledWith(SectionsManager.once, SectionsManager.INIT, callback);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("SectionsFeed", () => {
|
||||
let feed;
|
||||
|
||||
beforeEach(() => {
|
||||
SectionsManager.sections.clear();
|
||||
SectionsManager.initialized = false;
|
||||
feed = new SectionsFeed();
|
||||
feed.store = {dispatch: sinon.spy()};
|
||||
});
|
||||
|
@ -68,8 +138,14 @@ describe("SectionsFeed", () => {
|
|||
feed.uninit();
|
||||
});
|
||||
describe("#init", () => {
|
||||
it("should create a SectionsFeed", () => {
|
||||
it("should create a SectionsFeed and bind to SectionsManager.INIT", () => {
|
||||
assert.instanceOf(feed, SectionsFeed);
|
||||
SectionsManager.initialized = false;
|
||||
sinon.spy(SectionsManager, "once");
|
||||
feed = new SectionsFeed();
|
||||
assert.calledOnce(SectionsManager.once);
|
||||
assert.calledWith(SectionsManager.once, SectionsManager.INIT, feed.init);
|
||||
SectionsManager.once.restore();
|
||||
});
|
||||
it("should bind appropriate listeners", () => {
|
||||
sinon.spy(SectionsManager, "on");
|
||||
|
@ -78,18 +154,11 @@ describe("SectionsFeed", () => {
|
|||
for (const [event, listener] of [
|
||||
[SectionsManager.ADD_SECTION, feed.onAddSection],
|
||||
[SectionsManager.REMOVE_SECTION, feed.onRemoveSection],
|
||||
[SectionsManager.UPDATE_ROWS, feed.onUpdateRows]
|
||||
[SectionsManager.UPDATE_SECTION, feed.onUpdateSection]
|
||||
]) {
|
||||
assert.calledWith(SectionsManager.on, event, listener);
|
||||
}
|
||||
});
|
||||
it("should emit an INIT event and set SectionsManager.initialized to true", () => {
|
||||
const spy = sinon.spy();
|
||||
SectionsManager.on(SectionsManager.INIT, spy);
|
||||
feed.init();
|
||||
assert.calledOnce(spy);
|
||||
assert.ok(SectionsManager.initialized);
|
||||
});
|
||||
});
|
||||
describe("#uninit", () => {
|
||||
it("should unbind all listeners", () => {
|
||||
|
@ -100,7 +169,7 @@ describe("SectionsFeed", () => {
|
|||
for (const [event, listener] of [
|
||||
[SectionsManager.ADD_SECTION, feed.onAddSection],
|
||||
[SectionsManager.REMOVE_SECTION, feed.onRemoveSection],
|
||||
[SectionsManager.UPDATE_ROWS, feed.onUpdateRows]
|
||||
[SectionsManager.UPDATE_SECTION, feed.onUpdateSection]
|
||||
]) {
|
||||
assert.calledWith(SectionsManager.off, event, listener);
|
||||
}
|
||||
|
@ -135,21 +204,21 @@ describe("SectionsFeed", () => {
|
|||
assert.equal(action.meta.to, CONTENT_MESSAGE_TYPE);
|
||||
});
|
||||
});
|
||||
describe("#onUpdateRows", () => {
|
||||
describe("#onUpdateSection", () => {
|
||||
it("should do nothing if no rows are provided", () => {
|
||||
feed.onUpdateRows(null, FAKE_ID, null);
|
||||
feed.onUpdateSection(null, FAKE_ID, null);
|
||||
assert.notCalled(feed.store.dispatch);
|
||||
});
|
||||
it("should dispatch a SECTION_ROWS_UPDATE action with the correct data", () => {
|
||||
feed.onUpdateRows(null, FAKE_ID, FAKE_ROWS);
|
||||
it("should dispatch a SECTION_UPDATE action with the correct data", () => {
|
||||
feed.onUpdateSection(null, FAKE_ID, {rows: FAKE_ROWS});
|
||||
const action = feed.store.dispatch.firstCall.args[0];
|
||||
assert.equal(action.type, "SECTION_ROWS_UPDATE");
|
||||
assert.equal(action.type, "SECTION_UPDATE");
|
||||
assert.deepEqual(action.data, {id: FAKE_ID, rows: FAKE_ROWS});
|
||||
// Should be not broadcast by default, so meta should not exist
|
||||
assert.notOk(action.meta);
|
||||
});
|
||||
it("should broadcast the action only if shouldBroadcast is true", () => {
|
||||
feed.onUpdateRows(null, FAKE_ID, FAKE_ROWS, true);
|
||||
feed.onUpdateSection(null, FAKE_ID, {rows: FAKE_ROWS}, true);
|
||||
const action = feed.store.dispatch.firstCall.args[0];
|
||||
// Should be broadcast
|
||||
assert.equal(action.meta.from, MAIN_MESSAGE_TYPE);
|
||||
|
@ -157,18 +226,40 @@ describe("SectionsFeed", () => {
|
|||
});
|
||||
});
|
||||
describe("#onAction", () => {
|
||||
it("should call init() on action INIT", () => {
|
||||
sinon.spy(feed, "init");
|
||||
feed.onAction({type: "INIT"});
|
||||
assert.calledOnce(feed.init);
|
||||
it("should call SectionsManager.init on action PREFS_INITIAL_VALUES", () => {
|
||||
sinon.spy(SectionsManager, "init");
|
||||
feed.onAction({type: "PREFS_INITIAL_VALUES", data: {foo: "bar"}});
|
||||
assert.calledOnce(SectionsManager.init);
|
||||
assert.calledWith(SectionsManager.init, {foo: "bar"});
|
||||
});
|
||||
it("should call SectionsManager.addBuiltInSection on suitable PREF_CHANGED events", () => {
|
||||
sinon.spy(SectionsManager, "addBuiltInSection");
|
||||
feed.onAction({type: "PREF_CHANGED", data: {name: "feeds.section.topstories.options", value: "foo"}});
|
||||
assert.calledOnce(SectionsManager.addBuiltInSection);
|
||||
assert.calledWith(SectionsManager.addBuiltInSection, "feeds.section.topstories", "foo");
|
||||
});
|
||||
it("should call SectionsManager.disableSection on SECTION_DISABLE", () => {
|
||||
sinon.spy(SectionsManager, "disableSection");
|
||||
feed.onAction({type: "SECTION_DISABLE", data: 1234});
|
||||
assert.calledOnce(SectionsManager.disableSection);
|
||||
assert.calledWith(SectionsManager.disableSection, 1234);
|
||||
SectionsManager.disableSection.restore();
|
||||
});
|
||||
it("should call SectionsManager.enableSection on SECTION_ENABLE", () => {
|
||||
sinon.spy(SectionsManager, "enableSection");
|
||||
feed.onAction({type: "SECTION_ENABLE", data: 1234});
|
||||
assert.calledOnce(SectionsManager.enableSection);
|
||||
assert.calledWith(SectionsManager.enableSection, 1234);
|
||||
SectionsManager.enableSection.restore();
|
||||
});
|
||||
it("should emit a ACTION_DISPATCHED event and forward any action in ACTIONS_TO_PROXY if there are any sections", () => {
|
||||
const spy = sinon.spy();
|
||||
const allowedActions = SectionsManager.ACTIONS_TO_PROXY;
|
||||
const disallowedActions = ["PREF_CHANGED", "OPEN_PRIVATE_WINDOW"];
|
||||
feed.init();
|
||||
SectionsManager.on(SectionsManager.ACTION_DISPATCHED, spy);
|
||||
// Make sure we start with no sections - no event should be emitted
|
||||
assert.equal(SectionsManager.sections.size, 0);
|
||||
SectionsManager.sections.clear();
|
||||
feed.onAction({type: allowedActions[0]});
|
||||
assert.notCalled(spy);
|
||||
// Then add a section and check correct behaviour
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
"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 {actionTypes: at} = require("common/Actions.jsm");
|
||||
const {GlobalOverrider} = require("test/unit/utils");
|
||||
|
||||
describe("Top Stories Feed", () => {
|
||||
|
@ -10,34 +10,45 @@ describe("Top Stories Feed", () => {
|
|||
let TOPICS_UPDATE_TIME;
|
||||
let SECTION_ID;
|
||||
let FEED_PREF;
|
||||
let SECTION_OPTIONS_PREF;
|
||||
let instance;
|
||||
let clock;
|
||||
let globals;
|
||||
let sectionsManagerStub;
|
||||
let shortURLStub;
|
||||
|
||||
const FAKE_OPTIONS = {
|
||||
"stories_endpoint": "https://somedomain.org/stories?key=$apiKey",
|
||||
"stories_referrer": "https://somedomain.org/referrer",
|
||||
"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",
|
||||
"provider_description": "provider_desc"
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
FakePrefs.prototype.prefs["feeds.section.topstories.options"] = `{
|
||||
"stories_endpoint": "https://somedomain.org/stories?key=$apiKey",
|
||||
"stories_referrer": "https://somedomain.org/referrer",
|
||||
"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",
|
||||
"provider_description": "provider_desc"
|
||||
}`;
|
||||
FakePrefs.prototype.prefs.apiKeyPref = "test-api-key";
|
||||
|
||||
globals = new GlobalOverrider();
|
||||
globals.set("Services", {locale: {getRequestedLocale: () => "en-CA"}});
|
||||
|
||||
sectionsManagerStub = {
|
||||
onceInitialized: sinon.stub().callsFake(callback => callback()),
|
||||
enableSection: sinon.spy(),
|
||||
disableSection: sinon.spy(),
|
||||
updateSection: sinon.spy(),
|
||||
sections: new Map([["topstories", {options: FAKE_OPTIONS}]])
|
||||
};
|
||||
|
||||
clock = sinon.useFakeTimers();
|
||||
|
||||
shortURLStub = sinon.stub().callsFake(site => site.url);
|
||||
|
||||
({TopStoriesFeed, STORIES_UPDATE_TIME, TOPICS_UPDATE_TIME, SECTION_ID, FEED_PREF, SECTION_OPTIONS_PREF} = injector({
|
||||
({TopStoriesFeed, STORIES_UPDATE_TIME, TOPICS_UPDATE_TIME, SECTION_ID, FEED_PREF} = injector({
|
||||
"lib/ActivityStreamPrefs.jsm": {Prefs: FakePrefs},
|
||||
"lib/ShortURL.jsm": {shortURL: shortURLStub}
|
||||
"lib/ShortURL.jsm": {shortURL: shortURLStub},
|
||||
"lib/SectionsManager.jsm": {SectionsManager: sectionsManagerStub}
|
||||
}));
|
||||
instance = new TopStoriesFeed();
|
||||
instance.store = {getState() { return {}; }, dispatch: sinon.spy()};
|
||||
|
@ -52,42 +63,20 @@ describe("Top Stories Feed", () => {
|
|||
it("should create a TopStoriesFeed", () => {
|
||||
assert.instanceOf(instance, TopStoriesFeed);
|
||||
});
|
||||
it("should initialize endpoints based on prefs", () => {
|
||||
it("should bind parseOptions to SectionsManager.onceInitialized", () => {
|
||||
instance.onAction({type: at.INIT});
|
||||
assert.calledOnce(sectionsManagerStub.onceInitialized);
|
||||
});
|
||||
it("should initialize endpoints based on options", () => {
|
||||
instance.onAction({type: at.INIT});
|
||||
assert.equal("https://somedomain.org/stories?key=test-api-key", instance.stories_endpoint);
|
||||
assert.equal("https://somedomain.org/referrer", instance.stories_referrer);
|
||||
assert.equal("https://somedomain.org/topics?key=test-api-key", instance.topics_endpoint);
|
||||
});
|
||||
it("should register section", () => {
|
||||
const expectedSectionOptions = {
|
||||
id: SECTION_ID,
|
||||
eventSource: "TOP_STORIES",
|
||||
icon: "provider-icon",
|
||||
title: {id: "header_recommended_by", values: {provider: "test-provider"}},
|
||||
rows: [],
|
||||
maxRows: 1,
|
||||
contextMenuOptions: ["CheckBookmark", "SaveToPocket", "Separator", "OpenInNewWindow", "OpenInPrivateWindow", "Separator", "BlockUrl"],
|
||||
infoOption: {
|
||||
header: {id: "pocket_feedback_header"},
|
||||
body: {id: "provider_desc"},
|
||||
link: {
|
||||
href: "https://www.surveymonkey.com/r/newtabffx",
|
||||
id: "pocket_send_feedback"
|
||||
}
|
||||
},
|
||||
emptyState: {
|
||||
message: {id: "topstories_empty_state", values: {provider: "test-provider"}},
|
||||
icon: "check"
|
||||
}
|
||||
};
|
||||
|
||||
it("should enable its section", () => {
|
||||
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
|
||||
}));
|
||||
assert.calledOnce(sectionsManagerStub.enableSection);
|
||||
assert.calledWith(sectionsManagerStub.enableSection, SECTION_ID);
|
||||
});
|
||||
it("should fetch stories on init", () => {
|
||||
instance.fetchStories = sinon.spy();
|
||||
|
@ -104,13 +93,13 @@ describe("Top Stories Feed", () => {
|
|||
it("should not fetch if endpoint not configured", () => {
|
||||
let fetchStub = globals.sandbox.stub();
|
||||
globals.set("fetch", fetchStub);
|
||||
FakePrefs.prototype.prefs["feeds.section.topstories.options"] = "{}";
|
||||
sectionsManagerStub.sections.set("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";
|
||||
sectionsManagerStub.sections.set("topstories", {options: {api_key_pref: "invalid"}});
|
||||
instance.init();
|
||||
|
||||
assert.called(Components.utils.reportError);
|
||||
|
@ -119,31 +108,27 @@ describe("Top Stories Feed", () => {
|
|||
let fakeServices = {prefs: {getCharPref: sinon.spy()}, locale: {getRequestedLocale: 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"
|
||||
}`;
|
||||
sectionsManagerStub.sections.set("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: FEED_PREF});
|
||||
assert.calledOnce(instance.init);
|
||||
});
|
||||
it("should initialize on PREF_CHANGED", () => {
|
||||
instance.init = sinon.spy();
|
||||
instance.onAction({type: at.PREF_CHANGED, data: {name: SECTION_OPTIONS_PREF}});
|
||||
assert.calledOnce(instance.init);
|
||||
});
|
||||
describe("#uninit", () => {
|
||||
it("should disable its section", () => {
|
||||
instance.onAction({type: at.UNINIT});
|
||||
assert.calledOnce(sectionsManagerStub.disableSection);
|
||||
assert.calledWith(sectionsManagerStub.disableSection, SECTION_ID);
|
||||
});
|
||||
});
|
||||
describe("#fetch", () => {
|
||||
|
@ -179,14 +164,12 @@ describe("Top Stories Feed", () => {
|
|||
assert.calledOnce(fetchStub);
|
||||
assert.calledOnce(shortURLStub);
|
||||
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);
|
||||
assert.calledOnce(sectionsManagerStub.updateSection);
|
||||
assert.calledWith(sectionsManagerStub.updateSection, SECTION_ID, {rows: stories});
|
||||
});
|
||||
it("should dispatch events", () => {
|
||||
it("should call SectionsManager.updateSection", () => {
|
||||
instance.dispatchUpdateEvent(123, {});
|
||||
assert.calledOnce(instance.store.dispatch);
|
||||
assert.calledOnce(sectionsManagerStub.updateSection);
|
||||
});
|
||||
it("should report error for unexpected stories response", async () => {
|
||||
let fetchStub = globals.sandbox.stub();
|
||||
|
@ -199,7 +182,7 @@ describe("Top Stories Feed", () => {
|
|||
|
||||
assert.calledOnce(fetchStub);
|
||||
assert.calledWithExactly(fetchStub, instance.stories_endpoint);
|
||||
assert.notCalled(instance.store.dispatch);
|
||||
assert.notCalled(sectionsManagerStub.updateSection);
|
||||
assert.called(Components.utils.reportError);
|
||||
});
|
||||
it("should exclude blocked (dismissed) URLs", async () => {
|
||||
|
@ -212,10 +195,9 @@ describe("Top Stories Feed", () => {
|
|||
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");
|
||||
assert.calledOnce(sectionsManagerStub.updateSection);
|
||||
assert.equal(sectionsManagerStub.updateSection.firstCall.args[1].rows.length, 1);
|
||||
assert.equal(sectionsManagerStub.updateSection.firstCall.args[1].rows[0].url, "not_blocked");
|
||||
});
|
||||
it("should mark stories as new", async () => {
|
||||
let fetchStub = globals.sandbox.stub();
|
||||
|
@ -234,12 +216,11 @@ describe("Top Stories Feed", () => {
|
|||
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, 3);
|
||||
assert.equal(instance.store.dispatch.firstCall.args[0].data.rows[0].type, "now");
|
||||
assert.equal(instance.store.dispatch.firstCall.args[0].data.rows[1].type, "trending");
|
||||
assert.equal(instance.store.dispatch.firstCall.args[0].data.rows[2].type, "trending");
|
||||
assert.calledOnce(sectionsManagerStub.updateSection);
|
||||
assert.equal(sectionsManagerStub.updateSection.firstCall.args[1].rows.length, 3);
|
||||
assert.equal(sectionsManagerStub.updateSection.firstCall.args[1].rows[0].type, "now");
|
||||
assert.equal(sectionsManagerStub.updateSection.firstCall.args[1].rows[1].type, "trending");
|
||||
assert.equal(sectionsManagerStub.updateSection.firstCall.args[1].rows[2].type, "trending");
|
||||
});
|
||||
it("should fetch topics and send event", async () => {
|
||||
let fetchStub = globals.sandbox.stub();
|
||||
|
@ -260,10 +241,8 @@ describe("Top Stories Feed", () => {
|
|||
|
||||
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);
|
||||
assert.calledOnce(sectionsManagerStub.updateSection);
|
||||
assert.calledWithMatch(sectionsManagerStub.updateSection, SECTION_ID, {topics});
|
||||
});
|
||||
it("should report error for unexpected topics response", async () => {
|
||||
let fetchStub = globals.sandbox.stub();
|
||||
|
@ -282,6 +261,7 @@ describe("Top Stories Feed", () => {
|
|||
});
|
||||
describe("#update", () => {
|
||||
it("should fetch stories after update interval", () => {
|
||||
instance.init();
|
||||
instance.fetchStories = sinon.spy();
|
||||
instance.onAction({type: at.SYSTEM_TICK});
|
||||
assert.notCalled(instance.fetchStories);
|
||||
|
@ -291,6 +271,7 @@ describe("Top Stories Feed", () => {
|
|||
assert.calledOnce(instance.fetchStories);
|
||||
});
|
||||
it("should fetch topics after update interval", () => {
|
||||
instance.init();
|
||||
instance.fetchTopics = sinon.spy();
|
||||
instance.onAction({type: at.SYSTEM_TICK});
|
||||
assert.notCalled(instance.fetchTopics);
|
||||
|
|
|
@ -127,6 +127,7 @@ EventEmitter.decorate = function(objectToDecorate) {
|
|||
let emitter = new EventEmitter();
|
||||
objectToDecorate.on = emitter.on.bind(emitter);
|
||||
objectToDecorate.off = emitter.off.bind(emitter);
|
||||
objectToDecorate.once = emitter.once.bind(emitter);
|
||||
objectToDecorate.emit = emitter.emit.bind(emitter);
|
||||
};
|
||||
EventEmitter.prototype = {
|
||||
|
@ -150,6 +151,20 @@ EventEmitter.prototype = {
|
|||
));
|
||||
}
|
||||
},
|
||||
once(event, listener) {
|
||||
return new Promise(resolve => {
|
||||
let handler = (_, first, ...rest) => {
|
||||
this.off(event, handler);
|
||||
if (listener) {
|
||||
listener(event, first, ...rest);
|
||||
}
|
||||
resolve(first);
|
||||
};
|
||||
|
||||
handler._originalListener = listener;
|
||||
this.on(event, handler);
|
||||
});
|
||||
},
|
||||
// All arguments to this method will be sent to listeners
|
||||
emit(event, ...args) {
|
||||
if (!this._eventEmitterListeners || !this._eventEmitterListeners.has(event)) {
|
||||
|
|
Загрузка…
Ссылка в новой задаче