/* 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 { ASRouterUtils } from "../../asrouter-utils.mjs"; import React from "react"; import ReactDOM from "react-dom"; import { SimpleHashRouter } from "./SimpleHashRouter"; import { CopyButton } from "./CopyButton"; import { ImpressionsSection } from "./ImpressionsSection"; // Convert a UTF-8 string to a string in which only one byte of each // 16-bit unit is occupied. This is necessary to comply with `btoa` API constraints. export function toBinary(string) { const codeUnits = new Uint16Array(string.length); for (let i = 0; i < codeUnits.length; i++) { codeUnits[i] = string.charCodeAt(i); } return btoa( String.fromCharCode(...Array.from(new Uint8Array(codeUnits.buffer))) ); } 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 ToggleMessageJSON extends React.PureComponent { constructor(props) { super(props); this.handleClick = this.handleClick.bind(this); } handleClick() { this.props.toggleJSON(this.props.msgId); } render() { let direction = this.props.isCollapsed ? "forward" : "down"; return ( ); } } export class ASRouterAdminInner extends React.PureComponent { constructor(props) { super(props); this.handleEnabledToggle = this.handleEnabledToggle.bind(this); this.handleUserPrefToggle = this.handleUserPrefToggle.bind(this); this.onChangeFilters = this.onChangeFilters.bind(this); this.onClearFilters = this.onClearFilters.bind(this); this.unblockAll = this.unblockAll.bind(this); this.resetAllJSON = this.resetAllJSON.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.resetMessageState = this.resetMessageState.bind(this); this.toggleJSON = this.toggleJSON.bind(this); this.toggleAllMessages = this.toggleAllMessages.bind(this); this.resetGroupImpressions = this.resetGroupImpressions.bind(this); this.onMessageFromParent = this.onMessageFromParent.bind(this); this.setStateFromParent = this.setStateFromParent.bind(this); this.setState = this.setState.bind(this); this.state = { filterGroups: [], filterProviders: [], filterTemplates: [], filtersCollapsed: true, collapsedMessages: [], modifiedMessages: [], messageBlockList: [], multiProfileMessageBlocklist: [], 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; } } } async setStateFromParent(data) { await 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 ); } await this.setState({ stringTargetingParameters }); } } componentWillMount() { ASRouterUtils.addListener(this.onMessageFromParent); const endpoint = ASRouterUtils.getPreviewEndpoint(); ASRouterUtils.sendMessage({ type: "ADMIN_CONNECT_STATE", data: { endpoint }, }).then(this.setStateFromParent); } componentWillUnmount() { ASRouterUtils.removeListener(this.onMessageFromParent); } handleBlock(msg) { ASRouterUtils.blockById(msg.id); } handleUnblock(msg) { ASRouterUtils.unblockById(msg.id); } resetJSON(msg) { // reset the displayed JSON for the given message let textarea = document.getElementById(`${msg.id}-textarea`); textarea.value = JSON.stringify(msg, null, 2); textarea.classList.remove("errorState"); // 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), ], })); } resetAllJSON() { // reset the displayed JSON for each modified message for (const msgId of this.state.modifiedMessages) { const msg = this.state.messages.find(m => m.id === msgId); const textarea = document.getElementById(`${msgId}-textarea`); if (textarea) { textarea.value = JSON.stringify(msg, null, 2); textarea.classList.remove("errorState"); } } this.setState({ modifiedMessages: [] }); } showMessage(msg) { if (msg.template === "pb_newtab") { ASRouterUtils.openPBWindow(msg.content); } else { ASRouterUtils.overrideMessage(msg.id).then(state => this.setStateFromParent(state) ); } } async resetMessageState() { await Promise.all([ ASRouterUtils.resetMessageImpressions(), ASRouterUtils.resetGroupImpressions(), ASRouterUtils.resetScreenImpressions(), ASRouterUtils.unblockAll(), ]); let data = await ASRouterUtils.sendMessage({ type: "ADMIN_CONNECT_STATE", data: { endpoint: ASRouterUtils.getPreviewEndpoint() }, }); await this.setStateFromParent(data); } expireCache() { ASRouterUtils.sendMessage({ type: "EXPIRE_QUERY_CACHE" }); } resetPref() { ASRouterUtils.sendMessage({ type: "RESET_PROVIDER_PREF" }); } resetGroupImpressions() { ASRouterUtils.resetGroupImpressions().then(this.setStateFromParent); } resetMessageImpressions() { ASRouterUtils.resetMessageImpressions().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 || "undefined", context, }, }).then(this.setStateFromParent); } onChangeTargetingParameters(event) { const { name: eventName } = event.target; const { value } = event.target; let targetingParametersError = null; try { JSON.parse(value); event.target.classList.remove("errorState"); } catch (e) { console.error(`Error parsing value of parameter ${eventName}`); event.target.classList.add("errorState"); targetingParametersError = { id: eventName }; } this.setState(({ stringTargetingParameters }) => { const updatedParameters = { ...stringTargetingParameters }; updatedParameters[eventName] = value; return { copiedToClipboard: false, evaluationStatus: {}, stringTargetingParameters: updatedParameters, targetingParametersError, }; }); } unblockAll() { return ASRouterUtils.unblockAll().then(this.setStateFromParent); } async 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) { await ASRouterUtils.sendMessage({ type: "SET_PROVIDER_USER_PREF", data: { id: provider.id, value: true }, }); } if (!isSystemEnabled) { await ASRouterUtils.sendMessage({ type: "ENABLE_PROVIDER", data: provider.id, }); } } else { await ASRouterUtils.sendMessage({ type: "DISABLE_PROVIDER", data: provider.id, }); } this.setState({ filterProviders: [] }); } handleUserPrefToggle(event) { const action = { type: "SET_PROVIDER_USER_PREF", data: { id: event.target.dataset.provider, value: event.target.checked }, }; ASRouterUtils.sendMessage(action); this.setState({ filterProviders: [] }); } onChangeFilters(event) { // this function handles both provider filter and group filter. the checkbox // will have dataset.provider if it's a provider checkbox, and dataset.group // if it's a group checkbox. let stateKey; let itemValue; let { checked } = event.target; if (event.target.dataset.provider) { stateKey = "filterProviders"; itemValue = event.target.dataset.provider; } else if (event.target.dataset.group) { stateKey = "filterGroups"; itemValue = event.target.dataset.group; } else if (event.target.dataset.template) { stateKey = "filterTemplates"; itemValue = event.target.dataset.template; } else { return; } this.setState(prevState => { let newValue; if (checked) { newValue = prevState[stateKey].includes(itemValue) ? prevState[stateKey] : prevState[stateKey].concat(itemValue); } else { newValue = prevState[stateKey].filter(item => item !== itemValue); } return { [stateKey]: newValue }; }); } onClearFilters() { this.setState({ filterProviders: [], filterGroups: [], filterTemplates: [], }); } // Simulate a copy event that sets to clipboard all targeting paramters and values onCopyTargetingParams() { 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), })); } } onMessageChanged(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) || this.state.multiProfileMessageBlocklist.includes(msg.id); 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", "feature_callout", "pb_newtab", ].includes(msg.template); let itemClassName = "message-item"; if (isBlocked) { itemClassName += " blocked"; } let messageStats = []; let messageStatsString; if (impressions) { messageStats.push(`${impressions} impressions`); } if (isMessageBlocked) { messageStats.push("message blocked"); } else if (isBlockedByGroup) { messageStats.push("message group blocked"); } else if (isProviderExcluded) { messageStats.push("excluded by provider"); } if (messageStats.length) { messageStatsString = `(${messageStats.join(", ")})`; } return (
{msg.id}{" "} {messageStatsString}
{ // eslint-disable-next-line no-nested-ternary isBlocked ? null : isModified ? ( ) : ( ) } {isBlocked || !isModified ? null : ( )} {aboutMessagePreviewSupported ? ( `about:messagepreview?json=${encodeURIComponent( toBinary(text) )}` } label="Share" copiedLabel="Copied!" inputSelector={`#${msg.id}-textarea`} className={"share"} /> ) : null}
          
        
); } modifyJson(content) { const message = JSON.parse( document.getElementById(`${content.id}-textarea`).value ); if (message.template === "pb_newtab") { ASRouterUtils.openPBWindow(message.content); } else { ASRouterUtils.modifyMessageJson(message).then(state => { this.setStateFromParent(state); }); } } 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), })); }); } } filterMessages() { let messages = [...this.state.messages]; if (this.state.filterProviders.length) { messages = messages.filter(msg => this.state.filterProviders.includes(msg.provider) ); } if (this.state.filterGroups.length) { messages = messages.filter( msg => msg.groups?.some(group => this.state.filterGroups.includes(group)) || (!msg.groups?.length && this.state.filterGroups.includes("none")) ); } if (this.state.filterTemplates.length) { messages = messages.filter(msg => this.state.filterTemplates.includes(msg.template) ); } return messages; } renderMessages() { if (!this.state.messages) { return null; } const messagesToShow = this.filterMessages(); return (

{this.state.modifiedMessages.length ? ( ) : null} {this.state.messageBlockList.length ? ( ) : null}
{messagesToShow.map(msg => this.renderMessageItem(msg))}
); } renderFilters() { return (
{this.state.filterProviders.length || this.state.filterGroups.length || this.state.filterTemplates.length ? ( ) : null}
{this.state.filtersCollapsed ? null : (
{this.state.messages ? (

Templates

{this.state.messages .map(message => message.template) .filter( // eslint-disable-next-line no-shadow (value, index, self) => self.indexOf(value) === index ) .map(template => ( ))}
) : null} {this.state.groups ? (

Groups

{this.state.groups.map(group => ( ))}
) : null} {this.state.providers ? (

Providers

{this.state.providers.map(provider => ( ))}
) : null}
)}
); } renderProviders() { const providersConfig = this.state.providerPrefs; const providerInfo = this.state.providers; const userPrefInfo = this.state.userPrefs; return ( {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 ( ); })}
Provider Source Last Updated
{isTestProvider ? ( ) : ( )} {provider.id} {label} {info.lastUpdated ? relativeTime(info.lastUpdated) : ""}
); } renderMessageGroups() { return ( {this.state.groups && this.state.groups.map( ({ id, enabled, frequency, userPreferences = [] }) => { let frequencyCaps = []; if (!frequency) { frequencyCaps.push("n/a"); } else { if (frequency.custom) { for (let f of frequency.custom) { let { period } = f; let periodString = ""; if ( period >= 2419200000 && period % 2419200000 < 604800000 ) { let months = Math.round(period / 2419200000); periodString = months === 1 ? "/month" : ` in ${months}mos`; } else if ( period >= 604800000 && period % 604800000 < 86400000 ) { let weeks = Math.round(period / 604800000); periodString = weeks === 1 ? "/week" : ` in ${weeks}wks`; } else if ( period >= 86400000 && period % 86400000 < 3600000 ) { let days = Math.round(period / 86400000); periodString = days === 1 ? "/day" : ` in ${days}d`; } else { periodString = ` in ${period}ms`; } frequencyCaps.push(`${f.cap}${periodString}`); } } if ("lifetime" in frequency) { frequencyCaps.push(`${frequency.lifetime}/lifetime`); } } return ( ); } )}
Group Impressions Frequency caps User preferences
{id} {this._getGroupImpressionsCount(id, frequency)} {frequencyCaps.join(", ")} {userPreferences.join(", ")}
); } 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) || ""; return (

Evaluate JEXL expression