ASRouter.jsm 57 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493
  1. /* This Source Code Form is subject to the terms of the Mozilla Public
  2. * License, v. 2.0. If a copy of the MPL was not distributed with this
  3. * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
  4. "use strict";
  5. const {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm");
  6. const {XPCOMUtils} = ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
  7. XPCOMUtils.defineLazyGlobalGetters(this, ["fetch"]);
  8. XPCOMUtils.defineLazyModuleGetters(this, {
  9. AddonManager: "resource://gre/modules/AddonManager.jsm",
  10. UITour: "resource:///modules/UITour.jsm",
  11. FxAccounts: "resource://gre/modules/FxAccounts.jsm",
  12. AppConstants: "resource://gre/modules/AppConstants.jsm",
  13. OS: "resource://gre/modules/osfile.jsm",
  14. BookmarkPanelHub: "resource://activity-stream/lib/BookmarkPanelHub.jsm",
  15. SnippetsTestMessageProvider: "resource://activity-stream/lib/SnippetsTestMessageProvider.jsm",
  16. PanelTestProvider: "resource://activity-stream/lib/PanelTestProvider.jsm",
  17. });
  18. const {ASRouterActions: ra, actionTypes: at, actionCreators: ac} = ChromeUtils.import("resource://activity-stream/common/Actions.jsm");
  19. const {CFRMessageProvider} = ChromeUtils.import("resource://activity-stream/lib/CFRMessageProvider.jsm");
  20. const {OnboardingMessageProvider} = ChromeUtils.import("resource://activity-stream/lib/OnboardingMessageProvider.jsm");
  21. const {RemoteSettings} = ChromeUtils.import("resource://services-settings/remote-settings.js");
  22. const {CFRPageActions} = ChromeUtils.import("resource://activity-stream/lib/CFRPageActions.jsm");
  23. const {AttributionCode} = ChromeUtils.import("resource:///modules/AttributionCode.jsm");
  24. ChromeUtils.defineModuleGetter(this, "ASRouterPreferences",
  25. "resource://activity-stream/lib/ASRouterPreferences.jsm");
  26. ChromeUtils.defineModuleGetter(this, "TARGETING_PREFERENCES",
  27. "resource://activity-stream/lib/ASRouterPreferences.jsm");
  28. ChromeUtils.defineModuleGetter(this, "ASRouterTargeting",
  29. "resource://activity-stream/lib/ASRouterTargeting.jsm");
  30. ChromeUtils.defineModuleGetter(this, "QueryCache",
  31. "resource://activity-stream/lib/ASRouterTargeting.jsm");
  32. ChromeUtils.defineModuleGetter(this, "ASRouterTriggerListeners",
  33. "resource://activity-stream/lib/ASRouterTriggerListeners.jsm");
  34. ChromeUtils.defineModuleGetter(this, "TelemetryEnvironment",
  35. "resource://gre/modules/TelemetryEnvironment.jsm");
  36. ChromeUtils.defineModuleGetter(this, "ClientEnvironment",
  37. "resource://normandy/lib/ClientEnvironment.jsm");
  38. ChromeUtils.defineModuleGetter(this, "Sampling",
  39. "resource://gre/modules/components-utils/Sampling.jsm");
  40. const TRAILHEAD_CONFIG = {
  41. OVERRIDE_PREF: "trailhead.firstrun.branches",
  42. DID_SEE_ABOUT_WELCOME_PREF: "trailhead.firstrun.didSeeAboutWelcome",
  43. INTERRUPTS_EXPERIMENT_PREF: "trailhead.firstrun.interruptsExperiment",
  44. TRIPLETS_ENROLLED_PREF: "trailhead.firstrun.tripletsEnrolled",
  45. BRANCHES: {
  46. interrupts: [
  47. ["control"],
  48. ["join"],
  49. ["sync"],
  50. ["nofirstrun"],
  51. ["cards"],
  52. ],
  53. triplets: [
  54. ["supercharge"],
  55. ["payoff"],
  56. ["multidevice"],
  57. ["privacy"],
  58. ],
  59. },
  60. LOCALES: ["en-US", "en-GB", "en-CA", "de", "de-DE", "fr", "fr-FR"],
  61. EXPERIMENT_RATIOS: [
  62. ["", 0],
  63. ["interrupts", 1],
  64. ["triplets", 3],
  65. ],
  66. };
  67. const INCOMING_MESSAGE_NAME = "ASRouter:child-to-parent";
  68. const OUTGOING_MESSAGE_NAME = "ASRouter:parent-to-child";
  69. const ONE_DAY_IN_MS = 24 * 60 * 60 * 1000;
  70. // List of hosts for endpoints that serve router messages.
  71. // Key is allowed host, value is a name for the endpoint host.
  72. const DEFAULT_WHITELIST_HOSTS = {
  73. "activity-stream-icons.services.mozilla.com": "production",
  74. "snippets-admin.mozilla.org": "preview",
  75. };
  76. const SNIPPETS_ENDPOINT_WHITELIST = "browser.newtab.activity-stream.asrouter.whitelistHosts";
  77. // Max possible impressions cap for any message
  78. const MAX_MESSAGE_LIFETIME_CAP = 100;
  79. const LOCAL_MESSAGE_PROVIDERS = {OnboardingMessageProvider, CFRMessageProvider};
  80. const STARTPAGE_VERSION = "6";
  81. /**
  82. * chooseBranch<T> - Choose an item from a list of "branches" pseudorandomly using a seed / ratio configuration
  83. * @param seed {string} A unique seed for the randomizer
  84. * @param branches {Array<[T, number?]>} A list of branches, where branch[0] is any item and branch[1] is the ratio
  85. * @returns {T} An randomly chosen item in a branch
  86. */
  87. async function chooseBranch(seed, branches) {
  88. const ratios = branches.map(([item, ratio]) => ((typeof ratio !== "undefined") ? ratio : 1));
  89. return branches[await Sampling.ratioSample(seed, ratios)][0];
  90. }
  91. const MessageLoaderUtils = {
  92. STARTPAGE_VERSION,
  93. REMOTE_LOADER_CACHE_KEY: "RemoteLoaderCache",
  94. _errors: [],
  95. reportError(e) {
  96. Cu.reportError(e);
  97. this._errors.push({timestamp: new Date(), error: {message: e.toString(), stack: e.stack}});
  98. },
  99. get errors() {
  100. const errors = this._errors;
  101. this._errors = [];
  102. return errors;
  103. },
  104. /**
  105. * _localLoader - Loads messages for a local provider (i.e. one that lives in mozilla central)
  106. *
  107. * @param {obj} provider An AS router provider
  108. * @param {Array} provider.messages An array of messages
  109. * @returns {Array} the array of messages
  110. */
  111. _localLoader(provider) {
  112. return provider.messages;
  113. },
  114. async _remoteLoaderCache(storage) {
  115. let allCached;
  116. try {
  117. allCached = await storage.get(MessageLoaderUtils.REMOTE_LOADER_CACHE_KEY) || {};
  118. } catch (e) {
  119. // istanbul ignore next
  120. MessageLoaderUtils.reportError(e);
  121. // istanbul ignore next
  122. allCached = {};
  123. }
  124. return allCached;
  125. },
  126. /**
  127. * _remoteLoader - Loads messages for a remote provider
  128. *
  129. * @param {obj} provider An AS router provider
  130. * @param {string} provider.url An endpoint that returns an array of messages as JSON
  131. * @param {obj} options.storage A storage object with get() and set() methods for caching.
  132. * @returns {Promise} resolves with an array of messages, or an empty array if none could be fetched
  133. */
  134. async _remoteLoader(provider, options) {
  135. let remoteMessages = [];
  136. if (provider.url) {
  137. const allCached = await MessageLoaderUtils._remoteLoaderCache(options.storage);
  138. const cached = allCached[provider.id];
  139. let etag;
  140. if (cached && cached.url === provider.url && cached.version === STARTPAGE_VERSION) {
  141. const {lastFetched, messages} = cached;
  142. if (!MessageLoaderUtils.shouldProviderUpdate({...provider, lastUpdated: lastFetched})) {
  143. // Cached messages haven't expired, return early.
  144. return messages;
  145. }
  146. etag = cached.etag;
  147. remoteMessages = messages;
  148. }
  149. let headers = new Headers();
  150. if (etag) {
  151. headers.set("If-None-Match", etag);
  152. }
  153. let response;
  154. try {
  155. response = await fetch(provider.url, {headers, credentials: "omit"});
  156. } catch (e) {
  157. MessageLoaderUtils.reportError(e);
  158. }
  159. if (
  160. response &&
  161. response.ok &&
  162. (response.status >= 200 && response.status < 400)
  163. ) {
  164. let jsonResponse;
  165. try {
  166. jsonResponse = await response.json();
  167. } catch (e) {
  168. MessageLoaderUtils.reportError(e);
  169. return remoteMessages;
  170. }
  171. if (jsonResponse && jsonResponse.messages) {
  172. remoteMessages = jsonResponse.messages
  173. .map(msg => ({...msg, provider_url: provider.url}));
  174. // Cache the results if this isn't a preview URL.
  175. if (provider.updateCycleInMs > 0) {
  176. etag = response.headers.get("ETag");
  177. const cacheInfo = {
  178. messages: remoteMessages,
  179. etag,
  180. lastFetched: Date.now(),
  181. version: STARTPAGE_VERSION,
  182. };
  183. options.storage.set(MessageLoaderUtils.REMOTE_LOADER_CACHE_KEY, {...allCached, [provider.id]: cacheInfo});
  184. }
  185. } else {
  186. MessageLoaderUtils.reportError(`No messages returned from ${provider.url}.`);
  187. }
  188. } else if (response) {
  189. MessageLoaderUtils.reportError(`Invalid response status ${response.status} from ${provider.url}.`);
  190. }
  191. }
  192. return remoteMessages;
  193. },
  194. /**
  195. * _remoteSettingsLoader - Loads messages for a RemoteSettings provider
  196. *
  197. * @param {obj} provider An AS router provider
  198. * @param {string} provider.id The id of the provider
  199. * @param {string} provider.bucket The name of the Remote Settings bucket
  200. * @param {func} options.dispatchToAS dispatch an action the main AS Store
  201. * @returns {Promise} resolves with an array of messages, or an empty array if none could be fetched
  202. */
  203. async _remoteSettingsLoader(provider, options) {
  204. let messages = [];
  205. if (provider.bucket) {
  206. try {
  207. messages = await MessageLoaderUtils._getRemoteSettingsMessages(provider.bucket);
  208. if (!messages.length) {
  209. MessageLoaderUtils._handleRemoteSettingsUndesiredEvent("ASR_RS_NO_MESSAGES", provider.id, options.dispatchToAS);
  210. }
  211. } catch (e) {
  212. MessageLoaderUtils._handleRemoteSettingsUndesiredEvent("ASR_RS_ERROR", provider.id, options.dispatchToAS);
  213. MessageLoaderUtils.reportError(e);
  214. }
  215. }
  216. return messages;
  217. },
  218. _getRemoteSettingsMessages(bucket) {
  219. return RemoteSettings(bucket).get();
  220. },
  221. _handleRemoteSettingsUndesiredEvent(event, providerId, dispatchToAS) {
  222. if (dispatchToAS) {
  223. dispatchToAS(ac.ASRouterUserEvent({
  224. action: "asrouter_undesired_event",
  225. event,
  226. message_id: "n/a",
  227. value: providerId,
  228. }));
  229. }
  230. },
  231. /**
  232. * _getMessageLoader - return the right loading function given the provider's type
  233. *
  234. * @param {obj} provider An AS Router provider
  235. * @returns {func} A loading function
  236. */
  237. _getMessageLoader(provider) {
  238. switch (provider.type) {
  239. case "remote":
  240. return this._remoteLoader;
  241. case "remote-settings":
  242. return this._remoteSettingsLoader;
  243. case "local":
  244. default:
  245. return this._localLoader;
  246. }
  247. },
  248. /**
  249. * shouldProviderUpdate - Given the current time, should a provider update its messages?
  250. *
  251. * @param {any} provider An AS Router provider
  252. * @param {int} provider.updateCycleInMs The number of milliseconds we should wait between updates
  253. * @param {Date} provider.lastUpdated If the provider has been updated, the time the last update occurred
  254. * @param {Date} currentTime The time we should check against. (defaults to Date.now())
  255. * @returns {bool} Should an update happen?
  256. */
  257. shouldProviderUpdate(provider, currentTime = Date.now()) {
  258. return (!(provider.lastUpdated >= 0) || currentTime - provider.lastUpdated > provider.updateCycleInMs);
  259. },
  260. /**
  261. * loadMessagesForProvider - Load messages for a provider, given the provider's type.
  262. *
  263. * @param {obj} provider An AS Router provider
  264. * @param {string} provider.type An AS Router provider type (defaults to "local")
  265. * @param {obj} options.storage A storage object with get() and set() methods for caching.
  266. * @param {func} options.dispatchToAS dispatch an action the main AS Store
  267. * @returns {obj} Returns an object with .messages (an array of messages) and .lastUpdated (the time the messages were updated)
  268. */
  269. async loadMessagesForProvider(provider, options) {
  270. const loader = this._getMessageLoader(provider);
  271. let messages = await loader(provider, options);
  272. // istanbul ignore if
  273. if (!messages) {
  274. messages = [];
  275. MessageLoaderUtils.reportError(new Error(`Tried to load messages for ${provider.id} but the result was not an Array.`));
  276. }
  277. // Filter out messages we temporarily want to exclude
  278. if (provider.exclude && provider.exclude.length) {
  279. messages = messages.filter(message => !provider.exclude.includes(message.id));
  280. }
  281. const lastUpdated = Date.now();
  282. return {
  283. messages: messages.map(msg => ({weight: 100, ...msg, provider: provider.id}))
  284. .filter(message => message.weight > 0),
  285. lastUpdated,
  286. errors: MessageLoaderUtils.errors,
  287. };
  288. },
  289. /**
  290. * _loadAddonIconInURLBar - load addons-notification icon by displaying
  291. * box containing addons icon in urlbar. See Bug 1513882
  292. *
  293. * @param {XULElement} Target browser element for showing addons icon
  294. */
  295. _loadAddonIconInURLBar(browser) {
  296. if (!browser) {
  297. return;
  298. }
  299. const chromeDoc = browser.ownerDocument;
  300. let notificationPopupBox = chromeDoc.getElementById("notification-popup-box");
  301. if (!notificationPopupBox) {
  302. return;
  303. }
  304. if (notificationPopupBox.style.display === "none" ||
  305. notificationPopupBox.style.display === "") {
  306. notificationPopupBox.style.display = "block";
  307. }
  308. },
  309. async installAddonFromURL(browser, url) {
  310. try {
  311. MessageLoaderUtils._loadAddonIconInURLBar(browser);
  312. const aUri = Services.io.newURI(url);
  313. const systemPrincipal = Services.scriptSecurityManager.getSystemPrincipal();
  314. // AddonManager installation source associated to the addons installed from activitystream's CFR
  315. const telemetryInfo = {source: "amo"};
  316. const install = await AddonManager.getInstallForURL(aUri.spec, {telemetryInfo});
  317. await AddonManager.installAddonFromWebpage("application/x-xpinstall", browser,
  318. systemPrincipal, install);
  319. } catch (e) {
  320. Cu.reportError(e);
  321. }
  322. },
  323. /**
  324. * cleanupCache - Removes cached data of removed providers.
  325. *
  326. * @param {Array} providers A list of activer AS Router providers
  327. */
  328. async cleanupCache(providers, storage) {
  329. const ids = providers.filter(p => p.type === "remote").map(p => p.id);
  330. const cache = await MessageLoaderUtils._remoteLoaderCache(storage);
  331. let dirty = false;
  332. for (let id in cache) {
  333. if (!ids.includes(id)) {
  334. delete cache[id];
  335. dirty = true;
  336. }
  337. }
  338. if (dirty) {
  339. await storage.set(MessageLoaderUtils.REMOTE_LOADER_CACHE_KEY, cache);
  340. }
  341. },
  342. };
  343. this.MessageLoaderUtils = MessageLoaderUtils;
  344. /**
  345. * @class _ASRouter - Keeps track of all messages, UI surfaces, and
  346. * handles blocking, rotation, etc. Inspecting ASRouter.state will
  347. * tell you what the current displayed message is in all UI surfaces.
  348. *
  349. * Note: This is written as a constructor rather than just a plain object
  350. * so that it can be more easily unit tested.
  351. */
  352. class _ASRouter {
  353. constructor(localProviders = LOCAL_MESSAGE_PROVIDERS) {
  354. this.initialized = false;
  355. this.messageChannel = null;
  356. this.dispatchToAS = null;
  357. this._storage = null;
  358. this._resetInitialization();
  359. this._state = {
  360. lastMessageId: null,
  361. providers: [],
  362. messageBlockList: [],
  363. providerBlockList: [],
  364. messageImpressions: {},
  365. providerImpressions: {},
  366. trailheadInitialized: false,
  367. trailheadInterrupt: "",
  368. trailheadTriplet: "",
  369. messages: [],
  370. errors: [],
  371. };
  372. this._triggerHandler = this._triggerHandler.bind(this);
  373. this._localProviders = localProviders;
  374. this.onMessage = this.onMessage.bind(this);
  375. this.handleMessageRequest = this.handleMessageRequest.bind(this);
  376. this.addImpression = this.addImpression.bind(this);
  377. this._handleTargetingError = this._handleTargetingError.bind(this);
  378. this.onPrefChange = this.onPrefChange.bind(this);
  379. }
  380. async onPrefChange(prefName) {
  381. if (TARGETING_PREFERENCES.includes(prefName)) {
  382. // Notify all tabs of messages that have become invalid after pref change
  383. const invalidMessages = [];
  384. for (const msg of this._getUnblockedMessages()) {
  385. if (!msg.targeting) {
  386. continue;
  387. }
  388. const isMatch = await ASRouterTargeting.isMatch(msg.targeting);
  389. if (!isMatch) {
  390. invalidMessages.push(msg.id);
  391. }
  392. }
  393. this.messageChannel.sendAsyncMessage(OUTGOING_MESSAGE_NAME, {type: at.AS_ROUTER_TARGETING_UPDATE, data: invalidMessages});
  394. } else {
  395. // Update message providers and fetch new messages on pref change
  396. this._loadLocalProviders();
  397. this._updateMessageProviders();
  398. await this.loadMessagesFromAllProviders();
  399. }
  400. }
  401. // Replace all frequency time period aliases with their millisecond values
  402. // This allows us to avoid accounting for special cases later on
  403. normalizeItemFrequency({frequency}) {
  404. if (frequency && frequency.custom) {
  405. for (const setting of frequency.custom) {
  406. if (setting.period === "daily") {
  407. setting.period = ONE_DAY_IN_MS;
  408. }
  409. }
  410. }
  411. }
  412. // Fetch and decode the message provider pref JSON, and update the message providers
  413. _updateMessageProviders() {
  414. const previousProviders = this.state.providers;
  415. const providers = [
  416. // If we have added a `preview` provider, hold onto it
  417. ...previousProviders.filter(p => p.id === "preview"),
  418. // The provider should be enabled and not have a user preference set to false
  419. ...ASRouterPreferences.providers.filter(p => (
  420. p.enabled &&
  421. (
  422. ASRouterPreferences.getUserPreference(p.id) !== false &&
  423. // Provider is enabled or if provider has multiple categories
  424. // check that at least one category is enabled
  425. (!p.categories || p.categories.some(c => ASRouterPreferences.getUserPreference(c) !== false))
  426. )
  427. )),
  428. ].map(_provider => {
  429. // make a copy so we don't modify the source of the pref
  430. const provider = {..._provider};
  431. if (provider.type === "local" && !provider.messages) {
  432. // Get the messages from the local message provider
  433. const localProvider = this._localProviders[provider.localProvider];
  434. provider.messages = localProvider ? localProvider.getMessages() : [];
  435. }
  436. if (provider.type === "remote" && provider.url) {
  437. provider.url = provider.url.replace(/%STARTPAGE_VERSION%/g, STARTPAGE_VERSION);
  438. provider.url = Services.urlFormatter.formatURL(provider.url);
  439. }
  440. this.normalizeItemFrequency(provider);
  441. // Reset provider update timestamp to force message refresh
  442. provider.lastUpdated = undefined;
  443. return provider;
  444. });
  445. const providerIDs = providers.map(p => p.id);
  446. // Clear old messages for providers that are no longer enabled
  447. for (const prevProvider of previousProviders) {
  448. if (!providerIDs.includes(prevProvider.id)) {
  449. this.messageChannel.sendAsyncMessage(OUTGOING_MESSAGE_NAME, {type: "CLEAR_PROVIDER", data: {id: prevProvider.id}});
  450. }
  451. }
  452. this.setState(prevState => ({
  453. providers,
  454. // Clear any messages from removed providers
  455. messages: [...prevState.messages.filter(message => providerIDs.includes(message.provider))],
  456. }));
  457. }
  458. get state() {
  459. return this._state;
  460. }
  461. set state(value) {
  462. throw new Error("Do not modify this.state directy. Instead, call this.setState(newState)");
  463. }
  464. /**
  465. * _resetInitialization - adds the following to the instance:
  466. * .initialized {bool} Has AS Router been initialized?
  467. * .waitForInitialized {Promise} A promise that resolves when initializion is complete
  468. * ._finishInitializing {func} A function that, when called, resolves the .waitForInitialized
  469. * promise and sets .initialized to true.
  470. * @memberof _ASRouter
  471. */
  472. _resetInitialization() {
  473. this.initialized = false;
  474. this.waitForInitialized = new Promise(resolve => {
  475. this._finishInitializing = () => {
  476. this.initialized = true;
  477. resolve();
  478. };
  479. });
  480. }
  481. /**
  482. * loadMessagesFromAllProviders - Loads messages from all providers if they require updates.
  483. * Checks the .lastUpdated field on each provider to see if updates are needed
  484. * @memberof _ASRouter
  485. */
  486. async loadMessagesFromAllProviders() {
  487. const needsUpdate = this.state.providers.filter(provider => MessageLoaderUtils.shouldProviderUpdate(provider));
  488. // Don't do extra work if we don't need any updates
  489. if (needsUpdate.length) {
  490. let newState = {messages: [], providers: []};
  491. for (const provider of this.state.providers) {
  492. if (needsUpdate.includes(provider)) {
  493. let {messages, lastUpdated, errors} = await MessageLoaderUtils.loadMessagesForProvider(provider, {
  494. storage: this._storage,
  495. dispatchToAS: this.dispatchToAS,
  496. });
  497. messages = messages.filter(({content}) => !content || !content.category || ASRouterPreferences.getUserPreference(content.category));
  498. newState.providers.push({...provider, lastUpdated, errors});
  499. newState.messages = [...newState.messages, ...messages];
  500. } else {
  501. // Skip updating this provider's messages if no update is required
  502. let messages = this.state.messages.filter(msg => msg.provider === provider.id);
  503. newState.providers.push(provider);
  504. newState.messages = [...newState.messages, ...messages];
  505. }
  506. }
  507. for (const message of newState.messages) {
  508. this.normalizeItemFrequency(message);
  509. }
  510. // Some messages have triggers that require us to initalise trigger listeners
  511. const unseenListeners = new Set(ASRouterTriggerListeners.keys());
  512. for (const {trigger} of newState.messages) {
  513. if (trigger && ASRouterTriggerListeners.has(trigger.id)) {
  514. await ASRouterTriggerListeners.get(trigger.id).init(this._triggerHandler, trigger.params, trigger.patterns);
  515. unseenListeners.delete(trigger.id);
  516. }
  517. }
  518. // We don't need these listeners, but they may have previously been
  519. // initialised, so uninitialise them
  520. for (const triggerID of unseenListeners) {
  521. ASRouterTriggerListeners.get(triggerID).uninit();
  522. }
  523. // We don't want to cache preview endpoints, remove them after messages are fetched
  524. await this.setState(this._removePreviewEndpoint(newState));
  525. await this.cleanupImpressions();
  526. }
  527. }
  528. /**
  529. * init - Initializes the MessageRouter.
  530. * It is ready when it has been connected to a RemotePageManager instance.
  531. *
  532. * @param {RemotePageManager} channel a RemotePageManager instance
  533. * @param {obj} storage an AS storage instance
  534. * @param {func} dispatchToAS dispatch an action the main AS Store
  535. * @memberof _ASRouter
  536. */
  537. async init(channel, storage, dispatchToAS) {
  538. this.messageChannel = channel;
  539. this.messageChannel.addMessageListener(INCOMING_MESSAGE_NAME, this.onMessage);
  540. this._storage = storage;
  541. this.WHITELIST_HOSTS = this._loadSnippetsWhitelistHosts();
  542. this.dispatchToAS = dispatchToAS;
  543. this.dispatch = this.dispatch.bind(this);
  544. ASRouterPreferences.init();
  545. ASRouterPreferences.addListener(this.onPrefChange);
  546. BookmarkPanelHub.init(this.handleMessageRequest, this.addImpression, this.dispatch);
  547. this._loadLocalProviders();
  548. // We need to check whether to set up telemetry for trailhead
  549. await this.setupTrailhead();
  550. const messageBlockList = await this._storage.get("messageBlockList") || [];
  551. const providerBlockList = await this._storage.get("providerBlockList") || [];
  552. const messageImpressions = await this._storage.get("messageImpressions") || {};
  553. const providerImpressions = await this._storage.get("providerImpressions") || {};
  554. const previousSessionEnd = await this._storage.get("previousSessionEnd") || 0;
  555. await this.setState({messageBlockList, providerBlockList, messageImpressions, providerImpressions, previousSessionEnd});
  556. this._updateMessageProviders();
  557. await this.loadMessagesFromAllProviders();
  558. await MessageLoaderUtils.cleanupCache(this.state.providers, storage);
  559. // set necessary state in the rest of AS
  560. this.dispatchToAS(ac.BroadcastToContent({type: at.AS_ROUTER_INITIALIZED, data: ASRouterPreferences.specialConditions}));
  561. // sets .initialized to true and resolves .waitForInitialized promise
  562. this._finishInitializing();
  563. }
  564. uninit() {
  565. this._storage.set("previousSessionEnd", Date.now());
  566. this.messageChannel.sendAsyncMessage(OUTGOING_MESSAGE_NAME, {type: "CLEAR_ALL"});
  567. this.messageChannel.removeMessageListener(INCOMING_MESSAGE_NAME, this.onMessage);
  568. this.messageChannel = null;
  569. this.dispatchToAS = null;
  570. ASRouterPreferences.removeListener(this.onPrefChange);
  571. ASRouterPreferences.uninit();
  572. BookmarkPanelHub.uninit();
  573. // Uninitialise all trigger listeners
  574. for (const listener of ASRouterTriggerListeners.values()) {
  575. listener.uninit();
  576. }
  577. // If we added any CFR recommendations, they need to be removed
  578. CFRPageActions.clearRecommendations();
  579. this._resetInitialization();
  580. }
  581. setState(callbackOrObj) {
  582. const newState = (typeof callbackOrObj === "function") ? callbackOrObj(this.state) : callbackOrObj;
  583. this._state = {...this.state, ...newState};
  584. return new Promise(resolve => {
  585. this._onStateChanged(this.state);
  586. resolve();
  587. });
  588. }
  589. getMessageById(id) {
  590. return this.state.messages.find(message => message.id === id);
  591. }
  592. _onStateChanged(state) {
  593. if (ASRouterPreferences.devtoolsEnabled) {
  594. this._updateAdminState();
  595. }
  596. }
  597. _loadLocalProviders() {
  598. // If we're in ASR debug mode add the local test providers
  599. if (ASRouterPreferences.devtoolsEnabled) {
  600. this._localProviders = {
  601. ...this._localProviders,
  602. SnippetsTestMessageProvider,
  603. PanelTestProvider,
  604. };
  605. }
  606. }
  607. /**
  608. * Used by ASRouter Admin returns all ASRouterTargeting.Environment
  609. * and ASRouter._getMessagesContext parameters and values
  610. */
  611. async getTargetingParameters(environment, localContext) {
  612. const targetingParameters = {};
  613. for (const param of Object.keys(environment)) {
  614. targetingParameters[param] = await environment[param];
  615. }
  616. for (const param of Object.keys(localContext)) {
  617. targetingParameters[param] = await localContext[param];
  618. }
  619. return targetingParameters;
  620. }
  621. async _updateAdminState(target) {
  622. const channel = target || this.messageChannel;
  623. channel.sendAsyncMessage(OUTGOING_MESSAGE_NAME, {
  624. type: "ADMIN_SET_STATE",
  625. data: {
  626. ...this.state,
  627. providerPrefs: ASRouterPreferences.providers,
  628. userPrefs: ASRouterPreferences.getAllUserPreferences(),
  629. targetingParameters: await this.getTargetingParameters(ASRouterTargeting.Environment, this._getMessagesContext()),
  630. errors: this.errors,
  631. },
  632. });
  633. }
  634. _handleTargetingError(type, error, message) {
  635. Cu.reportError(error);
  636. if (this.dispatchToAS) {
  637. this.dispatchToAS(ac.ASRouterUserEvent({
  638. message_id: message.id,
  639. action: "asrouter_undesired_event",
  640. event: "TARGETING_EXPRESSION_ERROR",
  641. value: type,
  642. }));
  643. }
  644. }
  645. async _hasAddonAttributionData() {
  646. try {
  647. const data = await AttributionCode.getAttrDataAsync() || {};
  648. return data.source === "addons.mozilla.org";
  649. } catch (e) {
  650. return false;
  651. }
  652. }
  653. /**
  654. * _generateTrailheadBranches - Generates and returns Trailhead configuration and chooses an experiment
  655. * based on clientID and locale.
  656. * @returns {{experiment: string, interrupt: string, triplet: string}}
  657. */
  658. async _generateTrailheadBranches() {
  659. let experiment = "";
  660. let interrupt;
  661. let triplet;
  662. // If a value is set in TRAILHEAD_OVERRIDE_PREF, it will be returned and no experiment will be set.
  663. const overrideValue = Services.prefs.getStringPref(TRAILHEAD_CONFIG.OVERRIDE_PREF, "");
  664. if (overrideValue) {
  665. [interrupt, triplet] = overrideValue.split("-");
  666. return {experiment, interrupt, triplet: triplet || ""};
  667. }
  668. const locale = Services.locale.appLocaleAsLangTag;
  669. if (TRAILHEAD_CONFIG.LOCALES.includes(locale) && !(await this._hasAddonAttributionData())) {
  670. const {userId} = ClientEnvironment;
  671. experiment = await chooseBranch(`${userId}-trailhead-experiments`, TRAILHEAD_CONFIG.EXPERIMENT_RATIOS);
  672. // For the interrupts experiment,
  673. // we randomly assign an interrupt and always use the "supercharge" triplet.
  674. if (experiment === "interrupts") {
  675. interrupt = await chooseBranch(`${userId}-interrupts-branch`, TRAILHEAD_CONFIG.BRANCHES.interrupts);
  676. if (["join", "sync", "cards"].includes(interrupt)) {
  677. triplet = "supercharge";
  678. }
  679. // For the triplets experiment or non-experiment experience,
  680. // we randomly assign a triplet and always use the "join" interrupt.
  681. } else {
  682. interrupt = "join";
  683. triplet = await chooseBranch(`${userId}-triplets-branch`, TRAILHEAD_CONFIG.BRANCHES.triplets);
  684. }
  685. } else {
  686. // If the user is not in a trailhead-compabtible locale, return the control experience and no experiment.
  687. interrupt = "control";
  688. }
  689. return {experiment, interrupt, triplet};
  690. }
  691. // Dispatch a TRAILHEAD_ENROLL_EVENT action
  692. _sendTrailheadEnrollEvent(data) {
  693. this.dispatchToAS({
  694. type: at.TRAILHEAD_ENROLL_EVENT,
  695. data,
  696. });
  697. }
  698. async setupTrailhead() {
  699. // Don't initialize
  700. if (this.state.trailheadInitialized || !Services.prefs.getBoolPref(TRAILHEAD_CONFIG.DID_SEE_ABOUT_WELCOME_PREF, false)) {
  701. return;
  702. }
  703. const {experiment, interrupt, triplet} = await this._generateTrailheadBranches();
  704. await this.setState({trailheadInitialized: true, trailheadInterrupt: interrupt, trailheadTriplet: triplet});
  705. if (experiment) {
  706. // In order for ping centre to pick this up, it MUST contain a substring activity-stream
  707. const experimentName = `activity-stream-firstrun-trailhead-${experiment}`;
  708. TelemetryEnvironment.setExperimentActive(
  709. experimentName,
  710. experiment === "interrupts" ? interrupt : triplet,
  711. {type: "as-firstrun"}
  712. );
  713. // On the first time setting the interrupts experiment, expose the branch
  714. // for normandy to target for survey study, and send out the enrollment ping.
  715. if (experiment === "interrupts" &&
  716. !Services.prefs.prefHasUserValue(TRAILHEAD_CONFIG.INTERRUPTS_EXPERIMENT_PREF)) {
  717. Services.prefs.setStringPref(TRAILHEAD_CONFIG.INTERRUPTS_EXPERIMENT_PREF, interrupt);
  718. this._sendTrailheadEnrollEvent({experiment: experimentName, type: "as-firstrun", branch: interrupt});
  719. }
  720. // On the first time setting the triplets experiment, send out the enrollment ping.
  721. if (experiment === "triplets" &&
  722. !Services.prefs.getBoolPref(TRAILHEAD_CONFIG.TRIPLETS_ENROLLED_PREF, false)) {
  723. Services.prefs.setBoolPref(TRAILHEAD_CONFIG.TRIPLETS_ENROLLED_PREF, true);
  724. this._sendTrailheadEnrollEvent({experiment: experimentName, type: "as-firstrun", branch: triplet});
  725. }
  726. }
  727. }
  728. // Return an object containing targeting parameters used to select messages
  729. _getMessagesContext() {
  730. const {previousSessionEnd, trailheadInterrupt, trailheadTriplet} = this.state;
  731. return {
  732. get previousSessionEnd() {
  733. return previousSessionEnd;
  734. },
  735. get trailheadInterrupt() {
  736. return trailheadInterrupt;
  737. },
  738. get trailheadTriplet() {
  739. return trailheadTriplet;
  740. },
  741. };
  742. }
  743. _findMessage(candidateMessages, trigger) {
  744. const messages = candidateMessages.filter(m => this.isBelowFrequencyCaps(m));
  745. const context = this._getMessagesContext();
  746. // Find a message that matches the targeting context as well as the trigger context (if one is provided)
  747. // If no trigger is provided, we should find a message WITHOUT a trigger property defined.
  748. return ASRouterTargeting.findMatchingMessage({messages, trigger, context, onError: this._handleTargetingError});
  749. }
  750. async evaluateExpression(target, {expression, context}) {
  751. const channel = target || this.messageChannel;
  752. let evaluationStatus;
  753. try {
  754. evaluationStatus = {result: await ASRouterTargeting.isMatch(expression, context), success: true};
  755. } catch (e) {
  756. evaluationStatus = {result: e.message, success: false};
  757. }
  758. channel.sendAsyncMessage(OUTGOING_MESSAGE_NAME, {type: "ADMIN_SET_STATE", data: {...this.state, evaluationStatus}});
  759. }
  760. _orderBundle(bundle) {
  761. return bundle.sort((a, b) => a.order - b.order);
  762. }
  763. // Work out if a message can be shown based on its and its provider's frequency caps.
  764. isBelowFrequencyCaps(message) {
  765. const {providers, messageImpressions, providerImpressions} = this.state;
  766. const provider = providers.find(p => p.id === message.provider);
  767. const impressionsForMessage = messageImpressions[message.id];
  768. const impressionsForProvider = providerImpressions[message.provider];
  769. return (this._isBelowItemFrequencyCap(message, impressionsForMessage, MAX_MESSAGE_LIFETIME_CAP) &&
  770. this._isBelowItemFrequencyCap(provider, impressionsForProvider));
  771. }
  772. // Helper for isBelowFrecencyCaps - work out if the frequency cap for the given
  773. // item has been exceeded or not
  774. _isBelowItemFrequencyCap(item, impressions, maxLifetimeCap = Infinity) {
  775. if (item && item.frequency && impressions && impressions.length) {
  776. if (
  777. item.frequency.lifetime &&
  778. impressions.length >= Math.min(item.frequency.lifetime, maxLifetimeCap)
  779. ) {
  780. return false;
  781. }
  782. if (item.frequency.custom) {
  783. const now = Date.now();
  784. for (const setting of item.frequency.custom) {
  785. let {period} = setting;
  786. const impressionsInPeriod = impressions.filter(t => (now - t) < period);
  787. if (impressionsInPeriod.length >= setting.cap) {
  788. return false;
  789. }
  790. }
  791. }
  792. }
  793. return true;
  794. }
  795. async _getBundledMessages(originalMessage, target, trigger, force = false) {
  796. let result = [];
  797. let bundleLength;
  798. let bundleTemplate;
  799. let originalId;
  800. if (originalMessage.includeBundle) {
  801. // The original message is not part of the bundle, so don't include it
  802. bundleLength = originalMessage.includeBundle.length;
  803. bundleTemplate = originalMessage.includeBundle.template;
  804. } else {
  805. // The original message is part of the bundle
  806. bundleLength = originalMessage.bundled;
  807. bundleTemplate = originalMessage.template;
  808. originalId = originalMessage.id;
  809. // Add in a copy of the first message
  810. result.push({content: originalMessage.content, id: originalMessage.id, order: originalMessage.order || 0});
  811. }
  812. // First, find all messages of same template. These are potential matching targeting candidates
  813. let bundledMessagesOfSameTemplate = this._getUnblockedMessages()
  814. .filter(msg => msg.bundled && msg.template === bundleTemplate && msg.id !== originalId);
  815. if (force) {
  816. // Forcefully show the messages without targeting matching - this is for about:newtab#asrouter to show the messages
  817. for (const message of bundledMessagesOfSameTemplate) {
  818. result.push({content: message.content, id: message.id});
  819. // Stop once we have enough messages to fill a bundle
  820. if (result.length === bundleLength) {
  821. break;
  822. }
  823. }
  824. } else {
  825. while (bundledMessagesOfSameTemplate.length) {
  826. // Find a message that matches the targeting context - or break if there are no matching messages
  827. const message = await this._findMessage(bundledMessagesOfSameTemplate, trigger);
  828. if (!message) {
  829. /* istanbul ignore next */ // Code coverage in mochitests
  830. break;
  831. }
  832. // Only copy the content of the message (that's what the UI cares about)
  833. // Also delete the message we picked so we don't pick it again
  834. result.push({content: message.content, id: message.id, order: message.order || 0});
  835. bundledMessagesOfSameTemplate.splice(bundledMessagesOfSameTemplate.findIndex(msg => msg.id === message.id), 1);
  836. // Stop once we have enough messages to fill a bundle
  837. if (result.length === bundleLength) {
  838. break;
  839. }
  840. }
  841. }
  842. // If we did not find enough messages to fill the bundle, do not send the bundle down
  843. if (result.length < bundleLength) {
  844. return null;
  845. }
  846. // The bundle may have some extra attributes, like a header, or a dismiss button, so attempt to get those strings now
  847. // This is a temporary solution until we can use Fluent strings in the content process, in which case the content can
  848. // handle finding these strings on its own. See bug 1488973
  849. const extraTemplateStrings = await this._extraTemplateStrings(originalMessage);
  850. return {
  851. bundle: this._orderBundle(result),
  852. ...(extraTemplateStrings && {extraTemplateStrings}),
  853. provider: originalMessage.provider,
  854. template: originalMessage.template,
  855. };
  856. }
  857. async _extraTemplateStrings(originalMessage) {
  858. let extraTemplateStrings;
  859. let localProvider = this._findProvider(originalMessage.provider);
  860. if (localProvider && localProvider.getExtraAttributes) {
  861. extraTemplateStrings = await localProvider.getExtraAttributes();
  862. }
  863. return extraTemplateStrings;
  864. }
  865. _findProvider(providerID) {
  866. return this._localProviders[this.state.providers.find(i => i.id === providerID).localProvider];
  867. }
  868. _getUnblockedMessages() {
  869. let {state} = this;
  870. return state.messages.filter(item =>
  871. !state.messageBlockList.includes(item.id) &&
  872. (!item.campaign || !state.messageBlockList.includes(item.campaign)) &&
  873. !state.providerBlockList.includes(item.provider)
  874. );
  875. }
  876. /**
  877. * Route messages based on template to the correct module that can display them
  878. */
  879. routeMessageToTarget(message, target, trigger, force = false) {
  880. switch (message.template) {
  881. case "cfr_doorhanger":
  882. if (force) {
  883. CFRPageActions.forceRecommendation(target, message, this.dispatch);
  884. } else {
  885. CFRPageActions.addRecommendation(target, trigger.param.host, message, this.dispatch);
  886. }
  887. break;
  888. case "fxa_bookmark_panel":
  889. if (force) {
  890. BookmarkPanelHub._forceShowMessage(target, message);
  891. }
  892. break;
  893. default:
  894. target.sendAsyncMessage(OUTGOING_MESSAGE_NAME, {type: "SET_MESSAGE", data: message});
  895. break;
  896. }
  897. }
  898. async _sendMessageToTarget(message, target, trigger, force = false) {
  899. // No message is available, so send CLEAR_ALL.
  900. if (!message) {
  901. try {
  902. target.sendAsyncMessage(OUTGOING_MESSAGE_NAME, {type: "CLEAR_ALL"});
  903. } catch (e) {}
  904. // For bundled messages, look for the rest of the bundle or else send CLEAR_ALL
  905. } else if (message.bundled) {
  906. const bundledMessages = await this._getBundledMessages(message, target, trigger, force);
  907. const action = bundledMessages ? {type: "SET_BUNDLED_MESSAGES", data: bundledMessages} : {type: "CLEAR_ALL"};
  908. try {
  909. target.sendAsyncMessage(OUTGOING_MESSAGE_NAME, action);
  910. } catch (e) {}
  911. // For nested bundled messages, look for the desired bundle
  912. } else if (message.includeBundle) {
  913. const bundledMessages = await this._getBundledMessages(message, target, message.includeBundle.trigger, force);
  914. try {
  915. target.sendAsyncMessage(OUTGOING_MESSAGE_NAME, {type: "SET_MESSAGE", data: {...message, bundle: bundledMessages && bundledMessages.bundle}});
  916. } catch (e) {}
  917. } else {
  918. try {
  919. this.routeMessageToTarget(message, target, trigger, force);
  920. } catch (e) {}
  921. }
  922. }
  923. async addImpression(message) {
  924. const provider = this.state.providers.find(p => p.id === message.provider);
  925. // We only need to store impressions for messages that have frequency, or
  926. // that have providers that have frequency
  927. if (message.frequency || (provider && provider.frequency)) {
  928. const time = Date.now();
  929. await this.setState(state => {
  930. const messageImpressions = this._addImpressionForItem(state, message, "messageImpressions", time);
  931. const providerImpressions = this._addImpressionForItem(state, provider, "providerImpressions", time);
  932. return {messageImpressions, providerImpressions};
  933. });
  934. }
  935. }
  936. // Helper for addImpression - calculate the updated impressions object for the given
  937. // item, then store it and return it
  938. _addImpressionForItem(state, item, impressionsString, time) {
  939. // The destructuring here is to avoid mutating existing objects in state as in redux
  940. // (see https://redux.js.org/recipes/structuring-reducers/prerequisite-concepts#immutable-data-management)
  941. const impressions = {...state[impressionsString]};
  942. if (item.frequency) {
  943. impressions[item.id] = impressions[item.id] ? [...impressions[item.id]] : [];
  944. impressions[item.id].push(time);
  945. this._storage.set(impressionsString, impressions);
  946. }
  947. return impressions;
  948. }
  949. /**
  950. * getLongestPeriod
  951. *
  952. * @param {obj} item Either an ASRouter message or an ASRouter provider
  953. * @returns {int|null} if the item has custom frequency caps, the longest period found in the list of caps.
  954. if the item has no custom frequency caps, null
  955. * @memberof _ASRouter
  956. */
  957. getLongestPeriod(item) {
  958. if (!item.frequency || !item.frequency.custom) {
  959. return null;
  960. }
  961. return item.frequency.custom.sort((a, b) => b.period - a.period)[0].period;
  962. }
  963. /**
  964. * cleanupImpressions - this function cleans up obsolete impressions whenever
  965. * messages are refreshed or fetched. It will likely need to be more sophisticated in the future,
  966. * but the current behaviour for when both message impressions and provider impressions are
  967. * cleared is as follows (where `item` is either `message` or `provider`):
  968. *
  969. * 1. If the item id for a list of item impressions no longer exists in the ASRouter state, it
  970. * will be cleared.
  971. * 2. If the item has time-bound frequency caps but no lifetime cap, any item impressions older
  972. * than the longest time period will be cleared.
  973. */
  974. async cleanupImpressions() {
  975. await this.setState(state => {
  976. const messageImpressions = this._cleanupImpressionsForItems(state, state.messages, "messageImpressions");
  977. const providerImpressions = this._cleanupImpressionsForItems(state, state.providers, "providerImpressions");
  978. return {messageImpressions, providerImpressions};
  979. });
  980. }
  981. // Helper for cleanupImpressions - calculate the updated impressions object for
  982. // the given items, then store it and return it
  983. _cleanupImpressionsForItems(state, items, impressionsString) {
  984. const impressions = {...state[impressionsString]};
  985. let needsUpdate = false;
  986. Object.keys(impressions).forEach(id => {
  987. const [item] = items.filter(x => x.id === id);
  988. // Don't keep impressions for items that no longer exist
  989. if (!item || !item.frequency || !Array.isArray(impressions[id])) {
  990. delete impressions[id];
  991. needsUpdate = true;
  992. return;
  993. }
  994. if (!impressions[id].length) {
  995. return;
  996. }
  997. // If we don't want to store impressions older than the longest period
  998. if (item.frequency.custom && !item.frequency.lifetime) {
  999. const now = Date.now();
  1000. impressions[id] = impressions[id].filter(t => (now - t) < this.getLongestPeriod(item));
  1001. needsUpdate = true;
  1002. }
  1003. });
  1004. if (needsUpdate) {
  1005. this._storage.set(impressionsString, impressions);
  1006. }
  1007. return impressions;
  1008. }
  1009. async sendNextMessage(target, trigger) {
  1010. const msgs = this._getUnblockedMessages();
  1011. let message = null;
  1012. const previewMsgs = this.state.messages.filter(item => item.provider === "preview");
  1013. // Always send preview messages when available
  1014. if (previewMsgs.length) {
  1015. [message] = previewMsgs;
  1016. } else {
  1017. message = await this._findMessage(msgs, trigger);
  1018. }
  1019. if (previewMsgs.length) {
  1020. // We don't want to cache preview messages, remove them after we selected the message to show
  1021. await this.setState(state => ({
  1022. lastMessageId: message.id,
  1023. messages: state.messages.filter(m => m.id !== message.id),
  1024. }));
  1025. } else {
  1026. await this.setState({lastMessageId: message ? message.id : null});
  1027. }
  1028. await this._sendMessageToTarget(message, target, trigger);
  1029. }
  1030. handleMessageRequest(trigger) {
  1031. const msgs = this._getUnblockedMessages();
  1032. return this._findMessage(msgs.filter(m => m.trigger && m.trigger.id === trigger.id), trigger);
  1033. }
  1034. async setMessageById(id, target, force = true, action = {}) {
  1035. await this.setState({lastMessageId: id});
  1036. const newMessage = this.getMessageById(id);
  1037. await this._sendMessageToTarget(newMessage, target, action.data, force);
  1038. }
  1039. async blockMessageById(idOrIds) {
  1040. const idsToBlock = Array.isArray(idOrIds) ? idOrIds : [idOrIds];
  1041. await this.setState(state => {
  1042. const messageBlockList = [...state.messageBlockList];
  1043. const messageImpressions = {...state.messageImpressions};
  1044. idsToBlock.forEach(id => {
  1045. const message = state.messages.find(m => m.id === id);
  1046. const idToBlock = (message && message.campaign) ? message.campaign : id;
  1047. if (!messageBlockList.includes(idToBlock)) {
  1048. messageBlockList.push(idToBlock);
  1049. }
  1050. // When a message is blocked, its impressions should be cleared as well
  1051. delete messageImpressions[id];
  1052. });
  1053. this._storage.set("messageBlockList", messageBlockList);
  1054. this._storage.set("messageImpressions", messageImpressions);
  1055. return {messageBlockList, messageImpressions};
  1056. });
  1057. }
  1058. async blockProviderById(idOrIds) {
  1059. const idsToBlock = Array.isArray(idOrIds) ? idOrIds : [idOrIds];
  1060. await this.setState(state => {
  1061. const providerBlockList = [...state.providerBlockList, ...idsToBlock];
  1062. // When a provider is blocked, its impressions should be cleared as well
  1063. const providerImpressions = {...state.providerImpressions};
  1064. idsToBlock.forEach(id => delete providerImpressions[id]);
  1065. this._storage.set("providerBlockList", providerBlockList);
  1066. return {providerBlockList, providerImpressions};
  1067. });
  1068. }
  1069. _validPreviewEndpoint(url) {
  1070. try {
  1071. const endpoint = new URL(url);
  1072. if (!this.WHITELIST_HOSTS[endpoint.host]) {
  1073. Cu.reportError(`The preview URL host ${endpoint.host} is not in the whitelist.`);
  1074. }
  1075. if (endpoint.protocol !== "https:") {
  1076. Cu.reportError("The URL protocol is not https.");
  1077. }
  1078. return (endpoint.protocol === "https:" && this.WHITELIST_HOSTS[endpoint.host]);
  1079. } catch (e) {
  1080. return false;
  1081. }
  1082. }
  1083. // Ensure we switch to the Onboarding message after RTAMO addon was installed
  1084. _updateOnboardingState() {
  1085. let addonInstallObs = (subject, topic) => {
  1086. Services.obs.removeObserver(addonInstallObs, "webextension-install-notify");
  1087. this.messageChannel.sendAsyncMessage(OUTGOING_MESSAGE_NAME, {type: "CLEAR_MESSAGE", data: {id: "RETURN_TO_AMO_1"}});
  1088. this.blockMessageById("RETURN_TO_AMO_1");
  1089. };
  1090. Services.obs.addObserver(addonInstallObs, "webextension-install-notify");
  1091. }
  1092. _loadSnippetsWhitelistHosts() {
  1093. let additionalHosts = [];
  1094. const whitelistPrefValue = Services.prefs.getStringPref(SNIPPETS_ENDPOINT_WHITELIST, "");
  1095. try {
  1096. additionalHosts = JSON.parse(whitelistPrefValue);
  1097. } catch (e) {
  1098. if (whitelistPrefValue) {
  1099. Cu.reportError(`Pref ${SNIPPETS_ENDPOINT_WHITELIST} value is not valid JSON`);
  1100. }
  1101. }
  1102. if (!additionalHosts.length) {
  1103. return DEFAULT_WHITELIST_HOSTS;
  1104. }
  1105. // If there are additional hosts we want to whitelist, add them as
  1106. // `preview` so that the updateCycle is 0
  1107. return additionalHosts.reduce((whitelist_hosts, host) => {
  1108. whitelist_hosts[host] = "preview";
  1109. Services.console.logStringMessage(`Adding ${host} to whitelist hosts.`);
  1110. return whitelist_hosts;
  1111. }, {...DEFAULT_WHITELIST_HOSTS});
  1112. }
  1113. // To be passed to ASRouterTriggerListeners
  1114. async _triggerHandler(target, trigger) {
  1115. await this.onMessage({target, data: {type: "TRIGGER", data: {trigger}}});
  1116. }
  1117. _removePreviewEndpoint(state) {
  1118. state.providers = state.providers.filter(p => p.id !== "preview");
  1119. return state;
  1120. }
  1121. async _addPreviewEndpoint(url, portID) {
  1122. // When you view a preview snippet we want to hide all real content
  1123. const providers = [...this.state.providers];
  1124. if (this._validPreviewEndpoint(url) && !providers.find(p => p.url === url)) {
  1125. this.dispatchToAS(ac.OnlyToOneContent({type: at.SNIPPETS_PREVIEW_MODE}, portID));
  1126. providers.push({id: "preview", type: "remote", url, updateCycleInMs: 0});
  1127. await this.setState({providers});
  1128. }
  1129. }
  1130. // Windows specific calls to write attribution data
  1131. // Used by `forceAttribution` to set required targeting attributes for
  1132. // RTAMO messages. This should only be called from within about:newtab#asrouter
  1133. /* istanbul ignore next */
  1134. async _writeAttributionFile(data) {
  1135. let appDir = Services.dirsvc.get("LocalAppData", Ci.nsIFile);
  1136. let file = appDir.clone();
  1137. file.append(Services.appinfo.vendor || "mozilla");
  1138. file.append(AppConstants.MOZ_APP_NAME);
  1139. await OS.File.makeDir(file.path,
  1140. {from: appDir.path, ignoreExisting: true});
  1141. file.append("postSigningData");
  1142. await OS.File.writeAtomic(file.path, data);
  1143. }
  1144. /**
  1145. * forceAttribution - this function should only be called from within about:newtab#asrouter.
  1146. * It forces the browser attribution to be set to something specified in asrouter admin
  1147. * tools, and reloads the providers in order to get messages that are dependant on this
  1148. * attribution data (see Return to AMO flow in bug 1475354 for example). Note - OSX and Windows only
  1149. * @param {data} Object an object containing the attribtion data that came from asrouter admin page
  1150. */
  1151. /* istanbul ignore next */
  1152. async forceAttribution(data) {
  1153. // Extract the parameters from data that will make up the referrer url
  1154. const {source, campaign, content} = data;
  1155. if (AppConstants.platform === "win") {
  1156. const attributionData = `source=${source}&campaign=${campaign}&content=${content}`;
  1157. this._writeAttributionFile(encodeURIComponent(attributionData));
  1158. } else if (AppConstants.platform === "macosx") {
  1159. let appPath = Services.dirsvc.get("GreD", Ci.nsIFile).parent.parent.path;
  1160. let attributionSvc = Cc["@mozilla.org/mac-attribution;1"]
  1161. .getService(Ci.nsIMacAttributionService);
  1162. let referrer = `https://www.mozilla.org/anything/?utm_campaign=${campaign}&utm_source=${source}&utm_content=${encodeURIComponent(content)}`;
  1163. // This sets the Attribution to be the referrer
  1164. attributionSvc.setReferrerUrl(appPath, referrer, true);
  1165. }
  1166. // Clear cache call is only possible in a testing environment
  1167. let env = Cc["@mozilla.org/process/environment;1"].getService(Ci.nsIEnvironment);
  1168. env.set("XPCSHELL_TEST_PROFILE_DIR", "testing");
  1169. // Clear and refresh Attribution, and then fetch the messages again to update
  1170. AttributionCode._clearCache();
  1171. AttributionCode.getAttrDataAsync();
  1172. this._updateMessageProviders();
  1173. await this.loadMessagesFromAllProviders();
  1174. }
  1175. async handleUserAction({data: action, target}) {
  1176. switch (action.type) {
  1177. case ra.OPEN_PRIVATE_BROWSER_WINDOW:
  1178. // Forcefully open about:privatebrowsing
  1179. target.browser.ownerGlobal.OpenBrowserWindow({private: true});
  1180. break;
  1181. case ra.OPEN_URL:
  1182. target.browser.ownerGlobal.openLinkIn(action.data.args, action.data.where || "current", {
  1183. private: false,
  1184. triggeringPrincipal: Services.scriptSecurityManager.createNullPrincipal({}),
  1185. csp: null,
  1186. });
  1187. break;
  1188. case ra.OPEN_ABOUT_PAGE:
  1189. target.browser.ownerGlobal.openTrustedLinkIn(`about:${action.data.args}`, "tab");
  1190. break;
  1191. case ra.OPEN_PREFERENCES_PAGE:
  1192. target.browser.ownerGlobal.openPreferences(action.data.category);
  1193. break;
  1194. case ra.OPEN_APPLICATIONS_MENU:
  1195. UITour.showMenu(target.browser.ownerGlobal, action.data.args);
  1196. break;
  1197. case ra.INSTALL_ADDON_FROM_URL:
  1198. this._updateOnboardingState();
  1199. await MessageLoaderUtils.installAddonFromURL(target.browser, action.data.url);
  1200. break;
  1201. case ra.PIN_CURRENT_TAB:
  1202. let tab = target.browser.ownerGlobal.gBrowser.selectedTab;
  1203. target.browser.ownerGlobal.gBrowser.pinTab(tab);
  1204. target.browser.ownerGlobal.ConfirmationHint.show(tab, "pinTab", {showDescription: true});
  1205. break;
  1206. case ra.SHOW_FIREFOX_ACCOUNTS:
  1207. const url = await FxAccounts.config.promiseSignUpURI("snippets");
  1208. // We want to replace the current tab.
  1209. target.browser.ownerGlobal.openLinkIn(url, "current", {
  1210. private: false,
  1211. triggeringPrincipal: Services.scriptSecurityManager.createNullPrincipal({}),
  1212. csp: null,
  1213. });
  1214. break;
  1215. }
  1216. }
  1217. dispatch(action, target) {
  1218. this.onMessage({data: action, target});
  1219. }
  1220. /* eslint-disable complexity */
  1221. async onMessage({data: action, target}) {
  1222. switch (action.type) {
  1223. case "USER_ACTION":
  1224. if (action.data.type in ra) {
  1225. await this.handleUserAction({data: action.data, target});
  1226. }
  1227. break;
  1228. case "SNIPPETS_REQUEST":
  1229. case "TRIGGER":
  1230. // Wait for our initial message loading to be done before responding to any UI requests
  1231. await this.waitForInitialized;
  1232. if (action.data && action.data.endpoint) {
  1233. await this._addPreviewEndpoint(action.data.endpoint.url, target.portID);
  1234. }
  1235. // Special experiment intialization for trailhead
  1236. if (action.data && action.data.trigger && action.data.trigger.id === "firstRun") {
  1237. Services.prefs.setBoolPref(TRAILHEAD_CONFIG.DID_SEE_ABOUT_WELCOME_PREF, true);
  1238. await this.setupTrailhead();
  1239. }
  1240. // Check if any updates are needed first
  1241. await this.loadMessagesFromAllProviders();
  1242. await this.sendNextMessage(target, (action.data && action.data.trigger) || {});
  1243. break;
  1244. case "BLOCK_MESSAGE_BY_ID":
  1245. await this.blockMessageById(action.data.id);
  1246. // Block the message but don't dismiss it in case the action taken has
  1247. // another state that needs to be visible
  1248. if (action.data.preventDismiss) {
  1249. break;
  1250. }
  1251. this.messageChannel.sendAsyncMessage(OUTGOING_MESSAGE_NAME, {type: "CLEAR_MESSAGE", data: {id: action.data.id}});
  1252. break;
  1253. case "DISMISS_MESSAGE_BY_ID":
  1254. this.messageChannel.sendAsyncMessage(OUTGOING_MESSAGE_NAME, {type: "CLEAR_MESSAGE", data: {id: action.data.id}});
  1255. break;
  1256. case "BLOCK_PROVIDER_BY_ID":
  1257. await this.blockProviderById(action.data.id);
  1258. this.messageChannel.sendAsyncMessage(OUTGOING_MESSAGE_NAME, {type: "CLEAR_PROVIDER", data: {id: action.data.id}});
  1259. break;
  1260. case "DISMISS_BUNDLE":
  1261. this.messageChannel.sendAsyncMessage(OUTGOING_MESSAGE_NAME, {type: "CLEAR_BUNDLE"});
  1262. break;
  1263. case "BLOCK_BUNDLE":
  1264. await this.blockMessageById(action.data.bundle.map(b => b.id));
  1265. this.messageChannel.sendAsyncMessage(OUTGOING_MESSAGE_NAME, {type: "CLEAR_BUNDLE"});
  1266. break;
  1267. case "UNBLOCK_MESSAGE_BY_ID":
  1268. await this.setState(state => {
  1269. const messageBlockList = [...state.messageBlockList];
  1270. const message = state.messages.find(m => m.id === action.data.id);
  1271. const idToUnblock = (message && message.campaign) ? message.campaign : action.data.id;
  1272. messageBlockList.splice(messageBlockList.indexOf(idToUnblock), 1);
  1273. this._storage.set("messageBlockList", messageBlockList);
  1274. return {messageBlockList};
  1275. });
  1276. break;
  1277. case "UNBLOCK_PROVIDER_BY_ID":
  1278. await this.setState(state => {
  1279. const providerBlockList = [...state.providerBlockList];
  1280. providerBlockList.splice(providerBlockList.indexOf(action.data.id), 1);
  1281. this._storage.set("providerBlockList", providerBlockList);
  1282. return {providerBlockList};
  1283. });
  1284. break;
  1285. case "UNBLOCK_BUNDLE":
  1286. await this.setState(state => {
  1287. const messageBlockList = [...state.messageBlockList];
  1288. for (let message of action.data.bundle) {
  1289. messageBlockList.splice(messageBlockList.indexOf(message.id), 1);
  1290. }
  1291. this._storage.set("messageBlockList", messageBlockList);
  1292. return {messageBlockList};
  1293. });
  1294. break;
  1295. case "OVERRIDE_MESSAGE":
  1296. await this.setMessageById(action.data.id, target, true, action);
  1297. break;
  1298. case "ADMIN_CONNECT_STATE":
  1299. if (action.data && action.data.endpoint) {
  1300. this._addPreviewEndpoint(action.data.endpoint.url, target.portID);
  1301. await this.loadMessagesFromAllProviders();
  1302. } else {
  1303. await this._updateAdminState(target);
  1304. }
  1305. break;
  1306. case "IMPRESSION":
  1307. await this.addImpression(action.data);
  1308. break;
  1309. case "DOORHANGER_TELEMETRY":
  1310. if (this.dispatchToAS) {
  1311. this.dispatchToAS(ac.ASRouterUserEvent(action.data));
  1312. }
  1313. break;
  1314. case "EXPIRE_QUERY_CACHE":
  1315. QueryCache.expireAll();
  1316. break;
  1317. case "ENABLE_PROVIDER":
  1318. ASRouterPreferences.enableOrDisableProvider(action.data, true);
  1319. break;
  1320. case "DISABLE_PROVIDER":
  1321. ASRouterPreferences.enableOrDisableProvider(action.data, false);
  1322. break;
  1323. case "RESET_PROVIDER_PREF":
  1324. ASRouterPreferences.resetProviderPref();
  1325. break;
  1326. case "SET_PROVIDER_USER_PREF":
  1327. ASRouterPreferences.setUserPreference(action.data.id, action.data.value);
  1328. break;
  1329. case "EVALUATE_JEXL_EXPRESSION":
  1330. this.evaluateExpression(target, action.data);
  1331. break;
  1332. case "FORCE_ATTRIBUTION":
  1333. this.forceAttribution(action.data);
  1334. break;
  1335. default:
  1336. Cu.reportError("Unknown message received");
  1337. break;
  1338. }
  1339. }
  1340. }
  1341. this._ASRouter = _ASRouter;
  1342. this.chooseBranch = chooseBranch;
  1343. this.TRAILHEAD_CONFIG = TRAILHEAD_CONFIG;
  1344. /**
  1345. * ASRouter - singleton instance of _ASRouter that controls all messages
  1346. * in the new tab page.
  1347. */
  1348. this.ASRouter = new _ASRouter();
  1349. const EXPORTED_SYMBOLS = ["_ASRouter", "ASRouter", "MessageLoaderUtils", "chooseBranch", "TRAILHEAD_CONFIG"];