gecko-dev/browser/components/urlbar/QuickSuggest.sys.mjs

560 строки
17 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/. */
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.sys.mjs",
NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs",
rawSuggestionUrlMatches: "resource://gre/modules/RustSuggest.sys.mjs",
UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs",
UrlbarUtils: "resource:///modules/UrlbarUtils.sys.mjs",
});
// Quick suggest features. On init, QuickSuggest creates an instance of each and
// keeps it in the `#features` map. See `BaseFeature`.
const FEATURES = {
AddonSuggestions:
"resource:///modules/urlbar/private/AddonSuggestions.sys.mjs",
AdmWikipedia: "resource:///modules/urlbar/private/AdmWikipedia.sys.mjs",
BlockedSuggestions:
"resource:///modules/urlbar/private/BlockedSuggestions.sys.mjs",
ExposureSuggestions:
"resource:///modules/urlbar/private/ExposureSuggestions.sys.mjs",
FakespotSuggestions:
"resource:///modules/urlbar/private/FakespotSuggestions.sys.mjs",
ImpressionCaps: "resource:///modules/urlbar/private/ImpressionCaps.sys.mjs",
MDNSuggestions: "resource:///modules/urlbar/private/MDNSuggestions.sys.mjs",
PocketSuggestions:
"resource:///modules/urlbar/private/PocketSuggestions.sys.mjs",
SuggestBackendJs:
"resource:///modules/urlbar/private/SuggestBackendJs.sys.mjs",
SuggestBackendRust:
"resource:///modules/urlbar/private/SuggestBackendRust.sys.mjs",
Weather: "resource:///modules/urlbar/private/Weather.sys.mjs",
YelpSuggestions: "resource:///modules/urlbar/private/YelpSuggestions.sys.mjs",
};
const TIMESTAMP_TEMPLATE = "%YYYYMMDDHH%";
const TIMESTAMP_LENGTH = 10;
const TIMESTAMP_REGEXP = /^\d{10}$/;
const TELEMETRY_EVENT_CATEGORY = "contextservices.quicksuggest";
// Values returned by the onboarding dialog depending on the user's response.
// These values are used in telemetry events, so be careful about changing them.
const ONBOARDING_CHOICE = {
ACCEPT_2: "accept_2",
CLOSE_1: "close_1",
DISMISS_1: "dismiss_1",
DISMISS_2: "dismiss_2",
LEARN_MORE_1: "learn_more_1",
LEARN_MORE_2: "learn_more_2",
NOT_NOW_2: "not_now_2",
REJECT_2: "reject_2",
};
const ONBOARDING_URI =
"chrome://browser/content/urlbar/quicksuggestOnboarding.html";
/**
* This class manages the quick suggest feature (a.k.a Firefox Suggest) and has
* related helpers.
*/
class _QuickSuggest {
/**
* @returns {string}
* The name of the quick suggest telemetry event category.
*/
get TELEMETRY_EVENT_CATEGORY() {
return TELEMETRY_EVENT_CATEGORY;
}
/**
* @returns {string}
* The timestamp template string used in quick suggest URLs.
*/
get TIMESTAMP_TEMPLATE() {
return TIMESTAMP_TEMPLATE;
}
/**
* @returns {number}
* The length of the timestamp in quick suggest URLs.
*/
get TIMESTAMP_LENGTH() {
return TIMESTAMP_LENGTH;
}
/**
* @returns {string}
* The help URL for the Quick Suggest feature.
*/
get HELP_URL() {
return (
Services.urlFormatter.formatURLPref("app.support.baseURL") +
"firefox-suggest"
);
}
get ONBOARDING_CHOICE() {
return { ...ONBOARDING_CHOICE };
}
get ONBOARDING_URI() {
return ONBOARDING_URI;
}
/**
* @returns {SuggestBackendJs|SuggestBackendRust}
* The currently active backend.
*/
get backend() {
return lazy.UrlbarPrefs.get("quickSuggestRustEnabled")
? this.rustBackend
: this.jsBackend;
}
/**
* @returns {SuggestBackendRust}
* The Rust backend. Not used when the JS backend is enabled.
*/
get rustBackend() {
return this.#features.SuggestBackendRust;
}
/**
* @returns {SuggestBackendJs}
* The JS backend. Not used when the Rust backend is enabled.
*/
get jsBackend() {
return this.#features.SuggestBackendJs;
}
/**
* @returns {BlockedSuggestions}
* The blocked suggestions feature.
*/
get blockedSuggestions() {
return this.#features.BlockedSuggestions;
}
/**
* @returns {ImpressionCaps}
* The impression caps feature.
*/
get impressionCaps() {
return this.#features.ImpressionCaps;
}
/**
* @returns {Weather}
* A feature that periodically fetches weather suggestions from Merino.
*/
get weather() {
return this.#features.Weather;
}
/**
* @returns {Set}
* The set of features that manage Rust suggestion types, as determined by
* each feature's `rustSuggestionTypes`.
*/
get rustFeatures() {
return this.#rustFeatures;
}
get logger() {
if (!this._logger) {
this._logger = lazy.UrlbarUtils.getLogger({ prefix: "QuickSuggest" });
}
return this._logger;
}
/**
* Initializes the quick suggest feature. This must be called before using
* quick suggest. It's safe to call more than once.
*/
init() {
if (Object.keys(this.#features).length) {
// Already initialized.
return;
}
// Create an instance of each feature and keep it in `#features`.
for (let [name, uri] of Object.entries(FEATURES)) {
let { [name]: ctor } = ChromeUtils.importESModule(uri);
let feature = new ctor();
this.#features[name] = feature;
if (feature.merinoProvider) {
this.#featuresByMerinoProvider.set(feature.merinoProvider, feature);
}
for (let type of feature.rustSuggestionTypes) {
this.#featuresByRustSuggestionType.set(type, feature);
}
if (feature.rustSuggestionTypes.length) {
this.#rustFeatures.add(feature);
}
// Update the map from enabling preferences to features.
let prefs = feature.enablingPreferences;
if (prefs) {
for (let p of prefs) {
let features = this.#featuresByEnablingPrefs.get(p);
if (!features) {
features = new Set();
this.#featuresByEnablingPrefs.set(p, features);
}
features.add(feature);
}
}
}
this.#updateAll();
lazy.NimbusFeatures.urlbar.onUpdate(() => this.#updateAll());
lazy.UrlbarPrefs.addObserver(this);
}
/**
* Returns a quick suggest feature by name.
*
* @param {string} name
* The name of the feature's JS class.
* @returns {BaseFeature}
* The feature object, an instance of a subclass of `BaseFeature`.
*/
getFeature(name) {
return this.#features[name];
}
/**
* Returns a quick suggest feature by the name of the Merino provider that
* serves its suggestions (as defined by `feature.merinoProvider`). Not all
* features correspond to a Merino provider.
*
* @param {string} provider
* The name of a Merino provider.
* @returns {BaseFeature}
* The feature object, an instance of a subclass of `BaseFeature`, or null
* if no feature corresponds to the Merino provider.
*/
getFeatureByMerinoProvider(provider) {
return this.#featuresByMerinoProvider.get(provider);
}
/**
* Returns a Suggest feature by the type of Rust suggestion it manages (as
* defined by `feature.rustSuggestionTypes`). Not all features correspond to a
* Rust suggestion type.
*
* @param {string} type
* The name of a Rust suggestion type.
* @returns {BaseFeature}
* The feature object, an instance of a subclass of `BaseFeature`, or null
* if no feature corresponds to the type.
*/
getFeatureByRustSuggestionType(type) {
return this.#featuresByRustSuggestionType.get(type);
}
/**
* Called when a urlbar pref changes.
*
* @param {string} pref
* The name of the pref relative to `browser.urlbar`.
*/
onPrefChanged(pref) {
// If any feature's enabling preference changed, update it now.
let features = this.#featuresByEnablingPrefs.get(pref);
if (features) {
for (let f of features) {
f.update();
}
}
switch (pref) {
case "quicksuggest.dataCollection.enabled":
if (!lazy.UrlbarPrefs.updatingFirefoxSuggestScenario) {
Services.telemetry.recordEvent(
TELEMETRY_EVENT_CATEGORY,
"data_collect_toggled",
lazy.UrlbarPrefs.get(pref) ? "enabled" : "disabled"
);
}
break;
case "suggest.quicksuggest.nonsponsored":
if (!lazy.UrlbarPrefs.updatingFirefoxSuggestScenario) {
Services.telemetry.recordEvent(
TELEMETRY_EVENT_CATEGORY,
"enable_toggled",
lazy.UrlbarPrefs.get(pref) ? "enabled" : "disabled"
);
}
break;
case "suggest.quicksuggest.sponsored":
if (!lazy.UrlbarPrefs.updatingFirefoxSuggestScenario) {
Services.telemetry.recordEvent(
TELEMETRY_EVENT_CATEGORY,
"sponsored_toggled",
lazy.UrlbarPrefs.get(pref) ? "enabled" : "disabled"
);
}
break;
}
}
/**
* Returns whether a given URL and quick suggest's URL are equivalent. URLs
* are equivalent if they are identical except for substrings that replaced
* templates in the original suggestion URL.
*
* For example, a suggestion URL from the backing suggestions source might
* include a timestamp template "%YYYYMMDDHH%" like this:
*
* http://example.com/foo?bar=%YYYYMMDDHH%
*
* When a quick suggest result is created from this suggestion URL, it's
* created with a URL that is a copy of the suggestion URL but with the
* template replaced with a real timestamp value, like this:
*
* http://example.com/foo?bar=2021111610
*
* All URLs created from this single suggestion URL are considered equivalent
* regardless of their real timestamp values.
*
* @param {string} url
* The URL to check.
* @param {UrlbarResult} result
* The quick suggest result. Will compare {@link url} to `result.payload.url`
* @returns {boolean}
* Whether `url` is equivalent to `result.payload.url`.
*/
isURLEquivalentToResultURL(url, result) {
// If the URLs aren't the same length, they can't be equivalent.
let resultURL = result.payload.url;
if (resultURL.length != url.length) {
return false;
}
if (result.payload.source == "rust") {
// The Rust implementation has its own equivalence function.
return lazy.rawSuggestionUrlMatches(result.payload.originalUrl, url);
}
// If the result URL doesn't have a timestamp, then do a straight string
// comparison.
let { urlTimestampIndex } = result.payload;
if (typeof urlTimestampIndex != "number" || urlTimestampIndex < 0) {
return resultURL == url;
}
// Compare the first parts of the strings before the timestamps.
if (
resultURL.substring(0, urlTimestampIndex) !=
url.substring(0, urlTimestampIndex)
) {
return false;
}
// Compare the second parts of the strings after the timestamps.
let remainderIndex = urlTimestampIndex + TIMESTAMP_LENGTH;
if (resultURL.substring(remainderIndex) != url.substring(remainderIndex)) {
return false;
}
// Test the timestamp against the regexp.
let maybeTimestamp = url.substring(
urlTimestampIndex,
urlTimestampIndex + TIMESTAMP_LENGTH
);
return TIMESTAMP_REGEXP.test(maybeTimestamp);
}
/**
* Some suggestion properties like `url` and `click_url` include template
* substrings that must be replaced with real values. This method replaces
* templates with appropriate values in place.
*
* @param {object} suggestion
* A suggestion object fetched from remote settings or Merino.
*/
replaceSuggestionTemplates(suggestion) {
let now = new Date();
let timestampParts = [
now.getFullYear(),
now.getMonth() + 1,
now.getDate(),
now.getHours(),
];
let timestamp = timestampParts
.map(n => n.toString().padStart(2, "0"))
.join("");
for (let key of ["url", "click_url"]) {
let value = suggestion[key];
if (!value) {
continue;
}
let timestampIndex = value.indexOf(TIMESTAMP_TEMPLATE);
if (timestampIndex >= 0) {
if (key == "url") {
suggestion.urlTimestampIndex = timestampIndex;
}
// We could use replace() here but we need the timestamp index for
// `suggestion.urlTimestampIndex`, and since we already have that, avoid
// another O(n) substring search and manually replace the template with
// the timestamp.
suggestion[key] =
value.substring(0, timestampIndex) +
timestamp +
value.substring(timestampIndex + TIMESTAMP_TEMPLATE.length);
}
}
}
/**
* An onboarding dialog can be shown to the users who are enrolled into
* the QuickSuggest experiments or rollouts. This behavior is controlled
* by the pref `browser.urlbar.quicksuggest.shouldShowOnboardingDialog`
* which can be remotely configured by Nimbus.
*
* Given that the release may overlap with another onboarding dialog, we may
* wait for a few restarts before showing the QuickSuggest dialog. This can
* be remotely configured by Nimbus through
* `quickSuggestShowOnboardingDialogAfterNRestarts`, the default is 0.
*
* @returns {boolean}
* True if the dialog was shown and false if not.
*/
async maybeShowOnboardingDialog() {
// The call to this method races scenario initialization on startup, and the
// Nimbus variables we rely on below depend on the scenario, so wait for it
// to be initialized.
await lazy.UrlbarPrefs.firefoxSuggestScenarioStartupPromise;
// If the feature is disabled, the user has already seen the dialog, or the
// user has already opted in, don't show the onboarding.
if (
!lazy.UrlbarPrefs.get("quickSuggestEnabled") ||
lazy.UrlbarPrefs.get("quicksuggest.showedOnboardingDialog") ||
lazy.UrlbarPrefs.get("quicksuggest.dataCollection.enabled")
) {
return false;
}
// Wait a number of restarts before showing the dialog.
let restartsSeen = lazy.UrlbarPrefs.get("quicksuggest.seenRestarts");
if (
restartsSeen <
lazy.UrlbarPrefs.get("quickSuggestShowOnboardingDialogAfterNRestarts")
) {
lazy.UrlbarPrefs.set("quicksuggest.seenRestarts", restartsSeen + 1);
return false;
}
let win = lazy.BrowserWindowTracker.getTopWindow();
// Don't show the dialog on top of about:welcome for new users.
if (win.gBrowser?.currentURI?.spec == "about:welcome") {
return false;
}
if (
!lazy.UrlbarPrefs.get("quickSuggestShouldShowOnboardingDialog") ||
lazy.UrlbarPrefs.get("quicksuggest.contextualOptIn")
) {
return false;
}
let variationType;
try {
// An error happens if the pref is not in user prefs.
variationType = lazy.UrlbarPrefs.get(
"quickSuggestOnboardingDialogVariation"
).toLowerCase();
} catch (e) {}
let params = { choice: undefined, variationType, visitedMain: false };
await win.gDialogBox.open(ONBOARDING_URI, params);
lazy.UrlbarPrefs.set("quicksuggest.showedOnboardingDialog", true);
lazy.UrlbarPrefs.set(
"quicksuggest.onboardingDialogVersion",
JSON.stringify({ version: 1, variation: variationType })
);
// Record the user's opt-in choice on the user branch. This pref is sticky,
// so it will retain its user-branch value regardless of what the particular
// default was at the time.
let optedIn = params.choice == ONBOARDING_CHOICE.ACCEPT_2;
lazy.UrlbarPrefs.set("quicksuggest.dataCollection.enabled", optedIn);
switch (params.choice) {
case ONBOARDING_CHOICE.LEARN_MORE_1:
case ONBOARDING_CHOICE.LEARN_MORE_2:
win.openTrustedLinkIn(this.HELP_URL, "tab");
break;
case ONBOARDING_CHOICE.ACCEPT_2:
case ONBOARDING_CHOICE.REJECT_2:
case ONBOARDING_CHOICE.NOT_NOW_2:
case ONBOARDING_CHOICE.CLOSE_1:
// No other action required.
break;
default:
params.choice = params.visitedMain
? ONBOARDING_CHOICE.DISMISS_2
: ONBOARDING_CHOICE.DISMISS_1;
break;
}
lazy.UrlbarPrefs.set("quicksuggest.onboardingDialogChoice", params.choice);
Services.telemetry.recordEvent(
"contextservices.quicksuggest",
"opt_in_dialog",
params.choice
);
return true;
}
/**
* Updates state based on whether quick suggest and its features are enabled.
*/
#updateAll() {
// IMPORTANT: This method is a `NimbusFeatures.urlbar.onUpdate()` callback,
// which means it's called on every change to any pref that is a fallback
// for a urlbar Nimbus variable.
// Update features.
for (let feature of Object.values(this.#features)) {
feature.update();
}
// Update state related to quick suggest as a whole.
let enabled = lazy.UrlbarPrefs.get("quickSuggestEnabled");
Services.telemetry.setEventRecordingEnabled(
TELEMETRY_EVENT_CATEGORY,
enabled
);
}
// Maps from Suggest feature class names to feature instances.
#features = {};
// Maps from Merino provider names to Suggest feature instances.
#featuresByMerinoProvider = new Map();
// Maps from Rust suggestion types to Suggest feature instances.
#featuresByRustSuggestionType = new Map();
// Set of feature instances that manage Rust suggestion types.
#rustFeatures = new Set();
// Maps from preference names to the `Set` of feature instances they enable.
#featuresByEnablingPrefs = new Map();
}
export const QuickSuggest = new _QuickSuggest();