12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493 |
- /* 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/. */
- "use strict";
- const {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm");
- const {XPCOMUtils} = ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
- XPCOMUtils.defineLazyGlobalGetters(this, ["fetch"]);
- XPCOMUtils.defineLazyModuleGetters(this, {
- AddonManager: "resource://gre/modules/AddonManager.jsm",
- UITour: "resource:///modules/UITour.jsm",
- FxAccounts: "resource://gre/modules/FxAccounts.jsm",
- AppConstants: "resource://gre/modules/AppConstants.jsm",
- OS: "resource://gre/modules/osfile.jsm",
- BookmarkPanelHub: "resource://activity-stream/lib/BookmarkPanelHub.jsm",
- SnippetsTestMessageProvider: "resource://activity-stream/lib/SnippetsTestMessageProvider.jsm",
- PanelTestProvider: "resource://activity-stream/lib/PanelTestProvider.jsm",
- });
- const {ASRouterActions: ra, actionTypes: at, actionCreators: ac} = ChromeUtils.import("resource://activity-stream/common/Actions.jsm");
- const {CFRMessageProvider} = ChromeUtils.import("resource://activity-stream/lib/CFRMessageProvider.jsm");
- const {OnboardingMessageProvider} = ChromeUtils.import("resource://activity-stream/lib/OnboardingMessageProvider.jsm");
- const {RemoteSettings} = ChromeUtils.import("resource://services-settings/remote-settings.js");
- const {CFRPageActions} = ChromeUtils.import("resource://activity-stream/lib/CFRPageActions.jsm");
- const {AttributionCode} = ChromeUtils.import("resource:///modules/AttributionCode.jsm");
- ChromeUtils.defineModuleGetter(this, "ASRouterPreferences",
- "resource://activity-stream/lib/ASRouterPreferences.jsm");
- ChromeUtils.defineModuleGetter(this, "TARGETING_PREFERENCES",
- "resource://activity-stream/lib/ASRouterPreferences.jsm");
- ChromeUtils.defineModuleGetter(this, "ASRouterTargeting",
- "resource://activity-stream/lib/ASRouterTargeting.jsm");
- ChromeUtils.defineModuleGetter(this, "QueryCache",
- "resource://activity-stream/lib/ASRouterTargeting.jsm");
- ChromeUtils.defineModuleGetter(this, "ASRouterTriggerListeners",
- "resource://activity-stream/lib/ASRouterTriggerListeners.jsm");
- ChromeUtils.defineModuleGetter(this, "TelemetryEnvironment",
- "resource://gre/modules/TelemetryEnvironment.jsm");
- ChromeUtils.defineModuleGetter(this, "ClientEnvironment",
- "resource://normandy/lib/ClientEnvironment.jsm");
- ChromeUtils.defineModuleGetter(this, "Sampling",
- "resource://gre/modules/components-utils/Sampling.jsm");
- const TRAILHEAD_CONFIG = {
- OVERRIDE_PREF: "trailhead.firstrun.branches",
- DID_SEE_ABOUT_WELCOME_PREF: "trailhead.firstrun.didSeeAboutWelcome",
- INTERRUPTS_EXPERIMENT_PREF: "trailhead.firstrun.interruptsExperiment",
- TRIPLETS_ENROLLED_PREF: "trailhead.firstrun.tripletsEnrolled",
- BRANCHES: {
- interrupts: [
- ["control"],
- ["join"],
- ["sync"],
- ["nofirstrun"],
- ["cards"],
- ],
- triplets: [
- ["supercharge"],
- ["payoff"],
- ["multidevice"],
- ["privacy"],
- ],
- },
- LOCALES: ["en-US", "en-GB", "en-CA", "de", "de-DE", "fr", "fr-FR"],
- EXPERIMENT_RATIOS: [
- ["", 0],
- ["interrupts", 1],
- ["triplets", 3],
- ],
- };
- const INCOMING_MESSAGE_NAME = "ASRouter:child-to-parent";
- const OUTGOING_MESSAGE_NAME = "ASRouter:parent-to-child";
- const ONE_DAY_IN_MS = 24 * 60 * 60 * 1000;
- // List of hosts for endpoints that serve router messages.
- // Key is allowed host, value is a name for the endpoint host.
- const DEFAULT_WHITELIST_HOSTS = {
- "activity-stream-icons.services.mozilla.com": "production",
- "snippets-admin.mozilla.org": "preview",
- };
- const SNIPPETS_ENDPOINT_WHITELIST = "browser.newtab.activity-stream.asrouter.whitelistHosts";
- // Max possible impressions cap for any message
- const MAX_MESSAGE_LIFETIME_CAP = 100;
- const LOCAL_MESSAGE_PROVIDERS = {OnboardingMessageProvider, CFRMessageProvider};
- const STARTPAGE_VERSION = "6";
- /**
- * chooseBranch<T> - Choose an item from a list of "branches" pseudorandomly using a seed / ratio configuration
- * @param seed {string} A unique seed for the randomizer
- * @param branches {Array<[T, number?]>} A list of branches, where branch[0] is any item and branch[1] is the ratio
- * @returns {T} An randomly chosen item in a branch
- */
- async function chooseBranch(seed, branches) {
- const ratios = branches.map(([item, ratio]) => ((typeof ratio !== "undefined") ? ratio : 1));
- return branches[await Sampling.ratioSample(seed, ratios)][0];
- }
- const MessageLoaderUtils = {
- STARTPAGE_VERSION,
- REMOTE_LOADER_CACHE_KEY: "RemoteLoaderCache",
- _errors: [],
- reportError(e) {
- Cu.reportError(e);
- this._errors.push({timestamp: new Date(), error: {message: e.toString(), stack: e.stack}});
- },
- get errors() {
- const errors = this._errors;
- this._errors = [];
- return errors;
- },
- /**
- * _localLoader - Loads messages for a local provider (i.e. one that lives in mozilla central)
- *
- * @param {obj} provider An AS router provider
- * @param {Array} provider.messages An array of messages
- * @returns {Array} the array of messages
- */
- _localLoader(provider) {
- return provider.messages;
- },
- async _remoteLoaderCache(storage) {
- let allCached;
- try {
- allCached = await storage.get(MessageLoaderUtils.REMOTE_LOADER_CACHE_KEY) || {};
- } catch (e) {
- // istanbul ignore next
- MessageLoaderUtils.reportError(e);
- // istanbul ignore next
- allCached = {};
- }
- return allCached;
- },
- /**
- * _remoteLoader - Loads messages for a remote provider
- *
- * @param {obj} provider An AS router provider
- * @param {string} provider.url An endpoint that returns an array of messages as JSON
- * @param {obj} options.storage A storage object with get() and set() methods for caching.
- * @returns {Promise} resolves with an array of messages, or an empty array if none could be fetched
- */
- async _remoteLoader(provider, options) {
- let remoteMessages = [];
- if (provider.url) {
- const allCached = await MessageLoaderUtils._remoteLoaderCache(options.storage);
- const cached = allCached[provider.id];
- let etag;
- if (cached && cached.url === provider.url && cached.version === STARTPAGE_VERSION) {
- const {lastFetched, messages} = cached;
- if (!MessageLoaderUtils.shouldProviderUpdate({...provider, lastUpdated: lastFetched})) {
- // Cached messages haven't expired, return early.
- return messages;
- }
- etag = cached.etag;
- remoteMessages = messages;
- }
- let headers = new Headers();
- if (etag) {
- headers.set("If-None-Match", etag);
- }
- let response;
- try {
- response = await fetch(provider.url, {headers, credentials: "omit"});
- } catch (e) {
- MessageLoaderUtils.reportError(e);
- }
- if (
- response &&
- response.ok &&
- (response.status >= 200 && response.status < 400)
- ) {
- let jsonResponse;
- try {
- jsonResponse = await response.json();
- } catch (e) {
- MessageLoaderUtils.reportError(e);
- return remoteMessages;
- }
- if (jsonResponse && jsonResponse.messages) {
- remoteMessages = jsonResponse.messages
- .map(msg => ({...msg, provider_url: provider.url}));
- // Cache the results if this isn't a preview URL.
- if (provider.updateCycleInMs > 0) {
- etag = response.headers.get("ETag");
- const cacheInfo = {
- messages: remoteMessages,
- etag,
- lastFetched: Date.now(),
- version: STARTPAGE_VERSION,
- };
- options.storage.set(MessageLoaderUtils.REMOTE_LOADER_CACHE_KEY, {...allCached, [provider.id]: cacheInfo});
- }
- } else {
- MessageLoaderUtils.reportError(`No messages returned from ${provider.url}.`);
- }
- } else if (response) {
- MessageLoaderUtils.reportError(`Invalid response status ${response.status} from ${provider.url}.`);
- }
- }
- return remoteMessages;
- },
- /**
- * _remoteSettingsLoader - Loads messages for a RemoteSettings provider
- *
- * @param {obj} provider An AS router provider
- * @param {string} provider.id The id of the provider
- * @param {string} provider.bucket The name of the Remote Settings bucket
- * @param {func} options.dispatchToAS dispatch an action the main AS Store
- * @returns {Promise} resolves with an array of messages, or an empty array if none could be fetched
- */
- async _remoteSettingsLoader(provider, options) {
- let messages = [];
- if (provider.bucket) {
- try {
- messages = await MessageLoaderUtils._getRemoteSettingsMessages(provider.bucket);
- if (!messages.length) {
- MessageLoaderUtils._handleRemoteSettingsUndesiredEvent("ASR_RS_NO_MESSAGES", provider.id, options.dispatchToAS);
- }
- } catch (e) {
- MessageLoaderUtils._handleRemoteSettingsUndesiredEvent("ASR_RS_ERROR", provider.id, options.dispatchToAS);
- MessageLoaderUtils.reportError(e);
- }
- }
- return messages;
- },
- _getRemoteSettingsMessages(bucket) {
- return RemoteSettings(bucket).get();
- },
- _handleRemoteSettingsUndesiredEvent(event, providerId, dispatchToAS) {
- if (dispatchToAS) {
- dispatchToAS(ac.ASRouterUserEvent({
- action: "asrouter_undesired_event",
- event,
- message_id: "n/a",
- value: providerId,
- }));
- }
- },
- /**
- * _getMessageLoader - return the right loading function given the provider's type
- *
- * @param {obj} provider An AS Router provider
- * @returns {func} A loading function
- */
- _getMessageLoader(provider) {
- switch (provider.type) {
- case "remote":
- return this._remoteLoader;
- case "remote-settings":
- return this._remoteSettingsLoader;
- case "local":
- default:
- return this._localLoader;
- }
- },
- /**
- * shouldProviderUpdate - Given the current time, should a provider update its messages?
- *
- * @param {any} provider An AS Router provider
- * @param {int} provider.updateCycleInMs The number of milliseconds we should wait between updates
- * @param {Date} provider.lastUpdated If the provider has been updated, the time the last update occurred
- * @param {Date} currentTime The time we should check against. (defaults to Date.now())
- * @returns {bool} Should an update happen?
- */
- shouldProviderUpdate(provider, currentTime = Date.now()) {
- return (!(provider.lastUpdated >= 0) || currentTime - provider.lastUpdated > provider.updateCycleInMs);
- },
- /**
- * loadMessagesForProvider - Load messages for a provider, given the provider's type.
- *
- * @param {obj} provider An AS Router provider
- * @param {string} provider.type An AS Router provider type (defaults to "local")
- * @param {obj} options.storage A storage object with get() and set() methods for caching.
- * @param {func} options.dispatchToAS dispatch an action the main AS Store
- * @returns {obj} Returns an object with .messages (an array of messages) and .lastUpdated (the time the messages were updated)
- */
- async loadMessagesForProvider(provider, options) {
- const loader = this._getMessageLoader(provider);
- let messages = await loader(provider, options);
- // istanbul ignore if
- if (!messages) {
- messages = [];
- MessageLoaderUtils.reportError(new Error(`Tried to load messages for ${provider.id} but the result was not an Array.`));
- }
- // Filter out messages we temporarily want to exclude
- if (provider.exclude && provider.exclude.length) {
- messages = messages.filter(message => !provider.exclude.includes(message.id));
- }
- const lastUpdated = Date.now();
- return {
- messages: messages.map(msg => ({weight: 100, ...msg, provider: provider.id}))
- .filter(message => message.weight > 0),
- lastUpdated,
- errors: MessageLoaderUtils.errors,
- };
- },
- /**
- * _loadAddonIconInURLBar - load addons-notification icon by displaying
- * box containing addons icon in urlbar. See Bug 1513882
- *
- * @param {XULElement} Target browser element for showing addons icon
- */
- _loadAddonIconInURLBar(browser) {
- if (!browser) {
- return;
- }
- const chromeDoc = browser.ownerDocument;
- let notificationPopupBox = chromeDoc.getElementById("notification-popup-box");
- if (!notificationPopupBox) {
- return;
- }
- if (notificationPopupBox.style.display === "none" ||
- notificationPopupBox.style.display === "") {
- notificationPopupBox.style.display = "block";
- }
- },
- async installAddonFromURL(browser, url) {
- try {
- MessageLoaderUtils._loadAddonIconInURLBar(browser);
- const aUri = Services.io.newURI(url);
- const systemPrincipal = Services.scriptSecurityManager.getSystemPrincipal();
- // AddonManager installation source associated to the addons installed from activitystream's CFR
- const telemetryInfo = {source: "amo"};
- const install = await AddonManager.getInstallForURL(aUri.spec, {telemetryInfo});
- await AddonManager.installAddonFromWebpage("application/x-xpinstall", browser,
- systemPrincipal, install);
- } catch (e) {
- Cu.reportError(e);
- }
- },
- /**
- * cleanupCache - Removes cached data of removed providers.
- *
- * @param {Array} providers A list of activer AS Router providers
- */
- async cleanupCache(providers, storage) {
- const ids = providers.filter(p => p.type === "remote").map(p => p.id);
- const cache = await MessageLoaderUtils._remoteLoaderCache(storage);
- let dirty = false;
- for (let id in cache) {
- if (!ids.includes(id)) {
- delete cache[id];
- dirty = true;
- }
- }
- if (dirty) {
- await storage.set(MessageLoaderUtils.REMOTE_LOADER_CACHE_KEY, cache);
- }
- },
- };
- this.MessageLoaderUtils = MessageLoaderUtils;
- /**
- * @class _ASRouter - Keeps track of all messages, UI surfaces, and
- * handles blocking, rotation, etc. Inspecting ASRouter.state will
- * tell you what the current displayed message is in all UI surfaces.
- *
- * Note: This is written as a constructor rather than just a plain object
- * so that it can be more easily unit tested.
- */
- class _ASRouter {
- constructor(localProviders = LOCAL_MESSAGE_PROVIDERS) {
- this.initialized = false;
- this.messageChannel = null;
- this.dispatchToAS = null;
- this._storage = null;
- this._resetInitialization();
- this._state = {
- lastMessageId: null,
- providers: [],
- messageBlockList: [],
- providerBlockList: [],
- messageImpressions: {},
- providerImpressions: {},
- trailheadInitialized: false,
- trailheadInterrupt: "",
- trailheadTriplet: "",
- messages: [],
- errors: [],
- };
- this._triggerHandler = this._triggerHandler.bind(this);
- this._localProviders = localProviders;
- this.onMessage = this.onMessage.bind(this);
- this.handleMessageRequest = this.handleMessageRequest.bind(this);
- this.addImpression = this.addImpression.bind(this);
- this._handleTargetingError = this._handleTargetingError.bind(this);
- this.onPrefChange = this.onPrefChange.bind(this);
- }
- async onPrefChange(prefName) {
- if (TARGETING_PREFERENCES.includes(prefName)) {
- // Notify all tabs of messages that have become invalid after pref change
- const invalidMessages = [];
- for (const msg of this._getUnblockedMessages()) {
- if (!msg.targeting) {
- continue;
- }
- const isMatch = await ASRouterTargeting.isMatch(msg.targeting);
- if (!isMatch) {
- invalidMessages.push(msg.id);
- }
- }
- this.messageChannel.sendAsyncMessage(OUTGOING_MESSAGE_NAME, {type: at.AS_ROUTER_TARGETING_UPDATE, data: invalidMessages});
- } else {
- // Update message providers and fetch new messages on pref change
- this._loadLocalProviders();
- this._updateMessageProviders();
- await this.loadMessagesFromAllProviders();
- }
- }
- // Replace all frequency time period aliases with their millisecond values
- // This allows us to avoid accounting for special cases later on
- normalizeItemFrequency({frequency}) {
- if (frequency && frequency.custom) {
- for (const setting of frequency.custom) {
- if (setting.period === "daily") {
- setting.period = ONE_DAY_IN_MS;
- }
- }
- }
- }
- // Fetch and decode the message provider pref JSON, and update the message providers
- _updateMessageProviders() {
- const previousProviders = this.state.providers;
- const providers = [
- // If we have added a `preview` provider, hold onto it
- ...previousProviders.filter(p => p.id === "preview"),
- // The provider should be enabled and not have a user preference set to false
- ...ASRouterPreferences.providers.filter(p => (
- p.enabled &&
- (
- ASRouterPreferences.getUserPreference(p.id) !== false &&
- // Provider is enabled or if provider has multiple categories
- // check that at least one category is enabled
- (!p.categories || p.categories.some(c => ASRouterPreferences.getUserPreference(c) !== false))
- )
- )),
- ].map(_provider => {
- // make a copy so we don't modify the source of the pref
- const provider = {..._provider};
- if (provider.type === "local" && !provider.messages) {
- // Get the messages from the local message provider
- const localProvider = this._localProviders[provider.localProvider];
- provider.messages = localProvider ? localProvider.getMessages() : [];
- }
- if (provider.type === "remote" && provider.url) {
- provider.url = provider.url.replace(/%STARTPAGE_VERSION%/g, STARTPAGE_VERSION);
- provider.url = Services.urlFormatter.formatURL(provider.url);
- }
- this.normalizeItemFrequency(provider);
- // Reset provider update timestamp to force message refresh
- provider.lastUpdated = undefined;
- return provider;
- });
- const providerIDs = providers.map(p => p.id);
- // Clear old messages for providers that are no longer enabled
- for (const prevProvider of previousProviders) {
- if (!providerIDs.includes(prevProvider.id)) {
- this.messageChannel.sendAsyncMessage(OUTGOING_MESSAGE_NAME, {type: "CLEAR_PROVIDER", data: {id: prevProvider.id}});
- }
- }
- this.setState(prevState => ({
- providers,
- // Clear any messages from removed providers
- messages: [...prevState.messages.filter(message => providerIDs.includes(message.provider))],
- }));
- }
- get state() {
- return this._state;
- }
- set state(value) {
- throw new Error("Do not modify this.state directy. Instead, call this.setState(newState)");
- }
- /**
- * _resetInitialization - adds the following to the instance:
- * .initialized {bool} Has AS Router been initialized?
- * .waitForInitialized {Promise} A promise that resolves when initializion is complete
- * ._finishInitializing {func} A function that, when called, resolves the .waitForInitialized
- * promise and sets .initialized to true.
- * @memberof _ASRouter
- */
- _resetInitialization() {
- this.initialized = false;
- this.waitForInitialized = new Promise(resolve => {
- this._finishInitializing = () => {
- this.initialized = true;
- resolve();
- };
- });
- }
- /**
- * loadMessagesFromAllProviders - Loads messages from all providers if they require updates.
- * Checks the .lastUpdated field on each provider to see if updates are needed
- * @memberof _ASRouter
- */
- async loadMessagesFromAllProviders() {
- const needsUpdate = this.state.providers.filter(provider => MessageLoaderUtils.shouldProviderUpdate(provider));
- // Don't do extra work if we don't need any updates
- if (needsUpdate.length) {
- let newState = {messages: [], providers: []};
- for (const provider of this.state.providers) {
- if (needsUpdate.includes(provider)) {
- let {messages, lastUpdated, errors} = await MessageLoaderUtils.loadMessagesForProvider(provider, {
- storage: this._storage,
- dispatchToAS: this.dispatchToAS,
- });
- messages = messages.filter(({content}) => !content || !content.category || ASRouterPreferences.getUserPreference(content.category));
- newState.providers.push({...provider, lastUpdated, errors});
- newState.messages = [...newState.messages, ...messages];
- } else {
- // Skip updating this provider's messages if no update is required
- let messages = this.state.messages.filter(msg => msg.provider === provider.id);
- newState.providers.push(provider);
- newState.messages = [...newState.messages, ...messages];
- }
- }
- for (const message of newState.messages) {
- this.normalizeItemFrequency(message);
- }
- // Some messages have triggers that require us to initalise trigger listeners
- const unseenListeners = new Set(ASRouterTriggerListeners.keys());
- for (const {trigger} of newState.messages) {
- if (trigger && ASRouterTriggerListeners.has(trigger.id)) {
- await ASRouterTriggerListeners.get(trigger.id).init(this._triggerHandler, trigger.params, trigger.patterns);
- unseenListeners.delete(trigger.id);
- }
- }
- // We don't need these listeners, but they may have previously been
- // initialised, so uninitialise them
- for (const triggerID of unseenListeners) {
- ASRouterTriggerListeners.get(triggerID).uninit();
- }
- // We don't want to cache preview endpoints, remove them after messages are fetched
- await this.setState(this._removePreviewEndpoint(newState));
- await this.cleanupImpressions();
- }
- }
- /**
- * init - Initializes the MessageRouter.
- * It is ready when it has been connected to a RemotePageManager instance.
- *
- * @param {RemotePageManager} channel a RemotePageManager instance
- * @param {obj} storage an AS storage instance
- * @param {func} dispatchToAS dispatch an action the main AS Store
- * @memberof _ASRouter
- */
- async init(channel, storage, dispatchToAS) {
- this.messageChannel = channel;
- this.messageChannel.addMessageListener(INCOMING_MESSAGE_NAME, this.onMessage);
- this._storage = storage;
- this.WHITELIST_HOSTS = this._loadSnippetsWhitelistHosts();
- this.dispatchToAS = dispatchToAS;
- this.dispatch = this.dispatch.bind(this);
- ASRouterPreferences.init();
- ASRouterPreferences.addListener(this.onPrefChange);
- BookmarkPanelHub.init(this.handleMessageRequest, this.addImpression, this.dispatch);
- this._loadLocalProviders();
- // We need to check whether to set up telemetry for trailhead
- await this.setupTrailhead();
- const messageBlockList = await this._storage.get("messageBlockList") || [];
- const providerBlockList = await this._storage.get("providerBlockList") || [];
- const messageImpressions = await this._storage.get("messageImpressions") || {};
- const providerImpressions = await this._storage.get("providerImpressions") || {};
- const previousSessionEnd = await this._storage.get("previousSessionEnd") || 0;
- await this.setState({messageBlockList, providerBlockList, messageImpressions, providerImpressions, previousSessionEnd});
- this._updateMessageProviders();
- await this.loadMessagesFromAllProviders();
- await MessageLoaderUtils.cleanupCache(this.state.providers, storage);
- // set necessary state in the rest of AS
- this.dispatchToAS(ac.BroadcastToContent({type: at.AS_ROUTER_INITIALIZED, data: ASRouterPreferences.specialConditions}));
- // sets .initialized to true and resolves .waitForInitialized promise
- this._finishInitializing();
- }
- uninit() {
- this._storage.set("previousSessionEnd", Date.now());
- this.messageChannel.sendAsyncMessage(OUTGOING_MESSAGE_NAME, {type: "CLEAR_ALL"});
- this.messageChannel.removeMessageListener(INCOMING_MESSAGE_NAME, this.onMessage);
- this.messageChannel = null;
- this.dispatchToAS = null;
- ASRouterPreferences.removeListener(this.onPrefChange);
- ASRouterPreferences.uninit();
- BookmarkPanelHub.uninit();
- // Uninitialise all trigger listeners
- for (const listener of ASRouterTriggerListeners.values()) {
- listener.uninit();
- }
- // If we added any CFR recommendations, they need to be removed
- CFRPageActions.clearRecommendations();
- this._resetInitialization();
- }
- setState(callbackOrObj) {
- const newState = (typeof callbackOrObj === "function") ? callbackOrObj(this.state) : callbackOrObj;
- this._state = {...this.state, ...newState};
- return new Promise(resolve => {
- this._onStateChanged(this.state);
- resolve();
- });
- }
- getMessageById(id) {
- return this.state.messages.find(message => message.id === id);
- }
- _onStateChanged(state) {
- if (ASRouterPreferences.devtoolsEnabled) {
- this._updateAdminState();
- }
- }
- _loadLocalProviders() {
- // If we're in ASR debug mode add the local test providers
- if (ASRouterPreferences.devtoolsEnabled) {
- this._localProviders = {
- ...this._localProviders,
- SnippetsTestMessageProvider,
- PanelTestProvider,
- };
- }
- }
- /**
- * Used by ASRouter Admin returns all ASRouterTargeting.Environment
- * and ASRouter._getMessagesContext parameters and values
- */
- async getTargetingParameters(environment, localContext) {
- const targetingParameters = {};
- for (const param of Object.keys(environment)) {
- targetingParameters[param] = await environment[param];
- }
- for (const param of Object.keys(localContext)) {
- targetingParameters[param] = await localContext[param];
- }
- return targetingParameters;
- }
- async _updateAdminState(target) {
- const channel = target || this.messageChannel;
- channel.sendAsyncMessage(OUTGOING_MESSAGE_NAME, {
- type: "ADMIN_SET_STATE",
- data: {
- ...this.state,
- providerPrefs: ASRouterPreferences.providers,
- userPrefs: ASRouterPreferences.getAllUserPreferences(),
- targetingParameters: await this.getTargetingParameters(ASRouterTargeting.Environment, this._getMessagesContext()),
- errors: this.errors,
- },
- });
- }
- _handleTargetingError(type, error, message) {
- Cu.reportError(error);
- if (this.dispatchToAS) {
- this.dispatchToAS(ac.ASRouterUserEvent({
- message_id: message.id,
- action: "asrouter_undesired_event",
- event: "TARGETING_EXPRESSION_ERROR",
- value: type,
- }));
- }
- }
- async _hasAddonAttributionData() {
- try {
- const data = await AttributionCode.getAttrDataAsync() || {};
- return data.source === "addons.mozilla.org";
- } catch (e) {
- return false;
- }
- }
- /**
- * _generateTrailheadBranches - Generates and returns Trailhead configuration and chooses an experiment
- * based on clientID and locale.
- * @returns {{experiment: string, interrupt: string, triplet: string}}
- */
- async _generateTrailheadBranches() {
- let experiment = "";
- let interrupt;
- let triplet;
- // If a value is set in TRAILHEAD_OVERRIDE_PREF, it will be returned and no experiment will be set.
- const overrideValue = Services.prefs.getStringPref(TRAILHEAD_CONFIG.OVERRIDE_PREF, "");
- if (overrideValue) {
- [interrupt, triplet] = overrideValue.split("-");
- return {experiment, interrupt, triplet: triplet || ""};
- }
- const locale = Services.locale.appLocaleAsLangTag;
- if (TRAILHEAD_CONFIG.LOCALES.includes(locale) && !(await this._hasAddonAttributionData())) {
- const {userId} = ClientEnvironment;
- experiment = await chooseBranch(`${userId}-trailhead-experiments`, TRAILHEAD_CONFIG.EXPERIMENT_RATIOS);
- // For the interrupts experiment,
- // we randomly assign an interrupt and always use the "supercharge" triplet.
- if (experiment === "interrupts") {
- interrupt = await chooseBranch(`${userId}-interrupts-branch`, TRAILHEAD_CONFIG.BRANCHES.interrupts);
- if (["join", "sync", "cards"].includes(interrupt)) {
- triplet = "supercharge";
- }
- // For the triplets experiment or non-experiment experience,
- // we randomly assign a triplet and always use the "join" interrupt.
- } else {
- interrupt = "join";
- triplet = await chooseBranch(`${userId}-triplets-branch`, TRAILHEAD_CONFIG.BRANCHES.triplets);
- }
- } else {
- // If the user is not in a trailhead-compabtible locale, return the control experience and no experiment.
- interrupt = "control";
- }
- return {experiment, interrupt, triplet};
- }
- // Dispatch a TRAILHEAD_ENROLL_EVENT action
- _sendTrailheadEnrollEvent(data) {
- this.dispatchToAS({
- type: at.TRAILHEAD_ENROLL_EVENT,
- data,
- });
- }
- async setupTrailhead() {
- // Don't initialize
- if (this.state.trailheadInitialized || !Services.prefs.getBoolPref(TRAILHEAD_CONFIG.DID_SEE_ABOUT_WELCOME_PREF, false)) {
- return;
- }
- const {experiment, interrupt, triplet} = await this._generateTrailheadBranches();
- await this.setState({trailheadInitialized: true, trailheadInterrupt: interrupt, trailheadTriplet: triplet});
- if (experiment) {
- // In order for ping centre to pick this up, it MUST contain a substring activity-stream
- const experimentName = `activity-stream-firstrun-trailhead-${experiment}`;
- TelemetryEnvironment.setExperimentActive(
- experimentName,
- experiment === "interrupts" ? interrupt : triplet,
- {type: "as-firstrun"}
- );
- // On the first time setting the interrupts experiment, expose the branch
- // for normandy to target for survey study, and send out the enrollment ping.
- if (experiment === "interrupts" &&
- !Services.prefs.prefHasUserValue(TRAILHEAD_CONFIG.INTERRUPTS_EXPERIMENT_PREF)) {
- Services.prefs.setStringPref(TRAILHEAD_CONFIG.INTERRUPTS_EXPERIMENT_PREF, interrupt);
- this._sendTrailheadEnrollEvent({experiment: experimentName, type: "as-firstrun", branch: interrupt});
- }
- // On the first time setting the triplets experiment, send out the enrollment ping.
- if (experiment === "triplets" &&
- !Services.prefs.getBoolPref(TRAILHEAD_CONFIG.TRIPLETS_ENROLLED_PREF, false)) {
- Services.prefs.setBoolPref(TRAILHEAD_CONFIG.TRIPLETS_ENROLLED_PREF, true);
- this._sendTrailheadEnrollEvent({experiment: experimentName, type: "as-firstrun", branch: triplet});
- }
- }
- }
- // Return an object containing targeting parameters used to select messages
- _getMessagesContext() {
- const {previousSessionEnd, trailheadInterrupt, trailheadTriplet} = this.state;
- return {
- get previousSessionEnd() {
- return previousSessionEnd;
- },
- get trailheadInterrupt() {
- return trailheadInterrupt;
- },
- get trailheadTriplet() {
- return trailheadTriplet;
- },
- };
- }
- _findMessage(candidateMessages, trigger) {
- const messages = candidateMessages.filter(m => this.isBelowFrequencyCaps(m));
- const context = this._getMessagesContext();
- // Find a message that matches the targeting context as well as the trigger context (if one is provided)
- // If no trigger is provided, we should find a message WITHOUT a trigger property defined.
- return ASRouterTargeting.findMatchingMessage({messages, trigger, context, onError: this._handleTargetingError});
- }
- async evaluateExpression(target, {expression, context}) {
- const channel = target || this.messageChannel;
- let evaluationStatus;
- try {
- evaluationStatus = {result: await ASRouterTargeting.isMatch(expression, context), success: true};
- } catch (e) {
- evaluationStatus = {result: e.message, success: false};
- }
- channel.sendAsyncMessage(OUTGOING_MESSAGE_NAME, {type: "ADMIN_SET_STATE", data: {...this.state, evaluationStatus}});
- }
- _orderBundle(bundle) {
- return bundle.sort((a, b) => a.order - b.order);
- }
- // Work out if a message can be shown based on its and its provider's frequency caps.
- isBelowFrequencyCaps(message) {
- const {providers, messageImpressions, providerImpressions} = this.state;
- const provider = providers.find(p => p.id === message.provider);
- const impressionsForMessage = messageImpressions[message.id];
- const impressionsForProvider = providerImpressions[message.provider];
- return (this._isBelowItemFrequencyCap(message, impressionsForMessage, MAX_MESSAGE_LIFETIME_CAP) &&
- this._isBelowItemFrequencyCap(provider, impressionsForProvider));
- }
- // Helper for isBelowFrecencyCaps - work out if the frequency cap for the given
- // item has been exceeded or not
- _isBelowItemFrequencyCap(item, impressions, maxLifetimeCap = Infinity) {
- if (item && item.frequency && impressions && impressions.length) {
- if (
- item.frequency.lifetime &&
- impressions.length >= Math.min(item.frequency.lifetime, maxLifetimeCap)
- ) {
- return false;
- }
- if (item.frequency.custom) {
- const now = Date.now();
- for (const setting of item.frequency.custom) {
- let {period} = setting;
- const impressionsInPeriod = impressions.filter(t => (now - t) < period);
- if (impressionsInPeriod.length >= setting.cap) {
- return false;
- }
- }
- }
- }
- return true;
- }
- async _getBundledMessages(originalMessage, target, trigger, force = false) {
- let result = [];
- let bundleLength;
- let bundleTemplate;
- let originalId;
- if (originalMessage.includeBundle) {
- // The original message is not part of the bundle, so don't include it
- bundleLength = originalMessage.includeBundle.length;
- bundleTemplate = originalMessage.includeBundle.template;
- } else {
- // The original message is part of the bundle
- bundleLength = originalMessage.bundled;
- bundleTemplate = originalMessage.template;
- originalId = originalMessage.id;
- // Add in a copy of the first message
- result.push({content: originalMessage.content, id: originalMessage.id, order: originalMessage.order || 0});
- }
- // First, find all messages of same template. These are potential matching targeting candidates
- let bundledMessagesOfSameTemplate = this._getUnblockedMessages()
- .filter(msg => msg.bundled && msg.template === bundleTemplate && msg.id !== originalId);
- if (force) {
- // Forcefully show the messages without targeting matching - this is for about:newtab#asrouter to show the messages
- for (const message of bundledMessagesOfSameTemplate) {
- result.push({content: message.content, id: message.id});
- // Stop once we have enough messages to fill a bundle
- if (result.length === bundleLength) {
- break;
- }
- }
- } else {
- while (bundledMessagesOfSameTemplate.length) {
- // Find a message that matches the targeting context - or break if there are no matching messages
- const message = await this._findMessage(bundledMessagesOfSameTemplate, trigger);
- if (!message) {
- /* istanbul ignore next */ // Code coverage in mochitests
- break;
- }
- // Only copy the content of the message (that's what the UI cares about)
- // Also delete the message we picked so we don't pick it again
- result.push({content: message.content, id: message.id, order: message.order || 0});
- bundledMessagesOfSameTemplate.splice(bundledMessagesOfSameTemplate.findIndex(msg => msg.id === message.id), 1);
- // Stop once we have enough messages to fill a bundle
- if (result.length === bundleLength) {
- break;
- }
- }
- }
- // If we did not find enough messages to fill the bundle, do not send the bundle down
- if (result.length < bundleLength) {
- return null;
- }
- // The bundle may have some extra attributes, like a header, or a dismiss button, so attempt to get those strings now
- // This is a temporary solution until we can use Fluent strings in the content process, in which case the content can
- // handle finding these strings on its own. See bug 1488973
- const extraTemplateStrings = await this._extraTemplateStrings(originalMessage);
- return {
- bundle: this._orderBundle(result),
- ...(extraTemplateStrings && {extraTemplateStrings}),
- provider: originalMessage.provider,
- template: originalMessage.template,
- };
- }
- async _extraTemplateStrings(originalMessage) {
- let extraTemplateStrings;
- let localProvider = this._findProvider(originalMessage.provider);
- if (localProvider && localProvider.getExtraAttributes) {
- extraTemplateStrings = await localProvider.getExtraAttributes();
- }
- return extraTemplateStrings;
- }
- _findProvider(providerID) {
- return this._localProviders[this.state.providers.find(i => i.id === providerID).localProvider];
- }
- _getUnblockedMessages() {
- let {state} = this;
- return state.messages.filter(item =>
- !state.messageBlockList.includes(item.id) &&
- (!item.campaign || !state.messageBlockList.includes(item.campaign)) &&
- !state.providerBlockList.includes(item.provider)
- );
- }
- /**
- * Route messages based on template to the correct module that can display them
- */
- routeMessageToTarget(message, target, trigger, force = false) {
- switch (message.template) {
- case "cfr_doorhanger":
- if (force) {
- CFRPageActions.forceRecommendation(target, message, this.dispatch);
- } else {
- CFRPageActions.addRecommendation(target, trigger.param.host, message, this.dispatch);
- }
- break;
- case "fxa_bookmark_panel":
- if (force) {
- BookmarkPanelHub._forceShowMessage(target, message);
- }
- break;
- default:
- target.sendAsyncMessage(OUTGOING_MESSAGE_NAME, {type: "SET_MESSAGE", data: message});
- break;
- }
- }
- async _sendMessageToTarget(message, target, trigger, force = false) {
- // No message is available, so send CLEAR_ALL.
- if (!message) {
- try {
- target.sendAsyncMessage(OUTGOING_MESSAGE_NAME, {type: "CLEAR_ALL"});
- } catch (e) {}
- // For bundled messages, look for the rest of the bundle or else send CLEAR_ALL
- } else if (message.bundled) {
- const bundledMessages = await this._getBundledMessages(message, target, trigger, force);
- const action = bundledMessages ? {type: "SET_BUNDLED_MESSAGES", data: bundledMessages} : {type: "CLEAR_ALL"};
- try {
- target.sendAsyncMessage(OUTGOING_MESSAGE_NAME, action);
- } catch (e) {}
- // For nested bundled messages, look for the desired bundle
- } else if (message.includeBundle) {
- const bundledMessages = await this._getBundledMessages(message, target, message.includeBundle.trigger, force);
- try {
- target.sendAsyncMessage(OUTGOING_MESSAGE_NAME, {type: "SET_MESSAGE", data: {...message, bundle: bundledMessages && bundledMessages.bundle}});
- } catch (e) {}
- } else {
- try {
- this.routeMessageToTarget(message, target, trigger, force);
- } catch (e) {}
- }
- }
- async addImpression(message) {
- const provider = this.state.providers.find(p => p.id === message.provider);
- // We only need to store impressions for messages that have frequency, or
- // that have providers that have frequency
- if (message.frequency || (provider && provider.frequency)) {
- const time = Date.now();
- await this.setState(state => {
- const messageImpressions = this._addImpressionForItem(state, message, "messageImpressions", time);
- const providerImpressions = this._addImpressionForItem(state, provider, "providerImpressions", time);
- return {messageImpressions, providerImpressions};
- });
- }
- }
- // Helper for addImpression - calculate the updated impressions object for the given
- // item, then store it and return it
- _addImpressionForItem(state, item, impressionsString, time) {
- // The destructuring here is to avoid mutating existing objects in state as in redux
- // (see https://redux.js.org/recipes/structuring-reducers/prerequisite-concepts#immutable-data-management)
- const impressions = {...state[impressionsString]};
- if (item.frequency) {
- impressions[item.id] = impressions[item.id] ? [...impressions[item.id]] : [];
- impressions[item.id].push(time);
- this._storage.set(impressionsString, impressions);
- }
- return impressions;
- }
- /**
- * getLongestPeriod
- *
- * @param {obj} item Either an ASRouter message or an ASRouter provider
- * @returns {int|null} if the item has custom frequency caps, the longest period found in the list of caps.
- if the item has no custom frequency caps, null
- * @memberof _ASRouter
- */
- getLongestPeriod(item) {
- if (!item.frequency || !item.frequency.custom) {
- return null;
- }
- return item.frequency.custom.sort((a, b) => b.period - a.period)[0].period;
- }
- /**
- * cleanupImpressions - this function cleans up obsolete impressions whenever
- * messages are refreshed or fetched. It will likely need to be more sophisticated in the future,
- * but the current behaviour for when both message impressions and provider impressions are
- * cleared is as follows (where `item` is either `message` or `provider`):
- *
- * 1. If the item id for a list of item impressions no longer exists in the ASRouter state, it
- * will be cleared.
- * 2. If the item has time-bound frequency caps but no lifetime cap, any item impressions older
- * than the longest time period will be cleared.
- */
- async cleanupImpressions() {
- await this.setState(state => {
- const messageImpressions = this._cleanupImpressionsForItems(state, state.messages, "messageImpressions");
- const providerImpressions = this._cleanupImpressionsForItems(state, state.providers, "providerImpressions");
- return {messageImpressions, providerImpressions};
- });
- }
- // Helper for cleanupImpressions - calculate the updated impressions object for
- // the given items, then store it and return it
- _cleanupImpressionsForItems(state, items, impressionsString) {
- const impressions = {...state[impressionsString]};
- let needsUpdate = false;
- Object.keys(impressions).forEach(id => {
- const [item] = items.filter(x => x.id === id);
- // Don't keep impressions for items that no longer exist
- if (!item || !item.frequency || !Array.isArray(impressions[id])) {
- delete impressions[id];
- needsUpdate = true;
- return;
- }
- if (!impressions[id].length) {
- return;
- }
- // If we don't want to store impressions older than the longest period
- if (item.frequency.custom && !item.frequency.lifetime) {
- const now = Date.now();
- impressions[id] = impressions[id].filter(t => (now - t) < this.getLongestPeriod(item));
- needsUpdate = true;
- }
- });
- if (needsUpdate) {
- this._storage.set(impressionsString, impressions);
- }
- return impressions;
- }
- async sendNextMessage(target, trigger) {
- const msgs = this._getUnblockedMessages();
- let message = null;
- const previewMsgs = this.state.messages.filter(item => item.provider === "preview");
- // Always send preview messages when available
- if (previewMsgs.length) {
- [message] = previewMsgs;
- } else {
- message = await this._findMessage(msgs, trigger);
- }
- if (previewMsgs.length) {
- // We don't want to cache preview messages, remove them after we selected the message to show
- await this.setState(state => ({
- lastMessageId: message.id,
- messages: state.messages.filter(m => m.id !== message.id),
- }));
- } else {
- await this.setState({lastMessageId: message ? message.id : null});
- }
- await this._sendMessageToTarget(message, target, trigger);
- }
- handleMessageRequest(trigger) {
- const msgs = this._getUnblockedMessages();
- return this._findMessage(msgs.filter(m => m.trigger && m.trigger.id === trigger.id), trigger);
- }
- async setMessageById(id, target, force = true, action = {}) {
- await this.setState({lastMessageId: id});
- const newMessage = this.getMessageById(id);
- await this._sendMessageToTarget(newMessage, target, action.data, force);
- }
- async blockMessageById(idOrIds) {
- const idsToBlock = Array.isArray(idOrIds) ? idOrIds : [idOrIds];
- await this.setState(state => {
- const messageBlockList = [...state.messageBlockList];
- const messageImpressions = {...state.messageImpressions};
- idsToBlock.forEach(id => {
- const message = state.messages.find(m => m.id === id);
- const idToBlock = (message && message.campaign) ? message.campaign : id;
- if (!messageBlockList.includes(idToBlock)) {
- messageBlockList.push(idToBlock);
- }
- // When a message is blocked, its impressions should be cleared as well
- delete messageImpressions[id];
- });
- this._storage.set("messageBlockList", messageBlockList);
- this._storage.set("messageImpressions", messageImpressions);
- return {messageBlockList, messageImpressions};
- });
- }
- async blockProviderById(idOrIds) {
- const idsToBlock = Array.isArray(idOrIds) ? idOrIds : [idOrIds];
- await this.setState(state => {
- const providerBlockList = [...state.providerBlockList, ...idsToBlock];
- // When a provider is blocked, its impressions should be cleared as well
- const providerImpressions = {...state.providerImpressions};
- idsToBlock.forEach(id => delete providerImpressions[id]);
- this._storage.set("providerBlockList", providerBlockList);
- return {providerBlockList, providerImpressions};
- });
- }
- _validPreviewEndpoint(url) {
- try {
- const endpoint = new URL(url);
- if (!this.WHITELIST_HOSTS[endpoint.host]) {
- Cu.reportError(`The preview URL host ${endpoint.host} is not in the whitelist.`);
- }
- if (endpoint.protocol !== "https:") {
- Cu.reportError("The URL protocol is not https.");
- }
- return (endpoint.protocol === "https:" && this.WHITELIST_HOSTS[endpoint.host]);
- } catch (e) {
- return false;
- }
- }
- // Ensure we switch to the Onboarding message after RTAMO addon was installed
- _updateOnboardingState() {
- let addonInstallObs = (subject, topic) => {
- Services.obs.removeObserver(addonInstallObs, "webextension-install-notify");
- this.messageChannel.sendAsyncMessage(OUTGOING_MESSAGE_NAME, {type: "CLEAR_MESSAGE", data: {id: "RETURN_TO_AMO_1"}});
- this.blockMessageById("RETURN_TO_AMO_1");
- };
- Services.obs.addObserver(addonInstallObs, "webextension-install-notify");
- }
- _loadSnippetsWhitelistHosts() {
- let additionalHosts = [];
- const whitelistPrefValue = Services.prefs.getStringPref(SNIPPETS_ENDPOINT_WHITELIST, "");
- try {
- additionalHosts = JSON.parse(whitelistPrefValue);
- } catch (e) {
- if (whitelistPrefValue) {
- Cu.reportError(`Pref ${SNIPPETS_ENDPOINT_WHITELIST} value is not valid JSON`);
- }
- }
- if (!additionalHosts.length) {
- return DEFAULT_WHITELIST_HOSTS;
- }
- // If there are additional hosts we want to whitelist, add them as
- // `preview` so that the updateCycle is 0
- return additionalHosts.reduce((whitelist_hosts, host) => {
- whitelist_hosts[host] = "preview";
- Services.console.logStringMessage(`Adding ${host} to whitelist hosts.`);
- return whitelist_hosts;
- }, {...DEFAULT_WHITELIST_HOSTS});
- }
- // To be passed to ASRouterTriggerListeners
- async _triggerHandler(target, trigger) {
- await this.onMessage({target, data: {type: "TRIGGER", data: {trigger}}});
- }
- _removePreviewEndpoint(state) {
- state.providers = state.providers.filter(p => p.id !== "preview");
- return state;
- }
- async _addPreviewEndpoint(url, portID) {
- // When you view a preview snippet we want to hide all real content
- const providers = [...this.state.providers];
- if (this._validPreviewEndpoint(url) && !providers.find(p => p.url === url)) {
- this.dispatchToAS(ac.OnlyToOneContent({type: at.SNIPPETS_PREVIEW_MODE}, portID));
- providers.push({id: "preview", type: "remote", url, updateCycleInMs: 0});
- await this.setState({providers});
- }
- }
- // Windows specific calls to write attribution data
- // Used by `forceAttribution` to set required targeting attributes for
- // RTAMO messages. This should only be called from within about:newtab#asrouter
- /* istanbul ignore next */
- async _writeAttributionFile(data) {
- let appDir = Services.dirsvc.get("LocalAppData", Ci.nsIFile);
- let file = appDir.clone();
- file.append(Services.appinfo.vendor || "mozilla");
- file.append(AppConstants.MOZ_APP_NAME);
- await OS.File.makeDir(file.path,
- {from: appDir.path, ignoreExisting: true});
- file.append("postSigningData");
- await OS.File.writeAtomic(file.path, data);
- }
- /**
- * forceAttribution - this function should only be called from within about:newtab#asrouter.
- * It forces the browser attribution to be set to something specified in asrouter admin
- * tools, and reloads the providers in order to get messages that are dependant on this
- * attribution data (see Return to AMO flow in bug 1475354 for example). Note - OSX and Windows only
- * @param {data} Object an object containing the attribtion data that came from asrouter admin page
- */
- /* istanbul ignore next */
- async forceAttribution(data) {
- // Extract the parameters from data that will make up the referrer url
- const {source, campaign, content} = data;
- if (AppConstants.platform === "win") {
- const attributionData = `source=${source}&campaign=${campaign}&content=${content}`;
- this._writeAttributionFile(encodeURIComponent(attributionData));
- } else if (AppConstants.platform === "macosx") {
- let appPath = Services.dirsvc.get("GreD", Ci.nsIFile).parent.parent.path;
- let attributionSvc = Cc["@mozilla.org/mac-attribution;1"]
- .getService(Ci.nsIMacAttributionService);
- let referrer = `https://www.mozilla.org/anything/?utm_campaign=${campaign}&utm_source=${source}&utm_content=${encodeURIComponent(content)}`;
- // This sets the Attribution to be the referrer
- attributionSvc.setReferrerUrl(appPath, referrer, true);
- }
- // Clear cache call is only possible in a testing environment
- let env = Cc["@mozilla.org/process/environment;1"].getService(Ci.nsIEnvironment);
- env.set("XPCSHELL_TEST_PROFILE_DIR", "testing");
- // Clear and refresh Attribution, and then fetch the messages again to update
- AttributionCode._clearCache();
- AttributionCode.getAttrDataAsync();
- this._updateMessageProviders();
- await this.loadMessagesFromAllProviders();
- }
- async handleUserAction({data: action, target}) {
- switch (action.type) {
- case ra.OPEN_PRIVATE_BROWSER_WINDOW:
- // Forcefully open about:privatebrowsing
- target.browser.ownerGlobal.OpenBrowserWindow({private: true});
- break;
- case ra.OPEN_URL:
- target.browser.ownerGlobal.openLinkIn(action.data.args, action.data.where || "current", {
- private: false,
- triggeringPrincipal: Services.scriptSecurityManager.createNullPrincipal({}),
- csp: null,
- });
- break;
- case ra.OPEN_ABOUT_PAGE:
- target.browser.ownerGlobal.openTrustedLinkIn(`about:${action.data.args}`, "tab");
- break;
- case ra.OPEN_PREFERENCES_PAGE:
- target.browser.ownerGlobal.openPreferences(action.data.category);
- break;
- case ra.OPEN_APPLICATIONS_MENU:
- UITour.showMenu(target.browser.ownerGlobal, action.data.args);
- break;
- case ra.INSTALL_ADDON_FROM_URL:
- this._updateOnboardingState();
- await MessageLoaderUtils.installAddonFromURL(target.browser, action.data.url);
- break;
- case ra.PIN_CURRENT_TAB:
- let tab = target.browser.ownerGlobal.gBrowser.selectedTab;
- target.browser.ownerGlobal.gBrowser.pinTab(tab);
- target.browser.ownerGlobal.ConfirmationHint.show(tab, "pinTab", {showDescription: true});
- break;
- case ra.SHOW_FIREFOX_ACCOUNTS:
- const url = await FxAccounts.config.promiseSignUpURI("snippets");
- // We want to replace the current tab.
- target.browser.ownerGlobal.openLinkIn(url, "current", {
- private: false,
- triggeringPrincipal: Services.scriptSecurityManager.createNullPrincipal({}),
- csp: null,
- });
- break;
- }
- }
- dispatch(action, target) {
- this.onMessage({data: action, target});
- }
- /* eslint-disable complexity */
- async onMessage({data: action, target}) {
- switch (action.type) {
- case "USER_ACTION":
- if (action.data.type in ra) {
- await this.handleUserAction({data: action.data, target});
- }
- break;
- case "SNIPPETS_REQUEST":
- case "TRIGGER":
- // Wait for our initial message loading to be done before responding to any UI requests
- await this.waitForInitialized;
- if (action.data && action.data.endpoint) {
- await this._addPreviewEndpoint(action.data.endpoint.url, target.portID);
- }
- // Special experiment intialization for trailhead
- if (action.data && action.data.trigger && action.data.trigger.id === "firstRun") {
- Services.prefs.setBoolPref(TRAILHEAD_CONFIG.DID_SEE_ABOUT_WELCOME_PREF, true);
- await this.setupTrailhead();
- }
- // Check if any updates are needed first
- await this.loadMessagesFromAllProviders();
- await this.sendNextMessage(target, (action.data && action.data.trigger) || {});
- break;
- case "BLOCK_MESSAGE_BY_ID":
- await this.blockMessageById(action.data.id);
- // Block the message but don't dismiss it in case the action taken has
- // another state that needs to be visible
- if (action.data.preventDismiss) {
- break;
- }
- this.messageChannel.sendAsyncMessage(OUTGOING_MESSAGE_NAME, {type: "CLEAR_MESSAGE", data: {id: action.data.id}});
- break;
- case "DISMISS_MESSAGE_BY_ID":
- this.messageChannel.sendAsyncMessage(OUTGOING_MESSAGE_NAME, {type: "CLEAR_MESSAGE", data: {id: action.data.id}});
- break;
- case "BLOCK_PROVIDER_BY_ID":
- await this.blockProviderById(action.data.id);
- this.messageChannel.sendAsyncMessage(OUTGOING_MESSAGE_NAME, {type: "CLEAR_PROVIDER", data: {id: action.data.id}});
- break;
- case "DISMISS_BUNDLE":
- this.messageChannel.sendAsyncMessage(OUTGOING_MESSAGE_NAME, {type: "CLEAR_BUNDLE"});
- break;
- case "BLOCK_BUNDLE":
- await this.blockMessageById(action.data.bundle.map(b => b.id));
- this.messageChannel.sendAsyncMessage(OUTGOING_MESSAGE_NAME, {type: "CLEAR_BUNDLE"});
- break;
- case "UNBLOCK_MESSAGE_BY_ID":
- await this.setState(state => {
- const messageBlockList = [...state.messageBlockList];
- const message = state.messages.find(m => m.id === action.data.id);
- const idToUnblock = (message && message.campaign) ? message.campaign : action.data.id;
- messageBlockList.splice(messageBlockList.indexOf(idToUnblock), 1);
- this._storage.set("messageBlockList", messageBlockList);
- return {messageBlockList};
- });
- break;
- case "UNBLOCK_PROVIDER_BY_ID":
- await this.setState(state => {
- const providerBlockList = [...state.providerBlockList];
- providerBlockList.splice(providerBlockList.indexOf(action.data.id), 1);
- this._storage.set("providerBlockList", providerBlockList);
- return {providerBlockList};
- });
- break;
- case "UNBLOCK_BUNDLE":
- await this.setState(state => {
- const messageBlockList = [...state.messageBlockList];
- for (let message of action.data.bundle) {
- messageBlockList.splice(messageBlockList.indexOf(message.id), 1);
- }
- this._storage.set("messageBlockList", messageBlockList);
- return {messageBlockList};
- });
- break;
- case "OVERRIDE_MESSAGE":
- await this.setMessageById(action.data.id, target, true, action);
- break;
- case "ADMIN_CONNECT_STATE":
- if (action.data && action.data.endpoint) {
- this._addPreviewEndpoint(action.data.endpoint.url, target.portID);
- await this.loadMessagesFromAllProviders();
- } else {
- await this._updateAdminState(target);
- }
- break;
- case "IMPRESSION":
- await this.addImpression(action.data);
- break;
- case "DOORHANGER_TELEMETRY":
- if (this.dispatchToAS) {
- this.dispatchToAS(ac.ASRouterUserEvent(action.data));
- }
- break;
- case "EXPIRE_QUERY_CACHE":
- QueryCache.expireAll();
- break;
- case "ENABLE_PROVIDER":
- ASRouterPreferences.enableOrDisableProvider(action.data, true);
- break;
- case "DISABLE_PROVIDER":
- ASRouterPreferences.enableOrDisableProvider(action.data, false);
- break;
- case "RESET_PROVIDER_PREF":
- ASRouterPreferences.resetProviderPref();
- break;
- case "SET_PROVIDER_USER_PREF":
- ASRouterPreferences.setUserPreference(action.data.id, action.data.value);
- break;
- case "EVALUATE_JEXL_EXPRESSION":
- this.evaluateExpression(target, action.data);
- break;
- case "FORCE_ATTRIBUTION":
- this.forceAttribution(action.data);
- break;
- default:
- Cu.reportError("Unknown message received");
- break;
- }
- }
- }
- this._ASRouter = _ASRouter;
- this.chooseBranch = chooseBranch;
- this.TRAILHEAD_CONFIG = TRAILHEAD_CONFIG;
- /**
- * ASRouter - singleton instance of _ASRouter that controls all messages
- * in the new tab page.
- */
- this.ASRouter = new _ASRouter();
- const EXPORTED_SYMBOLS = ["_ASRouter", "ASRouter", "MessageLoaderUtils", "chooseBranch", "TRAILHEAD_CONFIG"];
|