/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at http://mozilla.org/MPL/2.0/. */
import { actionCreators as ac, actionTypes as at } from "common/Actions.mjs";
import { connect } from "react-redux";
import React from "react";
import { SimpleHashRouter } from "./SimpleHashRouter";
// Pref Constants
const PREF_AD_SIZE_MEDIUM_RECTANGLE = "newtabAdSize.mediumRectangle";
const PREF_AD_SIZE_BILLBOARD = "newtabAdSize.billboard";
const PREF_AD_SIZE_LEADERBOARD = "newtabAdSize.leaderboard";
const PREF_CONTEXTUAL_CONTENT_SELECTED_FEED =
"discoverystream.contextualContent.selectedFeed";
const PREF_CONTEXTUAL_CONTENT_FEEDS = "discoverystream.contextualContent.feeds";
const PREF_SECTIONS_ENABLED = "discoverystream.sections.enabled";
const PREF_SPOC_PLACEMENTS = "discoverystream.placements.spocs";
const PREF_SPOC_COUNTS = "discoverystream.placements.spocs.counts";
const Row = props => (
{props.children}
);
function relativeTime(timestamp) {
if (!timestamp) {
return "";
}
const seconds = Math.floor((Date.now() - timestamp) / 1000);
const minutes = Math.floor((Date.now() - timestamp) / 60000);
if (seconds < 2) {
return "just now";
} else if (seconds < 60) {
return `${seconds} seconds ago`;
} else if (minutes === 1) {
return "1 minute ago";
} else if (minutes < 600) {
return `${minutes} minutes ago`;
}
return new Date(timestamp).toLocaleString();
}
export class ToggleStoryButton extends React.PureComponent {
constructor(props) {
super(props);
this.handleClick = this.handleClick.bind(this);
}
handleClick() {
this.props.onClick(this.props.story);
}
render() {
return collapse/open ;
}
}
export class TogglePrefCheckbox extends React.PureComponent {
constructor(props) {
super(props);
this.onChange = this.onChange.bind(this);
}
onChange(event) {
this.props.onChange(this.props.pref, event.target.checked);
}
render() {
return (
<>
{" "}
{this.props.pref}{" "}
>
);
}
}
export class Personalization extends React.PureComponent {
constructor(props) {
super(props);
this.togglePersonalization = this.togglePersonalization.bind(this);
}
togglePersonalization() {
this.props.dispatch(
ac.OnlyToMain({
type: at.DISCOVERY_STREAM_PERSONALIZATION_TOGGLE,
})
);
}
render() {
const { lastUpdated, initialized } = this.props.state.Personalization;
return (
Personalization Last Updated
{relativeTime(lastUpdated) || "(no data)"}
Personalization Initialized
{initialized ? "true" : "false"}
);
}
}
export class DiscoveryStreamAdminUI extends React.PureComponent {
constructor(props) {
super(props);
this.restorePrefDefaults = this.restorePrefDefaults.bind(this);
this.setConfigValue = this.setConfigValue.bind(this);
this.expireCache = this.expireCache.bind(this);
this.refreshCache = this.refreshCache.bind(this);
this.showPlaceholder = this.showPlaceholder.bind(this);
this.idleDaily = this.idleDaily.bind(this);
this.systemTick = this.systemTick.bind(this);
this.syncRemoteSettings = this.syncRemoteSettings.bind(this);
this.onStoryToggle = this.onStoryToggle.bind(this);
this.handleWeatherSubmit = this.handleWeatherSubmit.bind(this);
this.handleWeatherUpdate = this.handleWeatherUpdate.bind(this);
this.resetBlocks = this.resetBlocks.bind(this);
this.refreshInferredPersonalization =
this.refreshInferredPersonalization.bind(this);
this.refreshTopicSelectionCache =
this.refreshTopicSelectionCache.bind(this);
this.toggleTBRFeed = this.toggleTBRFeed.bind(this);
this.handleSectionsToggle = this.handleSectionsToggle.bind(this);
this.toggleIABBanners = this.toggleIABBanners.bind(this);
this.state = {
toggledStories: {},
weatherQuery: "",
};
}
setConfigValue(configName, configValue) {
this.props.dispatch(
ac.OnlyToMain({
type: at.DISCOVERY_STREAM_CONFIG_SET_VALUE,
data: { name: configName, value: configValue },
})
);
}
restorePrefDefaults() {
this.props.dispatch(
ac.OnlyToMain({
type: at.DISCOVERY_STREAM_CONFIG_RESET_DEFAULTS,
})
);
}
refreshCache() {
const { config } = this.props.state.DiscoveryStream;
this.props.dispatch(
ac.OnlyToMain({
type: at.DISCOVERY_STREAM_CONFIG_CHANGE,
data: config,
})
);
}
refreshInferredPersonalization() {
this.props.dispatch(
ac.OnlyToMain({
type: at.INFERRED_PERSONALIZATION_REFRESH,
})
);
}
refreshTopicSelectionCache() {
this.props.dispatch(
ac.SetPref("discoverystream.topicSelection.onboarding.displayCount", 0)
);
this.props.dispatch(
ac.SetPref("discoverystream.topicSelection.onboarding.maybeDisplay", true)
);
}
dispatchSimpleAction(type) {
this.props.dispatch(
ac.OnlyToMain({
type,
})
);
}
resetBlocks() {
this.props.dispatch(
ac.OnlyToMain({
type: at.DISCOVERY_STREAM_DEV_BLOCKS_RESET,
})
);
}
systemTick() {
this.dispatchSimpleAction(at.DISCOVERY_STREAM_DEV_SYSTEM_TICK);
}
expireCache() {
this.dispatchSimpleAction(at.DISCOVERY_STREAM_DEV_EXPIRE_CACHE);
}
showPlaceholder() {
this.dispatchSimpleAction(at.DISCOVERY_STREAM_DEV_SHOW_PLACEHOLDER);
}
toggleTBRFeed(e) {
const feed = e.target.value;
const selectedFeed = PREF_CONTEXTUAL_CONTENT_SELECTED_FEED;
this.props.dispatch(ac.SetPref(selectedFeed, feed));
}
idleDaily() {
this.dispatchSimpleAction(at.DISCOVERY_STREAM_DEV_IDLE_DAILY);
}
syncRemoteSettings() {
this.dispatchSimpleAction(at.DISCOVERY_STREAM_DEV_SYNC_RS);
}
handleWeatherUpdate(e) {
this.setState({ weatherQuery: e.target.value || "" });
}
handleWeatherSubmit(e) {
e.preventDefault();
const { weatherQuery } = this.state;
this.props.dispatch(ac.SetPref("weather.query", weatherQuery));
}
toggleIABBanners(e) {
const { pressed, id } = e.target;
// Set the active pref to true/false
switch (id) {
case "newtab_billboard":
// Update boolean pref for billboard ad size
this.props.dispatch(ac.SetPref(PREF_AD_SIZE_BILLBOARD, pressed));
break;
case "newtab_leaderboard":
// Update boolean pref for billboard ad size
this.props.dispatch(ac.SetPref(PREF_AD_SIZE_LEADERBOARD, pressed));
break;
case "newtab_rectangle":
// Update boolean pref for mediumRectangle (MREC) ad size
this.props.dispatch(ac.SetPref(PREF_AD_SIZE_MEDIUM_RECTANGLE, pressed));
break;
}
// Note: The counts array is passively updated whenever the placements array is updated.
// The default pref values for each are:
// PREF_SPOC_PLACEMENTS: "newtab_spocs"
// PREF_SPOC_COUNTS: "6"
const generateSpocPrefValues = () => {
const placements =
this.props.otherPrefs[PREF_SPOC_PLACEMENTS]?.split(",")
.map(item => item.trim())
.filter(item => item) || [];
const counts =
this.props.otherPrefs[PREF_SPOC_COUNTS]?.split(",")
.map(item => item.trim())
.filter(item => item) || [];
// Confirm that the IAB type will have a count value of "1"
const supportIABAdTypes = [
"newtab_leaderboard",
"newtab_rectangle",
"newtab_billboard",
];
let countValue;
if (supportIABAdTypes.includes(id)) {
countValue = "1"; // Default count value for all IAB ad types
} else {
throw new Error("IAB ad type not supported");
}
if (pressed) {
// If pressed is true, add the id to the placements array
if (!placements.includes(id)) {
placements.push(id);
counts.push(countValue);
}
} else {
// If pressed is false, remove the id from the placements array
const index = placements.indexOf(id);
if (index !== -1) {
placements.splice(index, 1);
counts.splice(index, 1);
}
}
return {
placements: placements.join(", "),
counts: counts.join(", "),
};
};
const { placements, counts } = generateSpocPrefValues();
// Update prefs with new values
this.props.dispatch(ac.SetPref(PREF_SPOC_PLACEMENTS, placements));
this.props.dispatch(ac.SetPref(PREF_SPOC_COUNTS, counts));
}
handleSectionsToggle(e) {
const { pressed } = e.target;
this.props.dispatch(ac.SetPref(PREF_SECTIONS_ENABLED, pressed));
this.props.dispatch(
ac.SetPref("discoverystream.sections.cards.enabled", pressed)
);
this.props.dispatch(
ac.SetPref("discoverystream.sections.cards.thumbsUpDown.enabled", pressed)
);
}
renderComponent(width, component) {
return (
Type
{component.type}
Width
{width}
{component.feed && this.renderFeed(component.feed)}
);
}
renderWeatherData() {
const { suggestions } = this.props.state.Weather;
let weatherTable;
if (suggestions) {
weatherTable = (
{suggestions.map(suggestion => (
{suggestion.city_name}
{JSON.stringify(suggestion, null, 2)}
))}
);
}
return weatherTable;
}
renderPersonalizationData() {
const {
inferredInterests,
coarseInferredInterests,
coarsePrivateInferredInterests,
} = this.props.state.InferredPersonalization;
return (
{" "}
Inferred Intrests:
{JSON.stringify(inferredInterests, null, 2)} Coarse Inferred
Interests:
{JSON.stringify(coarseInferredInterests, null, 2)} Coarse
Inferred Interests With Differential Privacy:
{JSON.stringify(coarsePrivateInferredInterests, null, 2)}
);
}
renderFeedData(url) {
const { feeds } = this.props.state.DiscoveryStream;
const feed = feeds.data[url].data;
return (
Feed url: {url}
{feed.recommendations?.map(story => this.renderStoryData(story))}
);
}
renderFeedsData() {
const { feeds } = this.props.state.DiscoveryStream;
return (
{Object.keys(feeds.data).map(url => this.renderFeedData(url))}
);
}
renderImpressionsData() {
const { impressions } = this.props.state.DiscoveryStream;
return (
<>
Feed Impressions
{Object.keys(impressions.feed).map(key => {
return (
{key}
{relativeTime(impressions.feed[key]) || "(no data)"}
);
})}
>
);
}
renderBlocksData() {
const { blocks } = this.props.state.DiscoveryStream;
return (
<>
Blocks
Reset Blocks
{" "}
{Object.keys(blocks).map(key => {
return (
{key}
);
})}
>
);
}
renderSpocs() {
const { spocs } = this.props.state.DiscoveryStream;
const unifiedAdsSpocsEnabled =
this.props.otherPrefs["unifiedAds.spocs.enabled"];
const unifiedAdsEndpoint = this.props.otherPrefs["unifiedAds.endpoint"];
let spocsData = [];
if (
spocs.data &&
spocs.data.newtab_spocs &&
spocs.data.newtab_spocs.items
) {
spocsData = spocs.data.newtab_spocs.items || [];
}
return (
spocs_endpoint
{unifiedAdsSpocsEnabled
? unifiedAdsEndpoint
: spocs.spocs_endpoint}
Data last fetched
{relativeTime(spocs.lastUpdated)}
Spoc data
{spocsData.map(spoc => this.renderStoryData(spoc))}
Spoc frequency caps
{spocs.frequency_caps.map(spoc => this.renderStoryData(spoc))}
);
}
onStoryToggle(story) {
const { toggledStories } = this.state;
this.setState({
toggledStories: {
...toggledStories,
[story.id]: !toggledStories[story.id],
},
});
}
renderStoryData(story) {
let storyData = "";
if (this.state.toggledStories[story.id]) {
storyData = JSON.stringify(story, null, 2);
}
return (
{story.id}
{storyData}
);
}
renderFeed(feed) {
const { feeds } = this.props.state.DiscoveryStream;
if (!feed.url) {
return null;
}
return (
Feed url
{feed.url}
Data last fetched
{relativeTime(
feeds.data[feed.url] ? feeds.data[feed.url].lastUpdated : null
) || "(no data)"}
);
}
render() {
const prefToggles = "enabled collapsible".split(" ");
const { config, layout } = this.props.state.DiscoveryStream;
const personalized =
this.props.otherPrefs["discoverystream.personalization.enabled"];
const selectedFeed =
this.props.otherPrefs[PREF_CONTEXTUAL_CONTENT_SELECTED_FEED];
const sectionsEnabled = this.props.otherPrefs[PREF_SECTIONS_ENABLED];
const TBRFeeds = this.props.otherPrefs[PREF_CONTEXTUAL_CONTENT_FEEDS].split(
","
)
.map(s => s.trim())
.filter(item => item);
// Prefs for IAB Banners
const mediumRectangleEnabled =
this.props.otherPrefs[PREF_AD_SIZE_MEDIUM_RECTANGLE];
const billboardsEnabled = this.props.otherPrefs[PREF_AD_SIZE_BILLBOARD];
const leaderboardEnabled = this.props.otherPrefs[PREF_AD_SIZE_LEADERBOARD];
const spocPlacements = this.props.otherPrefs[PREF_SPOC_PLACEMENTS];
const mediumRectangleEnabledPressed =
mediumRectangleEnabled && spocPlacements.includes("newtab_rectangle");
const billboardPressed =
billboardsEnabled && spocPlacements.includes("newtab_billboard");
const leaderboardPressed =
leaderboardEnabled && spocPlacements.includes("newtab_leaderboard");
return (
Restore Pref Defaults
{" "}
Refresh Cache
Expire Cache
{" "}
Trigger System Tick
{" "}
Trigger Idle Daily
Refresh Inferred Personalization
Sync Remote Settings
{" "}
Refresh Topic selection count
Show Placeholder Cards
{" "}
{TBRFeeds.map(feed => (
{feed}
))}
{/* Collapsible Sections for experiments for easy on/off */}
IAB Banner Ad Sizes
{prefToggles.map(pref => (
))}
Layout
{layout.map((row, rowIndex) => (
{row.components.map((component, componentIndex) => (
{this.renderComponent(row.width, component)}
))}
))}
Personalization
Spocs
{this.renderSpocs()}
Feeds Data
{this.renderFeedsData()}
Impressions Data
{this.renderImpressionsData()}
Blocked Data
{this.renderBlocksData()}
Weather Data
{this.renderWeatherData()}
Personalization Data
{this.renderPersonalizationData()}
);
}
}
export class DiscoveryStreamAdminInner extends React.PureComponent {
constructor(props) {
super(props);
this.setState = this.setState.bind(this);
}
render() {
return (
Discovery Stream Admin
{" "}
Need to access the ASRouter Admin dev tools?{" "}
Click here
);
}
}
export class CollapseToggle extends React.PureComponent {
constructor(props) {
super(props);
this.onCollapseToggle = this.onCollapseToggle.bind(this);
this.state = { collapsed: false };
}
get renderAdmin() {
const { props } = this;
return props.location.hash && props.location.hash.startsWith("#devtools");
}
onCollapseToggle(e) {
e.preventDefault();
this.setState(state => ({ collapsed: !state.collapsed }));
}
setBodyClass() {
if (this.renderAdmin && !this.state.collapsed) {
globalThis.document.body.classList.add("no-scroll");
} else {
globalThis.document.body.classList.remove("no-scroll");
}
}
componentDidMount() {
this.setBodyClass();
}
componentDidUpdate() {
this.setBodyClass();
}
componentWillUnmount() {
globalThis.document.body.classList.remove("no-scroll");
}
render() {
const { props } = this;
const { renderAdmin } = this;
const isCollapsed = this.state.collapsed || !renderAdmin;
const label = `${isCollapsed ? "Expand" : "Collapse"} devtools`;
return (
{renderAdmin ? (
) : null}
);
}
}
const _DiscoveryStreamAdmin = props => (
);
export const DiscoveryStreamAdmin = connect(state => ({
Sections: state.Sections,
DiscoveryStream: state.DiscoveryStream,
Personalization: state.Personalization,
InferredPersonalization: state.InferredPersonalization,
Prefs: state.Prefs,
Weather: state.Weather,
}))(_DiscoveryStreamAdmin);