/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; import { RemotePageChild } from "resource://gre/actors/RemotePageChild.sys.mjs"; import { ShoppingProduct, isProductURL, isSupportedSiteURL, } from "chrome://global/content/shopping/ShoppingProduct.mjs"; let lazy = {}; let gAllActors = new Set(); XPCOMUtils.defineLazyPreferenceGetter( lazy, "optedIn", "browser.shopping.experience2023.optedIn", null, function optedInStateChanged() { for (let actor of gAllActors) { actor.optedInStateChanged(); } } ); XPCOMUtils.defineLazyPreferenceGetter( lazy, "adsEnabled", "browser.shopping.experience2023.ads.enabled", true ); XPCOMUtils.defineLazyPreferenceGetter( lazy, "adsEnabledByUser", "browser.shopping.experience2023.ads.userEnabled", true, function adsEnabledByUserChanged() { for (let actor of gAllActors) { actor.adsEnabledByUserChanged(); } } ); XPCOMUtils.defineLazyPreferenceGetter( lazy, "autoOpenEnabled", "browser.shopping.experience2023.autoOpen.enabled", true ); XPCOMUtils.defineLazyPreferenceGetter( lazy, "autoOpenEnabledByUser", "browser.shopping.experience2023.autoOpen.userEnabled", true, function autoOpenEnabledByUserChanged() { for (let actor of gAllActors) { actor.autoOpenEnabledByUserChanged(); } } ); XPCOMUtils.defineLazyPreferenceGetter( lazy, "autoCloseEnabledByUser", "browser.shopping.experience2023.autoClose.userEnabled", true, function autoCloseEnabledByUserChanged() { for (let actor of gAllActors) { actor.autoCloseEnabledByUserChanged(); } } ); XPCOMUtils.defineLazyPreferenceGetter( lazy, "isSidebarStartPosition", "sidebar.position_start" ); /** * The ReviewCheckerChild will get the current URL from the parent * and will request data to update the sidebar UI if that URL is a * product or display the current opt-in or empty state. */ export class ReviewCheckerChild extends RemotePageChild { constructor() { super(); } actorCreated() { super.actorCreated(); gAllActors.add(this); } didDestroy() { this._destroyed = true; super.didDestroy?.(); gAllActors.delete(this); this.#product?.uninit(); } #currentURI = null; #product = null; #supportedDomains = null; get currentURL() { return this.#currentURI?.spec; } get canFetchAndShowData() { return lazy.optedIn === 1; } get adsEnabled() { return lazy.adsEnabled; } get adsEnabledByUser() { return lazy.adsEnabledByUser; } get canFetchAndShowAd() { return this.adsEnabled && this.adsEnabledByUser; } get autoOpenEnabled() { return lazy.autoOpenEnabled; } get autoOpenEnabledByUser() { return lazy.autoOpenEnabledByUser; } get autoCloseEnabledByUser() { return lazy.autoCloseEnabledByUser; } get isSidebarStartPosition() { return lazy.isSidebarStartPosition; } receiveMessage(message) { if (this.browsingContext.usePrivateBrowsing) { throw new Error("We should never be invoked in PBM."); } switch (message.name) { case "ReviewChecker:UpdateCurrentURL": this.locationChanged(message.data); break; case "ReviewChecker:ShowNewPositionCard": this.sendToContent("ShowNewPositionCard", { isSidebarStartPosition: this.isSidebarStartPosition, }); break; } return null; } handleEvent(event) { let aid, sponsored, product; switch (event.type) { case "ContentReady": this.resetContent(); this.setLocation(); break; case "PolledRequestMade": product = this.getProductForURI(this.#currentURI); this.updateProductData(product, { isPolledRequest: true }); break; case "ReportProductAvailable": product = this.getProductForURI(this.#currentURI); this.reportProductAvailable(product); break; case "AdClicked": aid = event.detail.aid; sponsored = event.detail.sponsored; ShoppingProduct.sendAttributionEvent("click", aid); Glean.shopping.surfaceAdsClicked.record({ sponsored }); break; case "AdImpression": aid = event.detail.aid; sponsored = event.detail.sponsored; ShoppingProduct.sendAttributionEvent("impression", aid); Glean.shopping.surfaceAdsImpression.record({ sponsored }); break; case "DisableShopping": this.sendAsyncMessage("DisableShopping"); break; case "CloseShoppingSidebar": this.sendAsyncMessage("CloseShoppingSidebar"); break; case "MoveSidebarToRight": this.sendAsyncMessage("ReverseSidebarPosition"); break; case "MoveSidebarToLeft": this.sendAsyncMessage("ReverseSidebarPosition"); break; case "ShowSidebarSettings": this.sendAsyncMessage("ShowSidebarSettings"); break; } } /** * Exposed for testing to set the private currentURI. * * @param {nsIURI} uri */ set currentURI(uri) { if (!(uri instanceof Ci.nsIURI)) { throw new Error("currentURI setter expects an nsIURI"); } this.#currentURI = uri; } /** * Get the URI the content has been set to. * * @returns {nsIURI} currentURI */ get currentURI() { return this.#currentURI; } /** * Exposed for testing to set the private product. * * TODO: Bug 1927956 - This will need to get the URI for the product once we are * caching them. * * @param {ShoppingProduct} product */ set product(product) { if (!(product instanceof ShoppingProduct)) { throw new Error("product setter expects an instance of ShoppingProduct"); } this.#product = product; } /** * Get or create a product for the given URI. * * @param {nsIURI} productURI * @returns {ShoppingProduct} */ getProductForURI(productURI) { // TODO: Bug 1927956 - Check a product is cached for this URI. if (this.#product) { return this.#product; } let product = new ShoppingProduct(productURI); // TODO: Bug 1927956 - Add to product cache for this URI. this.#product = product; return product; } /** * Check check if URIs represent the same product by * comparing URLs and then parsed product ID. * * @param {nsIURI} newURI * @param {nsIURI} currentURI * @returns {boolean} */ isSameProduct(newURI, currentURI) { if (!newURI || !currentURI) { return false; } // Check if the URIs are equal: if (currentURI.equalsExceptRef(newURI)) { return true; } let product = this.getProductForURI(currentURI); if (!product) { return false; } // If the current ShoppingProduct has product info set, // check if the product ids are the same: let currentProduct = product.product; if (currentProduct) { let newProduct = ShoppingProduct.fromURL(URL.fromURI(newURI)); if (newProduct.id === currentProduct.id) { return true; } } return false; } /** * Reset the content and update for the current URI. * * @returns {Promise} */ async optedInStateChanged() { // Clear the current content this.resetContent({ focusCloseButton: true }); // Get a URI if we don't have one yet. if (!this.#currentURI) { await this.setLocation(); } await this.updateContent(this.#currentURI); } /** * Get recommendations for the current ShoppingProduct * if enabled or remove current recommendations. * * @returns {Promise} */ async adsEnabledByUserChanged() { this.updateAdsEnabledByUser(this.adsEnabledByUser); if (!this.canFetchAndShowAd) { return; } let product = this.getProductForURI(this.#currentURI); await this.updateRecommendations(product); } /** * Update auto-open to user's pref value. * */ autoOpenEnabledByUserChanged() { this.updateAutoOpenEnabledByUser(this.autoOpenEnabledByUser); } /** * Update auto-close to user's pref value. * */ autoCloseEnabledByUserChanged() { this.updateAutoCloseEnabledByUser(this.autoCloseEnabledByUser); } /** * Get current URL for the parent and update the location to it. * * @returns {Promise} */ async setLocation() { // check if can fetch and show data let url = await this.sendQuery("GetCurrentURL"); // Bail out if we opted out in the meantime, or don't have a URI. if (!this.canContinue(null, false)) { return; } await this.locationChanged({ url }); } /** * Update the currentURI to a new location. * * For a new location this will: * - Reset remove the ShoppingProduct for the previous URI. * - Update the content for the new URI. * * @param {object?} options * @param {bool} options.url * @param {bool} [options.isReload] * * @returns {Promise} */ async locationChanged({ url, isReload } = {}) { let uri = url ? Services.io.newURI(url) : null; // If we're going from null to null, bail out: if (!this.#currentURI && !uri) { return; } // If we haven't reloaded, check if the URIs represent the same product // as sites might change the URI after they have loaded (Bug 1852099). if (!isReload && this.isSameProduct(uri, this.#currentURI)) { return; } this.#product?.uninit(); this.#product = null; this.#currentURI = uri; await this.updateContent(uri); } /** * Re-renders the content whenever whenever the location changes. * * The expected cases for this are: * - page navigations (both to new products and away from a product once * the sidebar has been created) * - opt in state changes. * * For a new location this will: * - Check if the location is a product page or supported site. * - Update the content location and empty states. * - Get or create a new ShoppingProduct for the URI if needed. * - Update state and data for that product. * - Update recommendation for that product if enabled. * * @param {nsIURI} uri * * @returns {Promise} */ async updateContent(uri) { if (this._destroyed || !uri) { return; } let isProductPage = isProductURL(uri); let isSupportedSite = isSupportedSiteURL(uri); if (!this.canFetchAndShowData) { this.showOnboarding({ productUrl: uri.spec, isProductPage, isSupportedSite, }); return; } if (isProductPage) { let product = this.getProductForURI(uri); // We want to update the location to clear out the content from // the previous URL immediately, without waiting for potentially // async operations like obtaining product information. this.updateLocation({ isProductPage }); await this.updateProductData(product); if (this.canShowAds(uri)) { await this.updateRecommendations(product); } } else { if (!this.#supportedDomains) { this.#supportedDomains = ShoppingProduct.getSupportedDomains(); } // If the URI is not a product page, we should display an empty state. // That empty state could be for either a support or unsupported site. this.updateLocation({ isProductPage, isSupportedSite, supportedDomains: this.#supportedDomains, }); } } /** * updateContent is an async function, and when we're off making requests or doing * other things asynchronously, the actor can be destroyed, the user * might navigate to a new page, the user might disable the feature ... - * all kinds of things can change. So we need to repeatedly check * whether we can keep going with our async processes. This helper takes * care of these checks. * * @param {nsIURI} currentURI * @param {boolean} [checkURI] = true * * @returns {boolean} */ canContinue(currentURI, checkURI = true) { if (this._destroyed || !this.canFetchAndShowData) { return false; } if (!checkURI) { return true; } return currentURI && currentURI == this.#currentURI; } /** * Utility function to determine if we should display ads. This is different * from fetching ads, because of ads exposure telemetry (bug 1858470). * * @param {nsIURI} uri * * @returns {boolean} */ canShowAds(uri) { return ( uri.equalsExceptRef(this.#currentURI) && this.canFetchAndShowData && this.canFetchAndShowAd ); } /** * Async helper that will request data from the Fakespot API for a passed product * and update the content with the new state. * * Records telemetry if reviews are not available for the product. * * @param {ShoppingProduct} product * @param {object} options * @param {boolean} [options.isPolledRequest=false] */ async updateProductData(product, { isPolledRequest = false } = {}) { let uri = this.#currentURI; let productUrl = uri?.spec; let analysisStatusResponse; let data; try { if (isPolledRequest) { // Request a new analysis. analysisStatusResponse = await product.requestCreateAnalysis(); } else { // Request the current analysis status. analysisStatusResponse = await product.requestAnalysisCreationStatus(); } // Status will be "not_found" if the current analysis is up-to-date. let analysisStatus = analysisStatusResponse?.status ?? "not_found"; let isAnalysisInProgress = ShoppingProduct.isAnalysisInProgress(analysisStatus); if (isAnalysisInProgress) { // Do not clear data however if an analysis was requested via a call-to-action. if (!isPolledRequest) { this.updateAnalysisStatus({ analysisStatus }, productUrl); } analysisStatus = await this.waitForAnalysisCompleted( product, analysisStatus ); } this.updateAnalysisStatus({ analysisStatus }, productUrl); let hasAnalysisCompleted = ShoppingProduct.hasAnalysisCompleted(analysisStatus); if (!hasAnalysisCompleted) { return; } data = await product.requestAnalysis(); } catch (err) { console.warn("Failed to fetch product analysis data", err); data = { error: err }; } // Check if we got nuked from orbit, or the product URI or opt in changed while we waited. if (!data || !this.canContinue(uri)) { return; } this.sendToContent("Update", { productUrl, data, showOnboarding: false, isSidebarStartPosition: this.isSidebarStartPosition, }); if (!isPolledRequest && !data.error && !data.grade) { Glean.shopping.surfaceNoReviewReliabilityAvailable.record(); } } /** * Async helper that will request recommendation data from the Fakespot API for a passed product * and update the content with the new state. * * Records telemetry if recommendation are placed or not available for the product. * * @param {ShoppingProduct} product */ async updateRecommendations(product) { let uri = this.#currentURI; let recommendationData; try { recommendationData = await product.requestRecommendations(); } catch (err) { console.warn("Failed to fetch product recommendations data", err); recommendationData = []; } // Check if the product URI or opt in changed while we waited. if (!this.canShowAds(uri)) { return; } if (!recommendationData.length) { // We tried to fetch an ad, but didn't get one. Glean.shopping.surfaceNoAdsAvailable.record(); } else { let sponsored = recommendationData[0].sponsored; ShoppingProduct.sendAttributionEvent( "placement", recommendationData[0].aid ); Glean.shopping.surfaceAdsPlacement.record({ sponsored, }); } this.sendToContent("UpdateRecommendations", { recommendationData, }); } /** * Async helper that will report a product is now available to the Fakespot API. * * @param {ShoppingProduct} product */ async reportProductAvailable(product) { await product.sendReport(); } /** * Async helper that will poll the Fakespot API until a product's analysis is no longer * in progress, this could complete or fail as with a status. * * Callback updates the analysis progress as reported by the Fakespot API. * * @param {ShoppingProduct} product */ async waitForAnalysisCompleted(product, analysisStatus) { try { let analysisStatusResponse = await product.pollForAnalysisCompleted( { pollInitialWait: analysisStatus == "in_progress" ? 0 : undefined, }, progress => { this.sendToContent("UpdateAnalysisProgress", { progress, }); } ); return analysisStatusResponse.status; } catch (err) { console.warn("Failed to get product status", err); return analysisStatus; } } /** * Content state helper to send preference changes to the shopping-container * and clear the current state. * * @param {object?} options * @param {bool} [options.focusCloseButton=false] */ resetContent({ focusCloseButton = false } = {}) { this.sendToContent("Update", { adsEnabled: lazy.adsEnabled, adsEnabledByUser: lazy.adsEnabledByUser, autoOpenEnabled: lazy.autoOpenEnabled, autoOpenEnabledByUser: lazy.autoOpenEnabledByUser, autoCloseEnabledByUser: lazy.autoCloseEnabledByUser, showOnboarding: !this.canFetchAndShowData, data: null, recommendationData: null, focusCloseButton, isSidebarStartPosition: this.isSidebarStartPosition, }); } /** * Content state helper to send analysis status changes to the shopping-container. * * @param {object?} options * @param {string} options.analysisStatus * @param {string} productUrl the url of the analyzed product */ updateAnalysisStatus({ analysisStatus } = {}, productUrl) { let data; // Use the analysis status instead of re-requesting unnecessarily, // or throw if the status from the last analysis was an error. switch (analysisStatus) { case "not_analyzable": case "page_not_supported": data = { page_not_supported: true }; break; case "not_enough_reviews": data = { not_enough_reviews: true }; break; case "unprocessable": case "stale": data = { error: new Error(analysisStatus, { cause: analysisStatus }) }; break; default: // Status is "completed" or "not_found" (no analysis status), // so we should request the analysis data. } let isAnalysisInProgress = ShoppingProduct.isAnalysisInProgress(analysisStatus); if ( (!data && !isAnalysisInProgress) || this.#currentURI.spec !== productUrl ) { return; } this.sendToContent("Update", { data, isAnalysisInProgress, }); } /** * Content state helper to update the current location in the content. * * @param {object?} options * @param {bool} [options.isProductPage=false] If the location has a product or not. * @param {bool} [options.isSupportedSite=false] If the location is on a supported site or not. * @param {object | null} [options.supportedDomains] Object mapping supported sites and domains, or null if the list is unavailable or inapplicable. */ updateLocation({ isProductPage = true, isSupportedSite = false, supportedDomains, } = {}) { this.sendToContent("Update", { isProductPage, isSupportedSite, supportedDomains, isSidebarStartPosition: this.isSidebarStartPosition, }); } /** * Shows the onboarding flow in the content. * * @param {object?} options * @param {string} [options.productUrl] URL of the current product if this is a product page. * @param {boolean} [options.isProductPage] True if a product page, else false. * @param {boolean} [options.isSupportedSite] True if a supported site, else false. */ showOnboarding({ productUrl, isProductPage, isSupportedSite } = {}) { // Similar to canContinue() above, check to see if things // have changed while we were waiting. Bail out if the user // opted in, or if the actor doesn't exist. if (this._destroyed || this.canFetchAndShowData) { return; } // Send the productUrl to content for Onboarding's dynamic text this.sendToContent("Update", { showOnboarding: true, data: null, productUrl, isProductPage, isSupportedSite, }); } /** * Updates if recommendation have been enabled or disable in the content settings. * * @param {bool} adsEnabledByUser */ updateAdsEnabledByUser(adsEnabledByUser) { this.sendToContent("adsEnabledByUserChanged", { adsEnabledByUser, }); } /** * Updates if auto open has been enabled or disable in the content settings. * * @param {bool} autoOpenEnabledByUser */ updateAutoOpenEnabledByUser(autoOpenEnabledByUser) { this.sendToContent("autoOpenEnabledByUserChanged", { autoOpenEnabledByUser, }); } /** * Updates if auto close has been enabled or disable in the content settings. * * @param {bool} autoCloseEnabledByUser */ updateAutoCloseEnabledByUser(autoCloseEnabledByUser) { this.sendToContent("autoCloseEnabledByUserChanged", { autoCloseEnabledByUser, }); } /** * Updates percentage complete for a product analysis. * * @param {number} progress */ updateAnalysisProgress(progress) { this.sendToContent("UpdateAnalysisProgress", { progress, }); } /** * Send messages and cloned objects to the content. * * @param {string} eventName event string to pass. * @param {object} detail object to clone. */ sendToContent(eventName, detail) { if (this._destroyed) { return; } let win = this.contentWindow; let evt = new win.CustomEvent(eventName, { bubbles: true, detail: Cu.cloneInto(detail, win), }); win.document.dispatchEvent(evt); } }