/* 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"; // 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_SECTIONS_ENABLED = "discoverystream.sections.enabled"; const PREF_SPOC_PLACEMENTS = "discoverystream.placements.spocs"; const PREF_SPOC_COUNTS = "discoverystream.placements.spocs.counts"; const PREF_CONTEXTUAL_ADS_ENABLED = "discoverystream.sections.contextualAds.enabled"; const PREF_CONTEXTUAL_BANNER_PLACEMENTS = "discoverystream.placements.contextualBanners"; const PREF_CONTEXTUAL_BANNER_COUNTS = "discoverystream.placements.contextualBanners.counts"; const PREF_UNIFIED_ADS_ENABLED = "unifiedAds.spocs.enabled"; const PREF_UNIFIED_ADS_ENDPOINT = "unifiedAds.endpoint"; const PREF_ALLOWED_ENDPOINTS = "discoverystream.endpoints"; const PREF_OHTTP_CONFIG = "discoverystream.ohttp.configURL"; const PREF_OHTTP_RELAY = "discoverystream.ohttp.relayURL"; 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 DiscoveryStreamAdminUI extends React.PureComponent { constructor(props) { super(props); 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.refreshInferredPersonalizationAndDebug = this.refreshInferredPersonalizationAndDebug.bind(this); this.refreshTopicSelectionCache = this.refreshTopicSelectionCache.bind(this); this.requestDebugFeatures = this.requestDebugFeatures.bind(this); this.setDebugOverrides = this.setDebugOverrides.bind(this); this.handleDebugOverridesToggle = this.handleDebugOverridesToggle.bind(this); this.handleDebugOverrideChange = this.handleDebugOverrideChange.bind(this); this.handleResetAllOverrides = this.handleResetAllOverrides.bind(this); this.handleSectionsToggle = this.handleSectionsToggle.bind(this); this.toggleIABBanners = this.toggleIABBanners.bind(this); this.handleAllizomToggle = this.handleAllizomToggle.bind(this); this.sendConversionEvent = this.sendConversionEvent.bind(this); this.state = { toggledStories: {}, weatherQuery: "", pendingOverrides: {}, overridesTogglePressed: null, }; } componentDidMount() { this.requestDebugFeatures(); } refreshCache() { this.props.dispatch( ac.OnlyToMain({ type: at.DISCOVERY_STREAM_DEV_REFRESH_CACHE, }) ); } refreshInferredPersonalization() { this.props.dispatch( ac.OnlyToMain({ type: at.INFERRED_PERSONALIZATION_REFRESH, }) ); } refreshInferredPersonalizationAndDebug() { this.refreshInferredPersonalization(); } requestDebugFeatures() { this.props.dispatch( ac.OnlyToMain({ type: at.INFERRED_PERSONALIZATION_DEBUG_FEATURES_REQUEST, }) ); } setDebugOverrides(overrides) { this.props.dispatch( ac.OnlyToMain({ type: at.INFERRED_PERSONALIZATION_DEBUG_OVERRIDES_SET, data: overrides, }) ); } getDebugFeaturesList() { const { debugFeatures } = this.props.state.InferredPersonalization; if (!debugFeatures) { return []; } return Object.keys(debugFeatures) .sort() .filter(featureName => featureName !== "clicks") .map(featureName => ({ name: featureName, ...debugFeatures[featureName], })); } getOverrideValues(features, fallbackToCurrent = false) { const overrides = {}; for (const feature of features) { let value = feature.overrideValue; if (!Number.isFinite(value) && fallbackToCurrent) { value = Number.isFinite(feature.currentValue) ? feature.currentValue : 0; } if (Number.isFinite(value)) { overrides[feature.name] = value; } } return overrides; } handleDebugOverridesToggle(e) { const { pressed } = e.target; const features = this.getDebugFeaturesList(); const currentOverrides = this.getOverrideValues(features, true); if (!pressed) { this.setState({ pendingOverrides: { ...currentOverrides }, overridesTogglePressed: false, }); this.setDebugOverrides(null); return; } const overrides = Object.keys(this.state.pendingOverrides).length ? { ...this.state.pendingOverrides } : currentOverrides; this.setState({ overridesTogglePressed: true }); this.setDebugOverrides(overrides); } handleDebugOverrideChange(featureName, value) { const features = this.getDebugFeaturesList(); const overrides = Object.keys(this.state.pendingOverrides).length ? { ...this.state.pendingOverrides } : this.getOverrideValues(features, true); overrides[featureName] = value; this.setState({ pendingOverrides: { ...overrides } }); if (Object.keys(this.getOverrideValues(features)).length) { this.setDebugOverrides(overrides); } } handleResetAllOverrides() { const features = this.getDebugFeaturesList(); const overrides = Object.fromEntries( features.map(({ name: featureName }) => [featureName, 0]) ); this.setState({ pendingOverrides: { ...overrides } }); if (Object.keys(this.getOverrideValues(features)).length) { this.setDebugOverrides(overrides); } } 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); } 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)); // If contextual ads, sections, and one of the banners are enabled // update the contextualBanner prefs to include the banner value and count // Else, clear the prefs if (PREF_CONTEXTUAL_ADS_ENABLED && PREF_SECTIONS_ENABLED) { if (PREF_AD_SIZE_BILLBOARD && placements.includes("newtab_billboard")) { this.props.dispatch( ac.SetPref(PREF_CONTEXTUAL_BANNER_PLACEMENTS, "newtab_billboard") ); this.props.dispatch(ac.SetPref(PREF_CONTEXTUAL_BANNER_COUNTS, "1")); } else if ( PREF_AD_SIZE_LEADERBOARD && placements.includes("newtab_leaderboard") ) { this.props.dispatch( ac.SetPref(PREF_CONTEXTUAL_BANNER_PLACEMENTS, "newtab_leaderboard") ); this.props.dispatch(ac.SetPref(PREF_CONTEXTUAL_BANNER_COUNTS, "1")); } else { this.props.dispatch(ac.SetPref(PREF_CONTEXTUAL_BANNER_PLACEMENTS, "")); this.props.dispatch(ac.SetPref(PREF_CONTEXTUAL_BANNER_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) ); } sendConversionEvent() { const detail = { partnerId: "295BEEF7-1E3B-4128-B8F8-858E12AA660B", lookbackDays: 7, impressionType: "default", }; const event = new CustomEvent("FirefoxConversionNotification", { detail, bubbles: true, composed: true, }); window?.dispatchEvent(event); } 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; const inferredPersonalizationEnabled = Boolean( this.props.otherPrefs?.[ "discoverystream.sections.personalization.inferred.enabled" ] ); const hasModelData = inferredInterests !== undefined || coarseInferredInterests !== undefined || coarsePrivateInferredInterests !== undefined; if (!inferredPersonalizationEnabled || !hasModelData) { return null; } return (
{this.renderInferredPersonalizationOverrides()}
Raw Interest Values
{JSON.stringify(inferredInterests, null, 2)}
Differentially Private Interest Vector{" "}
                {JSON.stringify(coarsePrivateInferredInterests, null, 2)}
              
); } renderInferredPersonalizationOverrides() { const { lastUpdated } = this.props.state.InferredPersonalization; const features = this.getDebugFeaturesList(); if (!features.length) { return null; } const overrides = this.getOverrideValues(features); const storeOverridesEnabled = !!Object.keys(overrides).length; const overridesEnabled = this.state.overridesTogglePressed !== null ? this.state.overridesTogglePressed : storeOverridesEnabled; const hasAnyNonZeroOverride = Object.values(overrides).some( value => Number.isFinite(value) && value > 0 ); return ( <>

Inferred Personalization

Last refreshed {relativeTime(lastUpdated) || "(no data)"}
); })}
Overrides
{/** @backward-compat { version 150 } React 16 (cached page) uses ontoggle; React 19 uses onToggle. Remove onToggle once Firefox 150 reaches Release. */}
Score {features.map(feature => { const maxValue = Math.max(0, (feature.numValues || 1) - 1); const currentCoarseValue = feature.currentValue; const pendingValue = this.state.pendingOverrides[feature.name]; let displayValue = 0; if (Number.isFinite(pendingValue)) { displayValue = pendingValue; } else if (Number.isFinite(feature.overrideValue)) { displayValue = feature.overrideValue; } else if (Number.isFinite(feature.currentValue)) { displayValue = feature.currentValue; } return ( {feature.name} {Number.isFinite(currentCoarseValue) ? currentCoarseValue : "-"}
this.handleDebugOverrideChange( feature.name, Number(e.target.value) ) } /> {displayValue}
); } 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}
); } handleAllizomToggle(e) { const prefs = this.props.otherPrefs; const unifiedAdsSpocsEnabled = prefs[PREF_UNIFIED_ADS_ENABLED]; if (!unifiedAdsSpocsEnabled) { return; } const { pressed } = e.target; const { dispatch } = this.props; const allowedEndpoints = prefs[PREF_ALLOWED_ENDPOINTS]; const setPref = (pref = "", value = "") => { dispatch(ac.SetPref(pref, value)); }; const clearPref = (pref = "") => { dispatch( ac.OnlyToMain({ type: at.CLEAR_PREF, data: { name: pref, }, }) ); }; if (pressed) { setPref(PREF_UNIFIED_ADS_ENDPOINT, "https://ads.allizom.org/"); setPref( PREF_ALLOWED_ENDPOINTS, `${allowedEndpoints},https://ads.allizom.org/` ); setPref( PREF_OHTTP_CONFIG, "https://stage.ohttp-gateway.nonprod.webservices.mozgcp.net/ohttp-configs" ); setPref( PREF_OHTTP_RELAY, "https://mozilla-ohttp-relay-test.edgecompute.app/" ); } else { clearPref(PREF_UNIFIED_ADS_ENDPOINT); clearPref(PREF_ALLOWED_ENDPOINTS); clearPref(PREF_OHTTP_CONFIG); clearPref(PREF_OHTTP_RELAY); } } renderSpocs() { const { spocs } = this.props.state.DiscoveryStream; const unifiedAdsSpocsEnabled = this.props.otherPrefs[PREF_UNIFIED_ADS_ENABLED]; // Determine which mechanism is querying the UAPI ads server const PREF_UNIFIED_ADS_ADSFEED_ENABLED = "unifiedAds.adsFeed.enabled"; const adsFeedEnabled = this.props.otherPrefs[PREF_UNIFIED_ADS_ADSFEED_ENABLED]; const unifiedAdsEndpoint = this.props.otherPrefs[PREF_UNIFIED_ADS_ENDPOINT]; const spocsEndpoint = unifiedAdsSpocsEnabled ? unifiedAdsEndpoint : spocs.spocs_endpoint; let spocsData = []; let allizomEnabled = spocsEndpoint?.includes("allizom"); if ( spocs.data && spocs.data.newtab_spocs && spocs.data.newtab_spocs.items ) { spocsData = spocs.data.newtab_spocs.items || []; } return (
{/** @backward-compat { version 150 } React 16 (cached page) uses ontoggle; React 19 uses onToggle. Remove onToggle once Firefox 150 reaches Release. */} adsfeed enabled {adsFeedEnabled ? "true" : "false"} spocs endpoint {spocsEndpoint} 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 { layout } = this.props.state.DiscoveryStream; const sectionsEnabled = this.props.otherPrefs[PREF_SECTIONS_ENABLED]; // 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 (

{" "} {" "}
{" "}
{" "}
{/** @backward-compat { version 150 } React 16 (cached page) uses ontoggle; React 19 uses onToggle. Remove onToggle once Firefox 150 reaches Release. */}
{/* Collapsible Sections for experiments for easy on/off */}
IAB Banner Ad Sizes
{/** @backward-compat { version 150 } React 16 (cached page) uses ontoggle; React 19 uses onToggle. Remove onToggle once Firefox 150 reaches Release. */}
{/** @backward-compat { version 150 } React 16 (cached page) uses ontoggle; React 19 uses onToggle. Remove onToggle once Firefox 150 reaches Release. */}
{/** @backward-compat { version 150 } React 16 (cached page) uses ontoggle; React 19 uses onToggle. Remove onToggle once Firefox 150 reaches Release. */}

Layout

{layout.map((row, rowIndex) => (
{row.components.map((component, componentIndex) => (
{this.renderComponent(row.width, component)}
))}
))}

Spocs

{this.renderSpocs()}

Feeds Data

{this.renderFeedsData()}

Impressions Data

{this.renderImpressionsData()}

Blocked Data

{this.renderBlocksData()}

Weather Data

{this.renderWeatherData()} {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 function CollapseToggle(props) { const { devtoolsCollapsed } = props; const label = `${devtoolsCollapsed ? "Expand" : "Collapse"} devtools`; return ( <> {!devtoolsCollapsed ? ( ) : null} ); } const _DiscoveryStreamAdmin = props => ; export const DiscoveryStreamAdmin = connect(state => ({ Sections: state.Sections, DiscoveryStream: state.DiscoveryStream, InferredPersonalization: state.InferredPersonalization, Prefs: state.Prefs, Weather: state.Weather, }))(_DiscoveryStreamAdmin);