summaryrefslogtreecommitdiffstats
path: root/browser/components/shopping/ShoppingSidebarChild.sys.mjs
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
commit26a029d407be480d791972afb5975cf62c9360a6 (patch)
treef435a8308119effd964b339f76abb83a57c29483 /browser/components/shopping/ShoppingSidebarChild.sys.mjs
parentInitial commit. (diff)
downloadfirefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz
firefox-26a029d407be480d791972afb5975cf62c9360a6.zip
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'browser/components/shopping/ShoppingSidebarChild.sys.mjs')
-rw-r--r--browser/components/shopping/ShoppingSidebarChild.sys.mjs517
1 files changed, 517 insertions, 0 deletions
diff --git a/browser/components/shopping/ShoppingSidebarChild.sys.mjs b/browser/components/shopping/ShoppingSidebarChild.sys.mjs
new file mode 100644
index 0000000000..ff80086d5d
--- /dev/null
+++ b/browser/components/shopping/ShoppingSidebarChild.sys.mjs
@@ -0,0 +1,517 @@
+/* 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 } 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,
+ "adsExposure",
+ "browser.shopping.experience2023.ads.exposure",
+ false
+);
+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();
+ }
+ }
+);
+
+export class ShoppingSidebarChild extends RemotePageChild {
+ constructor() {
+ super();
+ }
+
+ actorCreated() {
+ super.actorCreated();
+ gAllActors.add(this);
+ }
+
+ didDestroy() {
+ this._destroyed = true;
+ super.didDestroy?.();
+ gAllActors.delete(this);
+ this.#product?.off("analysis-progress", this.#onAnalysisProgress);
+ this.#product?.uninit();
+ }
+
+ #productURI = null;
+ #product = null;
+
+ receiveMessage(message) {
+ switch (message.name) {
+ case "ShoppingSidebar:UpdateProductURL":
+ let { url, isReload } = message.data;
+ let uri = url ? Services.io.newURI(url) : null;
+ // If we're going from null to null, bail out:
+ if (!this.#productURI && !uri) {
+ return null;
+ }
+
+ // 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.#productURI)) {
+ return null;
+ }
+
+ this.#productURI = uri;
+ this.updateContent({ haveUpdatedURI: true });
+ break;
+ case "ShoppingSidebar:ShowKeepClosedMessage":
+ this.sendToContent("ShowKeepClosedMessage");
+ break;
+ case "ShoppingSidebar:HideKeepClosedMessage":
+ this.sendToContent("HideKeepClosedMessage");
+ break;
+ case "ShoppingSidebar:IsKeepClosedMessageShowing":
+ return !!this.document.querySelector("shopping-container")
+ ?.wrappedJSObject.showingKeepClosedMessage;
+ }
+ return null;
+ }
+
+ isSameProduct(newURI, currentURI) {
+ if (!newURI || !currentURI) {
+ return false;
+ }
+
+ // Check if the URIs are equal:
+ if (currentURI.equalsExceptRef(newURI)) {
+ return true;
+ }
+
+ if (!this.#product) {
+ return false;
+ }
+
+ // If the current ShoppingProduct has product info set,
+ // check if the product ids are the same:
+ let currentProduct = this.#product.product;
+ if (currentProduct) {
+ let newProduct = ShoppingProduct.fromURL(URL.fromURI(newURI));
+ if (newProduct.id === currentProduct.id) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ handleEvent(event) {
+ let aid;
+ switch (event.type) {
+ case "ContentReady":
+ this.updateContent();
+ break;
+ case "PolledRequestMade":
+ this.updateContent({ isPolledRequest: true });
+ break;
+ case "ReportProductAvailable":
+ this.reportProductAvailable();
+ break;
+ case "AdClicked":
+ aid = event.detail.aid;
+ ShoppingProduct.sendAttributionEvent("click", aid);
+ Glean.shopping.surfaceAdsClicked.record();
+ break;
+ case "AdImpression":
+ aid = event.detail.aid;
+ ShoppingProduct.sendAttributionEvent("impression", aid);
+ Glean.shopping.surfaceAdsImpression.record();
+ break;
+ case "DisableShopping":
+ this.sendAsyncMessage("DisableShopping");
+ break;
+ }
+ }
+
+ // Exposed for testing. Assumes uri is a nsURI.
+ set productURI(uri) {
+ if (!(uri instanceof Ci.nsIURI)) {
+ throw new Error("productURI setter expects an nsIURI");
+ }
+ this.#productURI = uri;
+ }
+
+ // Exposed for testing. Assumes product is a ShoppingProduct.
+ set product(product) {
+ if (!(product instanceof ShoppingProduct)) {
+ throw new Error("product setter expects an instance of ShoppingProduct");
+ }
+ this.#product = product;
+ }
+
+ 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;
+ }
+
+ optedInStateChanged() {
+ // Force re-fetching things if needed by clearing the last product URI:
+ this.#productURI = null;
+ // Then let content know.
+ this.updateContent({ focusCloseButton: true });
+ }
+
+ adsEnabledByUserChanged() {
+ this.sendToContent("adsEnabledByUserChanged", {
+ adsEnabledByUser: this.adsEnabledByUser,
+ });
+
+ this.requestRecommendations(this.#productURI);
+ }
+
+ autoOpenEnabledByUserChanged() {
+ this.sendToContent("autoOpenEnabledByUserChanged", {
+ autoOpenEnabledByUser: this.autoOpenEnabledByUser,
+ });
+ }
+
+ getProductURI() {
+ return this.#productURI;
+ }
+
+ /**
+ * This callback is invoked whenever something changes that requires
+ * re-rendering content. 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.
+ *
+ * @param {object?} options
+ * Optional parameter object.
+ * @param {bool} options.haveUpdatedURI = false
+ * Whether we've got an up-to-date URI already. If true, we avoid
+ * fetching the URI from the parent, and assume `this.#productURI`
+ * is current. Defaults to false.
+ * @param {bool} options.isPolledRequest = false
+ *
+ */
+ async updateContent({
+ haveUpdatedURI = false,
+ isPolledRequest = false,
+ focusCloseButton = false,
+ } = {}) {
+ // 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.
+ let canContinue = (currentURI, checkURI = true) => {
+ if (this._destroyed || !this.canFetchAndShowData) {
+ return false;
+ }
+ if (!checkURI) {
+ return true;
+ }
+ return currentURI && currentURI == this.#productURI;
+ };
+ this.#product?.off("analysis-progress", this.#onAnalysisProgress);
+ this.#product?.uninit();
+ // We are called either because the URL has changed or because the opt-in
+ // state has changed. In both cases, we want to clear out content
+ // immediately, without waiting for potentially async operations like
+ // obtaining product information.
+ // Do not clear data however if an analysis was requested via a call-to-action.
+ if (!isPolledRequest) {
+ this.sendToContent("Update", {
+ adsEnabled: this.adsEnabled,
+ adsEnabledByUser: this.adsEnabledByUser,
+ autoOpenEnabled: this.autoOpenEnabled,
+ autoOpenEnabledByUser: this.autoOpenEnabledByUser,
+ showOnboarding: !this.canFetchAndShowData,
+ data: null,
+ recommendationData: null,
+ focusCloseButton,
+ });
+ }
+ if (this.canFetchAndShowData) {
+ if (!this.#productURI) {
+ // If we already have a URI and it's just null, bail immediately.
+ if (haveUpdatedURI) {
+ return;
+ }
+ let url = await this.sendQuery("GetProductURL");
+
+ // Bail out if we opted out in the meantime, or don't have a URI.
+ if (!canContinue(null, false)) {
+ return;
+ }
+
+ this.#productURI = Services.io.newURI(url);
+ }
+
+ let uri = this.#productURI;
+ this.#product = new ShoppingProduct(uri);
+ this.#product.on(
+ "analysis-progress",
+ this.#onAnalysisProgress.bind(this)
+ );
+
+ let data;
+ let isAnalysisInProgress;
+
+ try {
+ let analysisStatusResponse;
+ if (isPolledRequest) {
+ // Request a new analysis.
+ analysisStatusResponse = await this.#product.requestCreateAnalysis();
+ } else {
+ // Check if there is an analysis in progress.
+ analysisStatusResponse =
+ await this.#product.requestAnalysisCreationStatus();
+ }
+ let analysisStatus = analysisStatusResponse?.status;
+
+ isAnalysisInProgress =
+ analysisStatus &&
+ (analysisStatus == "pending" || analysisStatus == "in_progress");
+ if (isAnalysisInProgress) {
+ // Only clear the existing data if the update wasn't
+ // triggered by a Polled Request event as re-analysis should
+ // keep any stale data visible while processing.
+ if (!isPolledRequest) {
+ this.sendToContent("Update", {
+ isAnalysisInProgress,
+ });
+ }
+ analysisStatusResponse = await this.#product.pollForAnalysisCompleted(
+ {
+ pollInitialWait: analysisStatus == "in_progress" ? 0 : undefined,
+ }
+ );
+ analysisStatus = analysisStatusResponse?.status;
+ isAnalysisInProgress = false;
+ }
+
+ // 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":
+ throw new Error(analysisStatus, { cause: analysisStatus });
+ default:
+ // Status is "completed" or "not_found" (no analysis status),
+ // so we should request the analysis data.
+ }
+
+ if (!data) {
+ data = await this.#product.requestAnalysis();
+ if (!data) {
+ throw new Error("request failed");
+ }
+ }
+ } catch (err) {
+ console.error("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 (!canContinue(uri)) {
+ return;
+ }
+
+ this.sendToContent("Update", {
+ adsEnabled: this.adsEnabled,
+ adsEnabledByUser: this.adsEnabledByUser,
+ autoOpenEnabled: this.autoOpenEnabled,
+ autoOpenEnabledByUser: this.autoOpenEnabledByUser,
+ showOnboarding: false,
+ data,
+ productUrl: this.#productURI.spec,
+ isAnalysisInProgress,
+ });
+
+ if (!data || data.error) {
+ return;
+ }
+
+ if (!isPolledRequest && !data.grade) {
+ Glean.shopping.surfaceNoReviewReliabilityAvailable.record();
+ }
+
+ this.requestRecommendations(uri);
+ } else {
+ // Don't bother continuing if the user has opted out.
+ if (lazy.optedIn == 2) {
+ return;
+ }
+ let url = await this.sendQuery("GetProductURL");
+
+ // 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;
+ }
+
+ this.#productURI = Services.io.newURI(url);
+ // Send the productURI to content for Onboarding's dynamic text
+ this.sendToContent("Update", {
+ showOnboarding: true,
+ data: null,
+ productUrl: this.#productURI.spec,
+ });
+ }
+ }
+
+ /**
+ * Utility function to determine if we should request ads.
+ */
+ canFetchAds(uri) {
+ return (
+ uri.equalsExceptRef(this.#productURI) &&
+ this.canFetchAndShowData &&
+ (lazy.adsExposure || this.canFetchAndShowAd)
+ );
+ }
+
+ /**
+ * Utility function to determine if we should display ads. This is different
+ * from fetching ads, because of ads exposure telemetry (bug 1858470).
+ */
+ canShowAds(uri) {
+ return (
+ uri.equalsExceptRef(this.#productURI) &&
+ this.canFetchAndShowData &&
+ this.canFetchAndShowAd
+ );
+ }
+
+ /**
+ * Request recommended products for a given uri and send the recommendations
+ * to the content if recommendations are enabled.
+ *
+ * @param {nsIURI} uri The uri of the current product page
+ */
+ async requestRecommendations(uri) {
+ if (!this.canFetchAds(uri)) {
+ return;
+ }
+
+ let recommendationData = await this.#product.requestRecommendations();
+
+ // Note: this needs to be separate from the inverse conditional check below
+ // because here we want to know if an ad exists for the product, regardless
+ // of whether ads are enabled, while for the surfaceNoAdsAvailable Glean
+ // probe, we want to know if ads would have been shown, but one wasn't
+ // available.
+ if (recommendationData.length) {
+ Glean.shopping.adsExposure.record();
+ }
+
+ // 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 {
+ ShoppingProduct.sendAttributionEvent(
+ "placement",
+ recommendationData[0].aid
+ );
+ Glean.shopping.surfaceAdsPlacement.record();
+ }
+
+ this.sendToContent("UpdateRecommendations", {
+ recommendationData,
+ });
+ }
+
+ 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);
+ }
+
+ async reportProductAvailable() {
+ await this.#product.sendReport();
+ }
+
+ #onAnalysisProgress(eventName, progress) {
+ this.sendToContent("UpdateAnalysisProgress", {
+ progress,
+ });
+ }
+}