/* 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.sys.mjs"; import { ASRouterUtils } from "../../asrouter/asrouter-utils"; import { connect } from "react-redux"; import React from "react"; import { SimpleHashRouter } from "./SimpleHashRouter"; import { CopyButton } from "./CopyButton"; 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(); } const LAYOUT_VARIANTS = { basic: "Basic default layout (on by default in nightly)", staging_spocs: "A layout with all spocs shown", "dev-test-all": "A little bit of everything. Good layout for testing all components", "dev-test-feeds": "Stress testing for slow feeds", }; 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 ToggleMessageJSON extends React.PureComponent { constructor(props) { super(props); this.handleClick = this.handleClick.bind(this); } handleClick() { this.props.toggleJSON(this.props.msgId); } render() { let iconName = this.props.isCollapsed ? "icon icon-arrowhead-forward-small" : "icon icon-arrowhead-down-small"; 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 DiscoveryStreamAdmin 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.idleDaily = this.idleDaily.bind(this); this.systemTick = this.systemTick.bind(this); this.syncRemoteSettings = this.syncRemoteSettings.bind(this); this.changeEndpointVariant = this.changeEndpointVariant.bind(this); this.onStoryToggle = this.onStoryToggle.bind(this); this.state = { toggledStories: {}, }; } setConfigValue(name, value) { this.props.dispatch( ac.OnlyToMain({ type: at.DISCOVERY_STREAM_CONFIG_SET_VALUE, data: { name, value }, }) ); } restorePrefDefaults(event) { 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, }) ); } dispatchSimpleAction(type) { this.props.dispatch( ac.OnlyToMain({ type, }) ); } systemTick() { this.dispatchSimpleAction(at.DISCOVERY_STREAM_DEV_SYSTEM_TICK); } expireCache() { this.dispatchSimpleAction(at.DISCOVERY_STREAM_DEV_EXPIRE_CACHE); } idleDaily() { this.dispatchSimpleAction(at.DISCOVERY_STREAM_DEV_IDLE_DAILY); } syncRemoteSettings() { this.dispatchSimpleAction(at.DISCOVERY_STREAM_DEV_SYNC_RS); } changeEndpointVariant(event) { const endpoint = this.props.state.DiscoveryStream.config.layout_endpoint; if (endpoint) { this.setConfigValue( "layout_endpoint", endpoint.replace( /layout_variant=.+/, `layout_variant=${event.target.value}` ) ); } } renderComponent(width, component) { return ( {component.feed && this.renderFeed(component.feed)}
Type {component.type} Width {width}
); } isCurrentVariant(id) { const endpoint = this.props.state.DiscoveryStream.config.layout_endpoint; const isMatch = endpoint && !!endpoint.match(`layout_variant=${id}`); return isMatch; } 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))} ); } renderSpocs() { const { spocs } = this.props.state.DiscoveryStream; let spocsData = []; if (spocs.data && spocs.data.spocs && spocs.data.spocs.items) { spocsData = spocs.data.spocs.items || []; } return (
spocs_endpoint {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 hardcoded_layout show_spocs collapsible".split( " " ); const { config, lastUpdated, layout } = this.props.state.DiscoveryStream; const personalized = this.props.otherPrefs["discoverystream.personalization.enabled"]; return (
{" "}
{" "} {" "}
{prefToggles.map(pref => ( ))}

Endpoint variant

You can also change this manually by changing this pref:{" "} browser.newtabpage.activity-stream.discoverystream.config

{Object.keys(LAYOUT_VARIANTS).map(id => ( ))}
{id} {LAYOUT_VARIANTS[id]}

Caching info

Data last fetched {relativeTime(lastUpdated) || "(no data)"}

Layout

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

Personalization

Spocs

{this.renderSpocs()}

Feeds Data

{this.renderFeedsData()}
); } } export class ASRouterAdminInner extends React.PureComponent { constructor(props) { super(props); this.handleEnabledToggle = this.handleEnabledToggle.bind(this); this.handleUserPrefToggle = this.handleUserPrefToggle.bind(this); this.onChangeMessageFilter = this.onChangeMessageFilter.bind(this); this.onChangeMessageGroupsFilter = this.onChangeMessageGroupsFilter.bind(this); this.unblockAll = this.unblockAll.bind(this); this.handleClearAllImpressionsByProvider = this.handleClearAllImpressionsByProvider.bind(this); this.handleExpressionEval = this.handleExpressionEval.bind(this); this.onChangeTargetingParameters = this.onChangeTargetingParameters.bind(this); this.onChangeAttributionParameters = this.onChangeAttributionParameters.bind(this); this.setAttribution = this.setAttribution.bind(this); this.onCopyTargetingParams = this.onCopyTargetingParams.bind(this); this.onNewTargetingParams = this.onNewTargetingParams.bind(this); this.handleOpenPB = this.handleOpenPB.bind(this); this.selectPBMessage = this.selectPBMessage.bind(this); this.resetPBJSON = this.resetPBJSON.bind(this); this.resetPBMessageState = this.resetPBMessageState.bind(this); this.toggleJSON = this.toggleJSON.bind(this); this.toggleAllMessages = this.toggleAllMessages.bind(this); this.resetGroups = this.resetGroups.bind(this); this.onMessageFromParent = this.onMessageFromParent.bind(this); this.setStateFromParent = this.setStateFromParent.bind(this); this.setState = this.setState.bind(this); this.state = { messageFilter: "all", messageGroupsFilter: "all", collapsedMessages: [], modifiedMessages: [], selectedPBMessage: "", evaluationStatus: {}, stringTargetingParameters: null, newStringTargetingParameters: null, copiedToClipboard: false, attributionParameters: { source: "addons.mozilla.org", medium: "referral", campaign: "non-fx-button", content: `rta:${btoa("uBlock0@raymondhill.net")}`, experiment: "ua-onboarding", variation: "chrome", ua: "Google Chrome 123", dltoken: "00000000-0000-0000-0000-000000000000", }, }; } onMessageFromParent({ type, data }) { // These only exists due to onPrefChange events in ASRouter switch (type) { case "UpdateAdminState": { this.setStateFromParent(data); break; } } } setStateFromParent(data) { this.setState(data); if (!this.state.stringTargetingParameters) { const stringTargetingParameters = {}; for (const param of Object.keys(data.targetingParameters)) { stringTargetingParameters[param] = JSON.stringify( data.targetingParameters[param], null, 2 ); } this.setState({ stringTargetingParameters }); } } componentWillMount() { ASRouterUtils.addListener(this.onMessageFromParent); const endpoint = ASRouterUtils.getPreviewEndpoint(); ASRouterUtils.sendMessage({ type: "ADMIN_CONNECT_STATE", data: { endpoint }, }).then(this.setStateFromParent); } handleBlock(msg) { return () => ASRouterUtils.blockById(msg.id); } handleUnblock(msg) { return () => ASRouterUtils.unblockById(msg.id); } resetJSON(msg) { // reset the displayed JSON for the given message document.getElementById(`${msg.id}-textarea`).value = JSON.stringify( msg, null, 2 ); // remove the message from the list of modified IDs let index = this.state.modifiedMessages.indexOf(msg.id); this.setState(prevState => ({ modifiedMessages: [ ...prevState.modifiedMessages.slice(0, index), ...prevState.modifiedMessages.slice(index + 1), ], })); } handleOverride(id) { return () => ASRouterUtils.overrideMessage(id).then(state => { this.setStateFromParent(state); this.props.notifyContent({ message: state.message, }); }); } resetPBMessageState() { // Iterate over Private Browsing messages and block/unblock each one to clear impressions const PBMessages = this.state.messages.filter( message => message.template === "pb_newtab" ); // messages from state go here PBMessages.forEach(message => { if (message?.id) { ASRouterUtils.blockById(message.id); ASRouterUtils.unblockById(message.id); } }); // Clear the selected messages & radio buttons document.getElementById("clear radio").checked = true; this.selectPBMessage("clear"); } resetPBJSON(msg) { // reset the displayed JSON for the given message document.getElementById(`${msg.id}-textarea`).value = JSON.stringify( msg, null, 2 ); } handleOpenPB() { ASRouterUtils.sendMessage({ type: "FORCE_PRIVATE_BROWSING_WINDOW", data: { message: { content: this.state.selectedPBMessage } }, }); } expireCache() { ASRouterUtils.sendMessage({ type: "EXPIRE_QUERY_CACHE" }); } resetPref() { ASRouterUtils.sendMessage({ type: "RESET_PROVIDER_PREF" }); } resetGroups(id, value) { ASRouterUtils.sendMessage({ type: "RESET_GROUPS_STATE", }).then(this.setStateFromParent); } handleExpressionEval() { const context = {}; for (const param of Object.keys(this.state.stringTargetingParameters)) { const value = this.state.stringTargetingParameters[param]; context[param] = value ? JSON.parse(value) : null; } ASRouterUtils.sendMessage({ type: "EVALUATE_JEXL_EXPRESSION", data: { expression: this.refs.expressionInput.value, context, }, }).then(this.setStateFromParent); } onChangeTargetingParameters(event) { const { name } = event.target; const { value } = event.target; this.setState(({ stringTargetingParameters }) => { let targetingParametersError = null; const updatedParameters = { ...stringTargetingParameters }; updatedParameters[name] = value; try { JSON.parse(value); } catch (e) { console.error(`Error parsing value of parameter ${name}`); targetingParametersError = { id: name }; } return { copiedToClipboard: false, evaluationStatus: {}, stringTargetingParameters: updatedParameters, targetingParametersError, }; }); } unblockAll() { return ASRouterUtils.sendMessage({ type: "UNBLOCK_ALL", }).then(this.setStateFromParent); } handleClearAllImpressionsByProvider() { const providerId = this.state.messageFilter; if (!providerId) { return; } const userPrefInfo = this.state.userPrefs; const isUserEnabled = providerId in userPrefInfo ? userPrefInfo[providerId] : true; ASRouterUtils.sendMessage({ type: "DISABLE_PROVIDER", data: providerId, }); if (!isUserEnabled) { ASRouterUtils.sendMessage({ type: "SET_PROVIDER_USER_PREF", data: { id: providerId, value: true }, }); } ASRouterUtils.sendMessage({ type: "ENABLE_PROVIDER", data: providerId, }); } handleEnabledToggle(event) { const provider = this.state.providerPrefs.find( p => p.id === event.target.dataset.provider ); const userPrefInfo = this.state.userPrefs; const isUserEnabled = provider.id in userPrefInfo ? userPrefInfo[provider.id] : true; const isSystemEnabled = provider.enabled; const isEnabling = event.target.checked; if (isEnabling) { if (!isUserEnabled) { ASRouterUtils.sendMessage({ type: "SET_PROVIDER_USER_PREF", data: { id: provider.id, value: true }, }); } if (!isSystemEnabled) { ASRouterUtils.sendMessage({ type: "ENABLE_PROVIDER", data: provider.id, }); } } else { ASRouterUtils.sendMessage({ type: "DISABLE_PROVIDER", data: provider.id, }); } this.setState({ messageFilter: "all" }); } handleUserPrefToggle(event) { const action = { type: "SET_PROVIDER_USER_PREF", data: { id: event.target.dataset.provider, value: event.target.checked }, }; ASRouterUtils.sendMessage(action); this.setState({ messageFilter: "all" }); } onChangeMessageFilter(event) { this.setState({ messageFilter: event.target.value }); } onChangeMessageGroupsFilter(event) { this.setState({ messageGroupsFilter: event.target.value }); } // Simulate a copy event that sets to clipboard all targeting paramters and values onCopyTargetingParams(event) { const stringTargetingParameters = { ...this.state.stringTargetingParameters, }; for (const key of Object.keys(stringTargetingParameters)) { // If the value is not set the parameter will be lost when we stringify if (stringTargetingParameters[key] === undefined) { stringTargetingParameters[key] = null; } } const setClipboardData = e => { e.preventDefault(); e.clipboardData.setData( "text", JSON.stringify(stringTargetingParameters, null, 2) ); document.removeEventListener("copy", setClipboardData); this.setState({ copiedToClipboard: true }); }; document.addEventListener("copy", setClipboardData); document.execCommand("copy"); } onNewTargetingParams(event) { this.setState({ newStringTargetingParameters: event.target.value }); event.target.classList.remove("errorState"); this.refs.targetingParamsEval.innerText = ""; try { const stringTargetingParameters = JSON.parse(event.target.value); this.setState({ stringTargetingParameters }); } catch (e) { event.target.classList.add("errorState"); this.refs.targetingParamsEval.innerText = e.message; } } toggleJSON(msgId) { if (this.state.collapsedMessages.includes(msgId)) { let index = this.state.collapsedMessages.indexOf(msgId); this.setState(prevState => ({ collapsedMessages: [ ...prevState.collapsedMessages.slice(0, index), ...prevState.collapsedMessages.slice(index + 1), ], })); } else { this.setState(prevState => ({ collapsedMessages: prevState.collapsedMessages.concat(msgId), })); } } handleChange(msgId) { if (!this.state.modifiedMessages.includes(msgId)) { this.setState(prevState => ({ modifiedMessages: prevState.modifiedMessages.concat(msgId), })); } } renderMessageItem(msg) { const isBlockedByGroup = this.state.groups .filter(group => msg.groups.includes(group.id)) .some(group => !group.enabled); const msgProvider = this.state.providers.find(provider => provider.id === msg.provider) || {}; const isProviderExcluded = msgProvider.exclude && msgProvider.exclude.includes(msg.id); const isMessageBlocked = this.state.messageBlockList.includes(msg.id) || this.state.messageBlockList.includes(msg.campaign); const isBlocked = isMessageBlocked || isBlockedByGroup || isProviderExcluded; const impressions = this.state.messageImpressions[msg.id] ? this.state.messageImpressions[msg.id].length : 0; const isCollapsed = this.state.collapsedMessages.includes(msg.id); const isModified = this.state.modifiedMessages.includes(msg.id); const aboutMessagePreviewSupported = [ "infobar", "spotlight", "cfr_doorhanger", ].includes(msg.template); let itemClassName = "message-item"; if (isBlocked) { itemClassName += " blocked"; } return ( {msg.id}
{ // eslint-disable-next-line no-nested-ternary isBlocked ? null : isModified ? ( ) : ( ) } {isBlocked ? null : ( )} {aboutMessagePreviewSupported ? ( `about:messagepreview?json=${encodeURIComponent(btoa(text))}` } label="Share" copiedLabel="Copied!" inputSelector={`#${msg.id}-textarea`} className={"button share"} /> ) : null}
({impressions} impressions) {isBlocked && ( Block reason: {isBlockedByGroup && " Blocked by group"} {isProviderExcluded && " Excluded by provider"} {isMessageBlocked && " Message blocked"} )}
              
            
); } selectPBMessage(msgId) { if (msgId === "clear") { this.setState({ selectedPBMessage: "", }); } else { let selected = document.getElementById(`${msgId} radio`); let msg = JSON.parse(document.getElementById(`${msgId}-textarea`).value); if (selected.checked) { this.setState({ selectedPBMessage: msg?.content, }); } else { this.setState({ selectedPBMessage: "", }); } } } modifyJson(content) { const message = JSON.parse( document.getElementById(`${content.id}-textarea`).value ); return ASRouterUtils.modifyMessageJson(message).then(state => { this.setStateFromParent(state); this.props.notifyContent({ message: state.message, }); }); } renderPBMessageItem(msg) { const isBlocked = this.state.messageBlockList.includes(msg.id) || this.state.messageBlockList.includes(msg.campaign); const impressions = this.state.messageImpressions[msg.id] ? this.state.messageImpressions[msg.id].length : 0; const isCollapsed = this.state.collapsedMessages.includes(msg.id); let itemClassName = "message-item"; if (isBlocked) { itemClassName += " blocked"; } return ( {msg.id}

({impressions} impressions)
this.selectPBMessage(msg.id)} disabled={isBlocked} />
            
          
); } toggleAllMessages(messagesToShow) { if (this.state.collapsedMessages.length) { this.setState({ collapsedMessages: [], }); } else { Array.prototype.forEach.call(messagesToShow, msg => { this.setState(prevState => ({ collapsedMessages: prevState.collapsedMessages.concat(msg.id), })); }); } } renderMessages() { if (!this.state.messages) { return null; } const messagesToShow = this.state.messageFilter === "all" ? this.state.messages : this.state.messages.filter( message => message.provider === this.state.messageFilter && message.template !== "pb_newtab" ); return (

{" "} To modify a message, change the JSON and click 'Modify' to see your changes. Click 'Reset' to restore the JSON to the original. Click 'Share' to copy a link to the clipboard that can be used to preview the message by opening the link in Nightly/local builds.

{messagesToShow.map(msg => this.renderMessageItem(msg))}
); } renderMessagesByGroup() { if (!this.state.messages) { return null; } const messagesToShow = this.state.messageGroupsFilter === "all" ? this.state.messages.filter(m => m.groups.length) : this.state.messages.filter(message => message.groups.includes(this.state.messageGroupsFilter) ); return ( {messagesToShow.map(msg => this.renderMessageItem(msg))}
); } renderPBMessages() { if (!this.state.messages) { return null; } const messagesToShow = this.state.messages.filter( message => message.template === "pb_newtab" ); return ( {messagesToShow.map(msg => this.renderPBMessageItem(msg))}
); } renderMessageFilter() { if (!this.state.providers) { return null; } return (

Show messages from{" "} {this.state.messageFilter !== "all" && !this.state.messageFilter.includes("_local_testing") ? ( ) : null}

); } renderMessageGroupsFilter() { if (!this.state.groups) { return null; } return (

Show messages from {/* eslint-disable-next-line jsx-a11y/no-onchange */}

); } renderTableHead() { return ( Provider ID Source Cohort Last Updated ); } renderProviders() { const providersConfig = this.state.providerPrefs; const providerInfo = this.state.providers; const userPrefInfo = this.state.userPrefs; return ( {this.renderTableHead()} {providersConfig.map((provider, i) => { const isTestProvider = provider.id.includes("_local_testing"); const info = providerInfo.find(p => p.id === provider.id) || {}; const isUserEnabled = provider.id in userPrefInfo ? userPrefInfo[provider.id] : true; const isSystemEnabled = isTestProvider || provider.enabled; let label = "local"; if (provider.type === "remote") { label = ( endpoint ( {info.url} ) ); } else if (provider.type === "remote-settings") { label = `remote settings (${provider.collection})`; } else if (provider.type === "remote-experiments") { label = ( remote settings ( nimbus-desktop-experiments ) ); } let reasonsDisabled = []; if (!isSystemEnabled) { reasonsDisabled.push("system pref"); } if (!isUserEnabled) { reasonsDisabled.push("user pref"); } if (reasonsDisabled.length) { label = `disabled via ${reasonsDisabled.join(", ")}`; } return ( ); })}
{isTestProvider ? ( ) : ( )} {provider.id} {label} {provider.cohort} {info.lastUpdated ? new Date(info.lastUpdated).toLocaleString() : ""}
); } renderTargetingParameters() { // There was no error and the result is truthy const success = this.state.evaluationStatus.success && !!this.state.evaluationStatus.result; const result = JSON.stringify(this.state.evaluationStatus.result, null, 2) || "(Empty result)"; return (

Evaluate JEXL expression