Files
tubestation/browser/components/shopping/ReviewCheckerChild.sys.mjs
kpatenio 484c791c0a Bug 1945646 - implement notification card for migrated Review Checker for existing opted-in users. r=desktop-theme-reviewers,firefox-desktop-core-reviewers ,fluent-reviewers,shopping-reviewers,bolsson,jhirsch
**Summary**
Adds a card for migrated Review Checker notifying users can move the sidebar to the left or the right side. Visibility is controlled by two factors:
- browser.shopping.experience2023.newPositionCard.hasSeen - if true, the user already saw the card and we don't have to show it again. If false (default), make sure to show the card once we're able to.
- browser.shopping.experience2023.integratedSidebar - must be true, since this enables Review Checker in the sidebar and allows us to use ReviewChecker actors

There's also three actors in play:

**ReviewCheckerManager**
- Responsible for the sidebar's visibility and auto-open behaviour. We only show the notification card on auto-open and a PDP, so we check if we can render the notification card in this file.
- It also handles behaviour for moving the sidebar position, or showing the sidebar settings panel.
- However, because ReviewCheckerManager is instantiated before the ReviewChecker parent and child pair actors, we have to wait for RC parent to be created before RC manager can communicate with it.

**ReviewCheckerParent**
- Once created, it communicates with ReviewCheckerManager, so that we know if we should render the notification card or not.
- If it receives a response back from RC manager, it will send a message to its RC child counterpart.
- Otherwise, if it receives a message from RC child to move the sidebar position or show the sidebar settings panel, communicate with RC manager again to do the appropriate action.

**ReviewCheckerChild**
- It receives a message from ReviewCheckerParent to know if we should show the notification card. If yes, then send an event to the content (shopping-container)
- It also listens for and handles events from content (shopping-container), like if a user presses the move position buttons, or wants to see the sidebar settings panel. Once an event is detected for either action from content, send a message to ReviewCheckerParent, and then RC manager by extension, to make the appropriate action.

**Follow-up work**
There's another patch https://phabricator.services.mozilla.com/D239083 that updates how the notification card and keep closed message behave, ensuring they don't visually conflict with each other.

Differential Revision: https://phabricator.services.mozilla.com/D238089
2025-02-27 06:48:10 +00:00

821 lines
22 KiB
JavaScript

/* 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<undefined>}
*/
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<undefined>}
*/
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<undefined>}
*/
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<undefined>}
*/
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<undefined>}
*/
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);
}
}