/* 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 ; } } 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 ( {component.feed && this.renderFeed(component.feed)}
Type {component.type} Width {width}
); } 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

{" "} {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 (
{" "}
{" "} {" "}

{" "}
{" "}
{/* 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);