summaryrefslogtreecommitdiffstats
path: root/browser/components/urlbar/private
diff options
context:
space:
mode:
Diffstat (limited to 'browser/components/urlbar/private')
-rw-r--r--browser/components/urlbar/private/AddonSuggestions.sys.mjs279
-rw-r--r--browser/components/urlbar/private/AdmWikipedia.sys.mjs307
-rw-r--r--browser/components/urlbar/private/BaseFeature.sys.mjs224
-rw-r--r--browser/components/urlbar/private/BlockedSuggestions.sys.mjs187
-rw-r--r--browser/components/urlbar/private/ImpressionCaps.sys.mjs561
-rw-r--r--browser/components/urlbar/private/MDNSuggestions.sys.mjs198
-rw-r--r--browser/components/urlbar/private/PocketSuggestions.sys.mjs314
-rw-r--r--browser/components/urlbar/private/SuggestBackendJs.sys.mjs443
-rw-r--r--browser/components/urlbar/private/SuggestBackendRust.sys.mjs407
-rw-r--r--browser/components/urlbar/private/Weather.sys.mjs896
-rw-r--r--browser/components/urlbar/private/YelpSuggestions.sys.mjs264
11 files changed, 4080 insertions, 0 deletions
diff --git a/browser/components/urlbar/private/AddonSuggestions.sys.mjs b/browser/components/urlbar/private/AddonSuggestions.sys.mjs
new file mode 100644
index 0000000000..35849e5cd1
--- /dev/null
+++ b/browser/components/urlbar/private/AddonSuggestions.sys.mjs
@@ -0,0 +1,279 @@
+/* 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 { BaseFeature } from "resource:///modules/urlbar/private/BaseFeature.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
+ QuickSuggest: "resource:///modules/QuickSuggest.sys.mjs",
+ SuggestionsMap: "resource:///modules/urlbar/private/SuggestBackendJs.sys.mjs",
+ UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs",
+ UrlbarResult: "resource:///modules/UrlbarResult.sys.mjs",
+ UrlbarUtils: "resource:///modules/UrlbarUtils.sys.mjs",
+});
+
+const UTM_PARAMS = {
+ utm_medium: "firefox-desktop",
+ utm_source: "firefox-suggest",
+};
+
+const RESULT_MENU_COMMAND = {
+ HELP: "help",
+ NOT_INTERESTED: "not_interested",
+ NOT_RELEVANT: "not_relevant",
+ SHOW_LESS_FREQUENTLY: "show_less_frequently",
+};
+
+/**
+ * A feature that supports Addon suggestions.
+ */
+export class AddonSuggestions extends BaseFeature {
+ get shouldEnable() {
+ return (
+ lazy.UrlbarPrefs.get("addonsFeatureGate") &&
+ lazy.UrlbarPrefs.get("suggest.addons") &&
+ lazy.UrlbarPrefs.get("suggest.quicksuggest.nonsponsored")
+ );
+ }
+
+ get enablingPreferences() {
+ return ["suggest.addons", "suggest.quicksuggest.nonsponsored"];
+ }
+
+ get merinoProvider() {
+ return "amo";
+ }
+
+ get rustSuggestionTypes() {
+ return ["Amo"];
+ }
+
+ enable(enabled) {
+ if (enabled) {
+ lazy.QuickSuggest.jsBackend.register(this);
+ } else {
+ lazy.QuickSuggest.jsBackend.unregister(this);
+ this.#suggestionsMap?.clear();
+ }
+ }
+
+ queryRemoteSettings(searchString) {
+ const suggestions = this.#suggestionsMap?.get(searchString);
+ if (!suggestions) {
+ return [];
+ }
+
+ return suggestions.map(suggestion => ({
+ icon: suggestion.icon,
+ url: suggestion.url,
+ title: suggestion.title,
+ description: suggestion.description,
+ guid: suggestion.guid,
+ score: suggestion.score,
+ }));
+ }
+
+ async onRemoteSettingsSync(rs) {
+ const records = await rs.get({ filters: { type: "amo-suggestions" } });
+ if (!this.isEnabled) {
+ return;
+ }
+
+ const suggestionsMap = new lazy.SuggestionsMap();
+
+ for (const record of records) {
+ const { buffer } = await rs.attachments.download(record);
+ if (!this.isEnabled) {
+ return;
+ }
+
+ const results = JSON.parse(new TextDecoder("utf-8").decode(buffer));
+ await suggestionsMap.add(results, {
+ mapKeyword:
+ lazy.SuggestionsMap.MAP_KEYWORD_PREFIXES_STARTING_AT_FIRST_WORD,
+ });
+ if (!this.isEnabled) {
+ return;
+ }
+ }
+
+ this.#suggestionsMap = suggestionsMap;
+ }
+
+ async makeResult(queryContext, suggestion, searchString) {
+ if (!this.isEnabled) {
+ // The feature is disabled on the client, but Merino may still return
+ // addon suggestions anyway, and we filter them out here.
+ return null;
+ }
+
+ // If the user hasn't clicked the "Show less frequently" command, the
+ // suggestion can be shown. Otherwise, the suggestion can be shown if the
+ // user typed more than one word with at least `showLessFrequentlyCount`
+ // characters after the first word, including spaces.
+ if (this.showLessFrequentlyCount) {
+ let spaceIndex = searchString.search(/\s/);
+ if (
+ spaceIndex < 0 ||
+ searchString.length - spaceIndex < this.showLessFrequentlyCount
+ ) {
+ return null;
+ }
+ }
+
+ const { guid } =
+ suggestion.source === "merino"
+ ? suggestion.custom_details.amo
+ : suggestion;
+
+ const addon = await lazy.AddonManager.getAddonByID(guid);
+ if (addon) {
+ // Addon suggested is already installed.
+ return null;
+ }
+
+ if (suggestion.source == "rust") {
+ suggestion.icon = suggestion.iconUrl;
+ delete suggestion.iconUrl;
+ }
+
+ // Set UTM params unless they're already defined. This allows remote
+ // settings or Merino to override them if need be.
+ let url = new URL(suggestion.url);
+ for (let [key, value] of Object.entries(UTM_PARAMS)) {
+ if (!url.searchParams.has(key)) {
+ url.searchParams.set(key, value);
+ }
+ }
+
+ const payload = {
+ url: url.href,
+ originalUrl: suggestion.url,
+ shouldShowUrl: true,
+ title: suggestion.title,
+ description: suggestion.description,
+ bottomTextL10n: { id: "firefox-suggest-addons-recommended" },
+ helpUrl: lazy.QuickSuggest.HELP_URL,
+ };
+
+ return Object.assign(
+ new lazy.UrlbarResult(
+ lazy.UrlbarUtils.RESULT_TYPE.URL,
+ lazy.UrlbarUtils.RESULT_SOURCE.SEARCH,
+ ...lazy.UrlbarResult.payloadAndSimpleHighlights(
+ queryContext.tokens,
+ payload
+ )
+ ),
+ {
+ isBestMatch: true,
+ suggestedIndex: 1,
+ isRichSuggestion: true,
+ richSuggestionIconSize: 24,
+ showFeedbackMenu: true,
+ }
+ );
+ }
+
+ getResultCommands(result) {
+ const commands = [];
+
+ if (this.canShowLessFrequently) {
+ commands.push({
+ name: RESULT_MENU_COMMAND.SHOW_LESS_FREQUENTLY,
+ l10n: {
+ id: "firefox-suggest-command-show-less-frequently",
+ },
+ });
+ }
+
+ commands.push(
+ {
+ l10n: {
+ id: "firefox-suggest-command-dont-show-this",
+ },
+ children: [
+ {
+ name: RESULT_MENU_COMMAND.NOT_RELEVANT,
+ l10n: {
+ id: "firefox-suggest-command-not-relevant",
+ },
+ },
+ {
+ name: RESULT_MENU_COMMAND.NOT_INTERESTED,
+ l10n: {
+ id: "firefox-suggest-command-not-interested",
+ },
+ },
+ ],
+ },
+ { name: "separator" },
+ {
+ name: RESULT_MENU_COMMAND.HELP,
+ l10n: {
+ id: "urlbar-result-menu-learn-more-about-firefox-suggest",
+ },
+ }
+ );
+
+ return commands;
+ }
+
+ handleCommand(view, result, selType) {
+ switch (selType) {
+ case RESULT_MENU_COMMAND.HELP:
+ // "help" is handled by UrlbarInput, no need to do anything here.
+ break;
+ // selType == "dismiss" when the user presses the dismiss key shortcut.
+ case "dismiss":
+ case RESULT_MENU_COMMAND.NOT_RELEVANT:
+ lazy.QuickSuggest.blockedSuggestions.add(result.payload.originalUrl);
+ result.acknowledgeDismissalL10n = {
+ id: "firefox-suggest-dismissal-acknowledgment-one",
+ };
+ view.controller.removeResult(result);
+ break;
+ case RESULT_MENU_COMMAND.NOT_INTERESTED:
+ lazy.UrlbarPrefs.set("suggest.addons", false);
+ result.acknowledgeDismissalL10n = {
+ id: "firefox-suggest-dismissal-acknowledgment-all",
+ };
+ view.controller.removeResult(result);
+ break;
+ case RESULT_MENU_COMMAND.SHOW_LESS_FREQUENTLY:
+ view.acknowledgeFeedback(result);
+ this.incrementShowLessFrequentlyCount();
+ if (!this.canShowLessFrequently) {
+ view.invalidateResultMenuCommands();
+ }
+ break;
+ }
+ }
+
+ incrementShowLessFrequentlyCount() {
+ if (this.canShowLessFrequently) {
+ lazy.UrlbarPrefs.set(
+ "addons.showLessFrequentlyCount",
+ this.showLessFrequentlyCount + 1
+ );
+ }
+ }
+
+ get showLessFrequentlyCount() {
+ const count = lazy.UrlbarPrefs.get("addons.showLessFrequentlyCount") || 0;
+ return Math.max(count, 0);
+ }
+
+ get canShowLessFrequently() {
+ const cap =
+ lazy.UrlbarPrefs.get("addonsShowLessFrequentlyCap") ||
+ lazy.QuickSuggest.backend.config?.showLessFrequentlyCap ||
+ 0;
+ return !cap || this.showLessFrequentlyCount < cap;
+ }
+
+ #suggestionsMap = null;
+}
diff --git a/browser/components/urlbar/private/AdmWikipedia.sys.mjs b/browser/components/urlbar/private/AdmWikipedia.sys.mjs
new file mode 100644
index 0000000000..0e266ced40
--- /dev/null
+++ b/browser/components/urlbar/private/AdmWikipedia.sys.mjs
@@ -0,0 +1,307 @@
+/* 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 { BaseFeature } from "resource:///modules/urlbar/private/BaseFeature.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ QuickSuggest: "resource:///modules/QuickSuggest.sys.mjs",
+ SuggestionsMap: "resource:///modules/urlbar/private/SuggestBackendJs.sys.mjs",
+ UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs",
+ UrlbarResult: "resource:///modules/UrlbarResult.sys.mjs",
+ UrlbarUtils: "resource:///modules/UrlbarUtils.sys.mjs",
+});
+
+const NONSPONSORED_IAB_CATEGORIES = new Set(["5 - Education"]);
+
+/**
+ * A feature that manages sponsored adM and non-sponsored Wikpedia (sometimes
+ * called "expanded Wikipedia") suggestions in remote settings.
+ */
+export class AdmWikipedia extends BaseFeature {
+ constructor() {
+ super();
+ this.#suggestionsMap = new lazy.SuggestionsMap();
+ }
+
+ get shouldEnable() {
+ return (
+ lazy.UrlbarPrefs.get("suggest.quicksuggest.nonsponsored") ||
+ lazy.UrlbarPrefs.get("suggest.quicksuggest.sponsored")
+ );
+ }
+
+ get enablingPreferences() {
+ return [
+ "suggest.quicksuggest.nonsponsored",
+ "suggest.quicksuggest.sponsored",
+ ];
+ }
+
+ get merinoProvider() {
+ return "adm";
+ }
+
+ get rustSuggestionTypes() {
+ return ["Amp", "Wikipedia"];
+ }
+
+ getSuggestionTelemetryType(suggestion) {
+ return suggestion.is_sponsored ? "adm_sponsored" : "adm_nonsponsored";
+ }
+
+ isRustSuggestionTypeEnabled(type) {
+ switch (type) {
+ case "Amp":
+ return lazy.UrlbarPrefs.get("suggest.quicksuggest.sponsored");
+ case "Wikipedia":
+ return lazy.UrlbarPrefs.get("suggest.quicksuggest.nonsponsored");
+ }
+ this.logger.error("Unknown Rust suggestion type: " + type);
+ return false;
+ }
+
+ enable(enabled) {
+ if (enabled) {
+ lazy.QuickSuggest.jsBackend.register(this);
+ } else {
+ lazy.QuickSuggest.jsBackend.unregister(this);
+ this.#suggestionsMap.clear();
+ }
+ }
+
+ async queryRemoteSettings(searchString) {
+ let suggestions = this.#suggestionsMap.get(searchString);
+ if (!suggestions) {
+ return [];
+ }
+
+ // Start each icon fetch at the same time and wait for them all to finish.
+ let icons = await Promise.all(
+ suggestions.map(({ icon }) => this.#fetchIcon(icon))
+ );
+
+ return suggestions.map(suggestion => ({
+ full_keyword: this.#getFullKeyword(searchString, suggestion.keywords),
+ title: suggestion.title,
+ url: suggestion.url,
+ click_url: suggestion.click_url,
+ impression_url: suggestion.impression_url,
+ block_id: suggestion.id,
+ advertiser: suggestion.advertiser,
+ iab_category: suggestion.iab_category,
+ is_sponsored: !NONSPONSORED_IAB_CATEGORIES.has(suggestion.iab_category),
+ score: suggestion.score,
+ position: suggestion.position,
+ icon: icons.shift(),
+ }));
+ }
+
+ async onRemoteSettingsSync(rs) {
+ let dataType = lazy.UrlbarPrefs.get("quickSuggestRemoteSettingsDataType");
+ this.logger.debug("Loading remote settings with type: " + dataType);
+
+ let [data] = await Promise.all([
+ rs.get({ filters: { type: dataType } }),
+ rs
+ .get({ filters: { type: "icon" } })
+ .then(icons =>
+ Promise.all(icons.map(i => rs.attachments.downloadToDisk(i)))
+ ),
+ ]);
+ if (!this.isEnabled) {
+ return;
+ }
+
+ let suggestionsMap = new lazy.SuggestionsMap();
+
+ this.logger.debug(`Got data with ${data.length} records`);
+ for (let record of data) {
+ let { buffer } = await rs.attachments.download(record);
+ if (!this.isEnabled) {
+ return;
+ }
+
+ let results = JSON.parse(new TextDecoder("utf-8").decode(buffer));
+ this.logger.debug(`Adding ${results.length} results`);
+ await suggestionsMap.add(results);
+ if (!this.isEnabled) {
+ return;
+ }
+ }
+
+ this.#suggestionsMap = suggestionsMap;
+ }
+
+ makeResult(queryContext, suggestion, searchString) {
+ let originalUrl;
+ if (suggestion.source == "rust") {
+ // The Rust backend defines `rawUrl` on AMP suggestions, and its value is
+ // what we on desktop call the `originalUrl`, i.e., it's a URL that may
+ // contain timestamp templates. Rust does not define `rawUrl` for
+ // Wikipedia suggestions, but we have historically included `originalUrl`
+ // for both AMP and Wikipedia even though Wikipedia URLs never contain
+ // timestamp templates. So, when setting `originalUrl`, fall back to `url`
+ // for suggestions without `rawUrl`.
+ originalUrl = suggestion.rawUrl ?? suggestion.url;
+
+ // The Rust backend uses camelCase instead of snake_case, and it excludes
+ // some properties in non-sponsored suggestions that we expect, so convert
+ // the Rust suggestion to a suggestion object we expect here on desktop.
+ let desktopSuggestion = {
+ title: suggestion.title,
+ url: suggestion.url,
+ is_sponsored: suggestion.is_sponsored,
+ full_keyword: suggestion.fullKeyword,
+ };
+ if (suggestion.is_sponsored) {
+ desktopSuggestion.impression_url = suggestion.impressionUrl;
+ desktopSuggestion.click_url = suggestion.clickUrl;
+ desktopSuggestion.block_id = suggestion.blockId;
+ desktopSuggestion.advertiser = suggestion.advertiser;
+ desktopSuggestion.iab_category = suggestion.iabCategory;
+ } else {
+ desktopSuggestion.advertiser = "Wikipedia";
+ desktopSuggestion.iab_category = "5 - Education";
+ }
+ suggestion = desktopSuggestion;
+ } else {
+ // Replace the suggestion's template substrings, but first save the
+ // original URL before its timestamp template is replaced.
+ originalUrl = suggestion.url;
+ lazy.QuickSuggest.replaceSuggestionTemplates(suggestion);
+ }
+
+ let payload = {
+ originalUrl,
+ url: suggestion.url,
+ title: suggestion.title,
+ qsSuggestion: [
+ suggestion.full_keyword,
+ lazy.UrlbarUtils.HIGHLIGHT.SUGGESTED,
+ ],
+ isSponsored: suggestion.is_sponsored,
+ requestId: suggestion.request_id,
+ urlTimestampIndex: suggestion.urlTimestampIndex,
+ sponsoredImpressionUrl: suggestion.impression_url,
+ sponsoredClickUrl: suggestion.click_url,
+ sponsoredBlockId: suggestion.block_id,
+ sponsoredAdvertiser: suggestion.advertiser,
+ sponsoredIabCategory: suggestion.iab_category,
+ helpUrl: lazy.QuickSuggest.HELP_URL,
+ helpL10n: {
+ id: "urlbar-result-menu-learn-more-about-firefox-suggest",
+ },
+ isBlockable: true,
+ blockL10n: {
+ id: "urlbar-result-menu-dismiss-firefox-suggest",
+ },
+ };
+
+ let result = new lazy.UrlbarResult(
+ lazy.UrlbarUtils.RESULT_TYPE.URL,
+ lazy.UrlbarUtils.RESULT_SOURCE.SEARCH,
+ ...lazy.UrlbarResult.payloadAndSimpleHighlights(
+ queryContext.tokens,
+ payload
+ )
+ );
+
+ if (suggestion.is_sponsored) {
+ if (!lazy.UrlbarPrefs.get("quickSuggestSponsoredPriority")) {
+ result.richSuggestionIconSize = 16;
+ }
+
+ result.payload.descriptionL10n = {
+ id: "urlbar-result-action-sponsored",
+ };
+ result.isRichSuggestion = true;
+ }
+
+ return result;
+ }
+
+ /**
+ * Gets the "full keyword" (i.e., suggestion) for a query from a list of
+ * keywords. The suggestions data doesn't include full keywords, so we make
+ * our own based on the result's keyword phrases and a particular query. We
+ * use two heuristics:
+ *
+ * (1) Find the first keyword phrase that has more words than the query. Use
+ * its first `queryWords.length` words as the full keyword. e.g., if the
+ * query is "moz" and `keywords` is ["moz", "mozi", "mozil", "mozill",
+ * "mozilla", "mozilla firefox"], pick "mozilla firefox", pop off the
+ * "firefox" and use "mozilla" as the full keyword.
+ * (2) If there isn't any keyword phrase with more words, then pick the
+ * longest phrase. e.g., pick "mozilla" in the previous example (assuming
+ * the "mozilla firefox" phrase isn't there). That might be the query
+ * itself.
+ *
+ * @param {string} query
+ * The query string.
+ * @param {Array} keywords
+ * An array of suggestion keywords.
+ * @returns {string}
+ * The full keyword.
+ */
+ #getFullKeyword(query, keywords) {
+ let longerPhrase;
+ let trimmedQuery = query.toLocaleLowerCase().trim();
+ let queryWords = trimmedQuery.split(" ");
+
+ for (let phrase of keywords) {
+ if (phrase.startsWith(query)) {
+ let trimmedPhrase = phrase.trim();
+ let phraseWords = trimmedPhrase.split(" ");
+ // As an exception to (1), if the query ends with a space, then look for
+ // phrases with one more word so that the suggestion includes a word
+ // following the space.
+ let extra = query.endsWith(" ") ? 1 : 0;
+ let len = queryWords.length + extra;
+ if (len < phraseWords.length) {
+ // We found a phrase with more words.
+ return phraseWords.slice(0, len).join(" ");
+ }
+ if (
+ query.length < phrase.length &&
+ (!longerPhrase || longerPhrase.length < trimmedPhrase.length)
+ ) {
+ // We found a longer phrase with the same number of words.
+ longerPhrase = trimmedPhrase;
+ }
+ }
+ }
+ return longerPhrase || trimmedQuery;
+ }
+
+ /**
+ * Fetch the icon from RemoteSettings attachments.
+ *
+ * @param {string} path
+ * The icon's remote settings path.
+ */
+ async #fetchIcon(path) {
+ if (!path) {
+ return null;
+ }
+
+ let { rs } = lazy.QuickSuggest.jsBackend;
+ if (!rs) {
+ return null;
+ }
+
+ let record = (
+ await rs.get({
+ filters: { id: `icon-${path}` },
+ })
+ ).pop();
+ if (!record) {
+ return null;
+ }
+ return rs.attachments.downloadToDisk(record);
+ }
+
+ #suggestionsMap;
+}
diff --git a/browser/components/urlbar/private/BaseFeature.sys.mjs b/browser/components/urlbar/private/BaseFeature.sys.mjs
new file mode 100644
index 0000000000..d95ace6940
--- /dev/null
+++ b/browser/components/urlbar/private/BaseFeature.sys.mjs
@@ -0,0 +1,224 @@
+/* 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, {
+ UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs",
+ UrlbarUtils: "resource:///modules/UrlbarUtils.sys.mjs",
+});
+
+/**
+ * Base class for quick suggest features. It can be extended to implement a
+ * feature that is part of the larger quick suggest feature and that should be
+ * enabled only when quick suggest is enabled.
+ *
+ * You can extend this class as an alternative to implementing your feature
+ * directly in `QuickSuggest`. Doing so has the following advantages:
+ *
+ * - If your feature is gated on a Nimbus variable or preference, `QuickSuggest`
+ * will manage its lifetime automatically. This is really only useful if the
+ * feature has state that must be initialized when the feature is enabled and
+ * uninitialized when it's disabled.
+ *
+ * - Encapsulation. You can keep all the code related to your feature in one
+ * place, without mixing it with unrelated code and cluttering up
+ * `QuickSuggest`. You can also test it in isolation from `QuickSuggest`.
+ *
+ * - Remote settings management. You can register your feature with
+ * `QuickSuggestRemoteSettings` and it will be called at appropriate times to
+ * sync from remote settings.
+ *
+ * - If your feature also serves suggestions from remote settings, you can
+ * implement one method, `queryRemoteSettings()`, to hook into
+ * `UrlbarProviderQuickSuggest`.
+ *
+ * - Your feature will automatically get its own logger.
+ *
+ * To register your subclass with `QuickSuggest`, add it to the `FEATURES` const
+ * in QuickSuggest.sys.mjs.
+ */
+export class BaseFeature {
+ /**
+ * {boolean}
+ * Whether the feature should be enabled. Typically the subclass will check
+ * the values of one or more Nimbus variables or preferences. `QuickSuggest`
+ * will access this getter only when the quick suggest feature as a whole is
+ * enabled. Otherwise the subclass feature will be disabled automatically.
+ */
+ get shouldEnable() {
+ throw new Error("`shouldEnable` must be overridden");
+ }
+
+ /**
+ * @returns {Array}
+ * If the subclass's `shouldEnable` implementation depends on any prefs that
+ * are not fallbacks for Nimbus variables, the subclass should override this
+ * getter and return their names in this array so that `update()` can be
+ * called when they change. Names should be relative to `browser.urlbar.`.
+ * It doesn't hurt to include prefs that are fallbacks for Nimbus variables,
+ * it's just not necessary because `QuickSuggest` will update all features
+ * whenever a `urlbar` Nimbus variable or its fallback pref changes.
+ */
+ get enablingPreferences() {
+ return null;
+ }
+
+ /**
+ * @returns {string}
+ * If the feature manages suggestions served by Merino, the subclass should
+ * override this getter and return the name of the specific Merino provider
+ * that serves them.
+ */
+ get merinoProvider() {
+ return "";
+ }
+
+ /**
+ * @returns {Array}
+ * If the feature manages one or more types of suggestions served by the
+ * Suggest Rust component, the subclass should override this getter and
+ * return an array of the type names as defined in `suggest.udl`.
+ */
+ get rustSuggestionTypes() {
+ return [];
+ }
+
+ /**
+ * This method should initialize or uninitialize any state related to the
+ * feature.
+ *
+ * @param {boolean} enabled
+ * Whether the feature should be enabled or not.
+ */
+ enable(enabled) {}
+
+ /**
+ * If the feature manages suggestions from remote settings that should be
+ * returned by UrlbarProviderQuickSuggest, the subclass should override this
+ * method. It should return remote settings suggestions matching the given
+ * search string.
+ *
+ * @param {string} searchString
+ * The search string.
+ * @returns {Array}
+ * An array of matching suggestions, or null if not implemented.
+ */
+ async queryRemoteSettings(searchString) {
+ return null;
+ }
+
+ /**
+ * If the feature manages data in remote settings, the subclass should
+ * override this method. It should fetch the data and build whatever data
+ * structures are necessary to support the feature.
+ *
+ * @param {RemoteSettings} rs
+ * The `RemoteSettings` client object.
+ */
+ async onRemoteSettingsSync(rs) {}
+
+ /**
+ * If the feature manages suggestions that either aren't served by Merino or
+ * whose telemetry type is different from `merinoProvider`, the subclass
+ * should override this method. It should return the telemetry type for the
+ * given suggestion. A telemetry type uniquely identifies a type of suggestion
+ * as well as the kind of `UrlbarResult` instances created from it.
+ *
+ * @param {object} suggestion
+ * A suggestion from either remote settings or Merino.
+ * @returns {string}
+ * The suggestion's telemetry type.
+ */
+ getSuggestionTelemetryType(suggestion) {
+ return this.merinoProvider;
+ }
+
+ /**
+ * If the feature manages more than one type of suggestion served by the
+ * Suggest Rust component, the subclass should override this method and return
+ * true if the given suggestion type is enabled and false otherwise. Ideally a
+ * feature manages at most one type of Rust suggestion, and in that case it's
+ * fine to rely on the default implementation here because the suggestion type
+ * will be enabled iff the feature itself is enabled.
+ *
+ * @param {string} type
+ * A Rust suggestion type name as defined in `suggest.udl`. See also
+ * `rustSuggestionTypes`.
+ * @returns {boolean}
+ * Whether the suggestion type is enabled.
+ */
+ isRustSuggestionTypeEnabled(type) {
+ return true;
+ }
+
+ /**
+ * If the feature corresponds to a type of suggestion, the subclass should
+ * override this method. It should return a new `UrlbarResult` for a given
+ * suggestion, which can come from either remote settings or Merino.
+ *
+ * @param {UrlbarQueryContext} queryContext
+ * The query context.
+ * @param {object} suggestion
+ * The suggestion from either remote settings or Merino.
+ * @param {string} searchString
+ * The search string that was used to fetch the suggestion. It may be
+ * different from `queryContext.searchString` due to trimming, lower-casing,
+ * etc. This is included as a param in case it's useful.
+ * @returns {UrlbarResult}
+ * A new result for the suggestion.
+ */
+ async makeResult(queryContext, suggestion, searchString) {
+ return null;
+ }
+
+ // Methods not designed for overriding below
+
+ /**
+ * @returns {Logger}
+ * The feature's logger.
+ */
+ get logger() {
+ if (!this._logger) {
+ this._logger = lazy.UrlbarUtils.getLogger({
+ prefix: `QuickSuggest.${this.name}`,
+ });
+ }
+ return this._logger;
+ }
+
+ /**
+ * @returns {boolean}
+ * Whether the feature is enabled. The enabled status is automatically
+ * managed by `QuickSuggest` and subclasses should not override this.
+ */
+ get isEnabled() {
+ return this.#isEnabled;
+ }
+
+ /**
+ * @returns {string}
+ * The feature's name.
+ */
+ get name() {
+ return this.constructor.name;
+ }
+
+ /**
+ * Enables or disables the feature according to `shouldEnable` and whether
+ * quick suggest is enabled. If the feature is already enabled appropriately,
+ * does nothing.
+ */
+ update() {
+ let enable =
+ lazy.UrlbarPrefs.get("quickSuggestEnabled") && this.shouldEnable;
+ if (enable != this.isEnabled) {
+ this.logger.info(`Setting enabled = ${enable}`);
+ this.enable(enable);
+ this.#isEnabled = enable;
+ }
+ }
+
+ #isEnabled = false;
+}
diff --git a/browser/components/urlbar/private/BlockedSuggestions.sys.mjs b/browser/components/urlbar/private/BlockedSuggestions.sys.mjs
new file mode 100644
index 0000000000..d74a0979d1
--- /dev/null
+++ b/browser/components/urlbar/private/BlockedSuggestions.sys.mjs
@@ -0,0 +1,187 @@
+/* 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 { BaseFeature } from "resource:///modules/urlbar/private/BaseFeature.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ TaskQueue: "resource:///modules/UrlbarUtils.sys.mjs",
+ UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs",
+});
+
+/**
+ * A set of blocked suggestions for quick suggest.
+ */
+export class BlockedSuggestions extends BaseFeature {
+ constructor() {
+ super();
+ this.#taskQueue = new lazy.TaskQueue();
+ lazy.UrlbarPrefs.addObserver(this);
+ }
+
+ get shouldEnable() {
+ // Return true so that we'll always load blocked digests when quick suggest
+ // is enabled, even if blocking new suggestions is currently disabled.
+ // Blocking may have been enabled previously, and blocked suggestions should
+ // remain blocked as long as quick suggest as a whole remains enabled.
+ return true;
+ }
+
+ enable(enabled) {
+ if (enabled) {
+ this.#loadDigests();
+ }
+ }
+
+ /**
+ * Blocks a suggestion.
+ *
+ * @param {string} originalUrl
+ * The suggestion's original URL with its unreplaced timestamp template.
+ */
+ async add(originalUrl) {
+ this.logger.debug(`Queueing add: ${originalUrl}`);
+ await this.#taskQueue.queue(async () => {
+ this.logger.info(`Blocking suggestion: ${originalUrl}`);
+ let digest = await this.#getDigest(originalUrl);
+ this.logger.debug(`Got digest for '${originalUrl}': ${digest}`);
+ this.#digests.add(digest);
+ let json = JSON.stringify([...this.#digests]);
+ this.#updatingDigests = true;
+ try {
+ lazy.UrlbarPrefs.set("quicksuggest.blockedDigests", json);
+ } finally {
+ this.#updatingDigests = false;
+ }
+ this.logger.debug(`All blocked suggestions: ${json}`);
+ });
+ }
+
+ /**
+ * Gets whether a suggestion is blocked.
+ *
+ * @param {string} originalUrl
+ * The suggestion's original URL with its unreplaced timestamp template.
+ * @returns {boolean}
+ * Whether the suggestion is blocked.
+ */
+ async has(originalUrl) {
+ this.logger.debug(`Queueing has: ${originalUrl}`);
+ return this.#taskQueue.queue(async () => {
+ this.logger.info(`Getting blocked status: ${originalUrl}`);
+ let digest = await this.#getDigest(originalUrl);
+ this.logger.debug(`Got digest for '${originalUrl}': ${digest}`);
+ let isBlocked = this.#digests.has(digest);
+ this.logger.info(`Blocked status for '${originalUrl}': ${isBlocked}`);
+ return isBlocked;
+ });
+ }
+
+ /**
+ * Unblocks all suggestions.
+ */
+ async clear() {
+ this.logger.debug(`Queueing clearBlockedSuggestions`);
+ await this.#taskQueue.queue(() => {
+ this.logger.info(`Clearing all blocked suggestions`);
+ this.#digests.clear();
+ lazy.UrlbarPrefs.clear("quicksuggest.blockedDigests");
+ });
+ }
+
+ /**
+ * Called when a urlbar pref changes.
+ *
+ * @param {string} pref
+ * The name of the pref relative to `browser.urlbar`.
+ */
+ onPrefChanged(pref) {
+ switch (pref) {
+ case "quicksuggest.blockedDigests":
+ if (!this.#updatingDigests) {
+ this.logger.info(
+ "browser.urlbar.quicksuggest.blockedDigests changed"
+ );
+ this.#loadDigests();
+ }
+ break;
+ }
+ }
+
+ /**
+ * Loads blocked suggestion digests from the pref into `#digests`.
+ */
+ async #loadDigests() {
+ this.logger.debug(`Queueing #loadDigests`);
+ await this.#taskQueue.queue(() => {
+ this.logger.info(`Loading blocked suggestion digests`);
+ let json = lazy.UrlbarPrefs.get("quicksuggest.blockedDigests");
+ this.logger.debug(
+ `browser.urlbar.quicksuggest.blockedDigests value: ${json}`
+ );
+ if (!json) {
+ this.logger.info(`There are no blocked suggestion digests`);
+ this.#digests.clear();
+ } else {
+ try {
+ this.#digests = new Set(JSON.parse(json));
+ this.logger.info(`Successfully loaded blocked suggestion digests`);
+ } catch (error) {
+ this.logger.error(
+ `Error loading blocked suggestion digests: ${error}`
+ );
+ }
+ }
+ });
+ }
+
+ /**
+ * Returns the SHA-1 digest of a string as a 40-character hex-encoded string.
+ *
+ * @param {string} string
+ * The string to convert to SHA-1
+ * @returns {string}
+ * The hex-encoded digest of the given string.
+ */
+ async #getDigest(string) {
+ let stringArray = new TextEncoder().encode(string);
+ let hashBuffer = await crypto.subtle.digest("SHA-1", stringArray);
+ let hashArray = new Uint8Array(hashBuffer);
+ return Array.from(hashArray, b => b.toString(16).padStart(2, "0")).join("");
+ }
+
+ get _test_readyPromise() {
+ return this.#taskQueue.emptyPromise;
+ }
+
+ get _test_digests() {
+ return this.#digests;
+ }
+
+ _test_getDigest(string) {
+ return this.#getDigest(string);
+ }
+
+ // Set of digests of the original URLs of blocked suggestions. A suggestion's
+ // "original URL" is its URL straight from the source with an unreplaced
+ // timestamp template. For details on the digests, see `#getDigest()`.
+ //
+ // The only reason we use URL digests is that suggestions currently do not
+ // have persistent IDs. We could use the URLs themselves but SHA-1 digests are
+ // only 40 chars long, so they save a little space. This is also consistent
+ // with how blocked tiles on the newtab page are stored, but they use MD5. We
+ // do *not* store digests for any security or obfuscation reason.
+ //
+ // This value is serialized as a JSON'ed array to the
+ // `browser.urlbar.quicksuggest.blockedDigests` pref.
+ #digests = new Set();
+
+ // Used to serialize access to blocked suggestions. This is only necessary
+ // because getting a suggestion's URL digest is async.
+ #taskQueue = null;
+
+ // Whether blocked digests are currently being updated.
+ #updatingDigests = false;
+}
diff --git a/browser/components/urlbar/private/ImpressionCaps.sys.mjs b/browser/components/urlbar/private/ImpressionCaps.sys.mjs
new file mode 100644
index 0000000000..2587c3ba25
--- /dev/null
+++ b/browser/components/urlbar/private/ImpressionCaps.sys.mjs
@@ -0,0 +1,561 @@
+/* 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 { BaseFeature } from "resource:///modules/urlbar/private/BaseFeature.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AsyncShutdown: "resource://gre/modules/AsyncShutdown.sys.mjs",
+ QuickSuggest: "resource:///modules/QuickSuggest.sys.mjs",
+ UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs",
+ clearInterval: "resource://gre/modules/Timer.sys.mjs",
+ setInterval: "resource://gre/modules/Timer.sys.mjs",
+});
+
+const IMPRESSION_COUNTERS_RESET_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
+
+// This object maps impression stats object keys to their corresponding keys in
+// the `extra` object of impression cap telemetry events. The main reason this
+// is necessary is because the keys of the `extra` object are limited to 15
+// characters in length, which some stats object keys exceed. It also forces us
+// to be deliberate about keys we add to the `extra` object, since the `extra`
+// object is limited to 10 keys.
+const TELEMETRY_IMPRESSION_CAP_EXTRA_KEYS = {
+ // stats object key -> `extra` telemetry event object key
+ intervalSeconds: "intervalSeconds",
+ startDateMs: "startDate",
+ count: "count",
+ maxCount: "maxCount",
+ impressionDateMs: "impressionDate",
+};
+
+/**
+ * Impression caps and stats for quick suggest suggestions.
+ */
+export class ImpressionCaps extends BaseFeature {
+ constructor() {
+ super();
+ lazy.UrlbarPrefs.addObserver(this);
+ }
+
+ get shouldEnable() {
+ return (
+ lazy.UrlbarPrefs.get("quickSuggestImpressionCapsSponsoredEnabled") ||
+ lazy.UrlbarPrefs.get("quickSuggestImpressionCapsNonSponsoredEnabled")
+ );
+ }
+
+ enable(enabled) {
+ if (enabled) {
+ this.#init();
+ } else {
+ this.#uninit();
+ }
+ }
+
+ /**
+ * Increments the user's impression stats counters for the given type of
+ * suggestion. This should be called only when a suggestion impression is
+ * recorded.
+ *
+ * @param {string} type
+ * The suggestion type, one of: "sponsored", "nonsponsored"
+ */
+ updateStats(type) {
+ this.logger.info("Starting impression stats update");
+ this.logger.debug(
+ JSON.stringify({
+ type,
+ currentStats: this.#stats,
+ impression_caps: lazy.QuickSuggest.jsBackend.config.impression_caps,
+ })
+ );
+
+ // Don't bother recording anything if caps are disabled.
+ let isSponsored = type == "sponsored";
+ if (
+ (isSponsored &&
+ !lazy.UrlbarPrefs.get("quickSuggestImpressionCapsSponsoredEnabled")) ||
+ (!isSponsored &&
+ !lazy.UrlbarPrefs.get("quickSuggestImpressionCapsNonSponsoredEnabled"))
+ ) {
+ this.logger.info("Impression caps disabled, skipping update");
+ return;
+ }
+
+ // Get the user's impression stats. Since stats are synced from caps, if the
+ // stats don't exist then the caps don't exist, and don't bother recording
+ // anything in that case.
+ let stats = this.#stats[type];
+ if (!stats) {
+ this.logger.info("Impression caps undefined, skipping update");
+ return;
+ }
+
+ // Increment counters.
+ for (let stat of stats) {
+ stat.count++;
+ stat.impressionDateMs = Date.now();
+
+ // Record a telemetry event for each newly hit cap.
+ if (stat.count == stat.maxCount) {
+ this.logger.info(`'${type}' impression cap hit`);
+ this.logger.debug(JSON.stringify({ type, hitStat: stat }));
+ this.#recordCapEvent({
+ stat,
+ eventType: "hit",
+ suggestionType: type,
+ });
+ }
+ }
+
+ // Save the stats.
+ this.#updatingStats = true;
+ try {
+ lazy.UrlbarPrefs.set(
+ "quicksuggest.impressionCaps.stats",
+ JSON.stringify(this.#stats)
+ );
+ } finally {
+ this.#updatingStats = false;
+ }
+
+ this.logger.info("Finished impression stats update");
+ this.logger.debug(JSON.stringify({ newStats: this.#stats }));
+ }
+
+ /**
+ * Returns a non-null value if an impression cap has been reached for the
+ * given suggestion type and null otherwise. This method can therefore be used
+ * to tell whether a cap has been reached for a given type. The actual return
+ * value an object describing the impression stats that caused the cap to be
+ * reached.
+ *
+ * @param {string} type
+ * The suggestion type, one of: "sponsored", "nonsponsored"
+ * @returns {object}
+ * An impression stats object or null.
+ */
+ getHitStats(type) {
+ this.#resetElapsedCounters();
+ let stats = this.#stats[type];
+ if (stats) {
+ let hitStats = stats.filter(s => s.maxCount <= s.count);
+ if (hitStats.length) {
+ return hitStats;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Called when a urlbar pref changes.
+ *
+ * @param {string} pref
+ * The name of the pref relative to `browser.urlbar`.
+ */
+ onPrefChanged(pref) {
+ switch (pref) {
+ case "quicksuggest.impressionCaps.stats":
+ if (!this.#updatingStats) {
+ this.logger.info(
+ "browser.urlbar.quicksuggest.impressionCaps.stats changed"
+ );
+ this.#loadStats();
+ }
+ break;
+ }
+ }
+
+ #init() {
+ this.#loadStats();
+
+ // Validate stats against any changes to the impression caps in the config.
+ this._onConfigSet = () => this.#validateStats();
+ lazy.QuickSuggest.jsBackend.emitter.on("config-set", this._onConfigSet);
+
+ // Periodically record impression counters reset telemetry.
+ this.#setCountersResetInterval();
+
+ // On shutdown, record any final impression counters reset telemetry.
+ this._shutdownBlocker = () => this.#resetElapsedCounters();
+ lazy.AsyncShutdown.profileChangeTeardown.addBlocker(
+ "QuickSuggest: Record impression counters reset telemetry",
+ this._shutdownBlocker
+ );
+ }
+
+ #uninit() {
+ lazy.QuickSuggest.jsBackend.emitter.off("config-set", this._onConfigSet);
+ this._onConfigSet = null;
+
+ lazy.clearInterval(this._impressionCountersResetInterval);
+ this._impressionCountersResetInterval = 0;
+
+ lazy.AsyncShutdown.profileChangeTeardown.removeBlocker(
+ this._shutdownBlocker
+ );
+ this._shutdownBlocker = null;
+ }
+
+ /**
+ * Loads and validates impression stats.
+ */
+ #loadStats() {
+ let json = lazy.UrlbarPrefs.get("quicksuggest.impressionCaps.stats");
+ if (!json) {
+ this.#stats = {};
+ } else {
+ try {
+ this.#stats = JSON.parse(
+ json,
+ // Infinity, which is the `intervalSeconds` for the lifetime cap, is
+ // stringified as `null` in the JSON, so convert it back to Infinity.
+ (key, value) =>
+ key == "intervalSeconds" && value === null ? Infinity : value
+ );
+ } catch (error) {}
+ }
+ this.#validateStats();
+ }
+
+ /**
+ * Validates impression stats, which includes two things:
+ *
+ * - Type checks stats and discards any that are invalid. We do this because
+ * stats are stored in prefs where anyone can modify them.
+ * - Syncs stats with impression caps so that there is one stats object
+ * corresponding to each impression cap. See the `#stats` comment for info.
+ */
+ #validateStats() {
+ let { impression_caps } = lazy.QuickSuggest.jsBackend.config;
+
+ this.logger.info("Validating impression stats");
+ this.logger.debug(
+ JSON.stringify({
+ impression_caps,
+ currentStats: this.#stats,
+ })
+ );
+
+ if (!this.#stats || typeof this.#stats != "object") {
+ this.#stats = {};
+ }
+
+ for (let [type, cap] of Object.entries(impression_caps || {})) {
+ // Build a map from interval seconds to max counts in the caps.
+ let maxCapCounts = (cap.custom || []).reduce(
+ (map, { interval_s, max_count }) => {
+ map.set(interval_s, max_count);
+ return map;
+ },
+ new Map()
+ );
+ if (typeof cap.lifetime == "number") {
+ maxCapCounts.set(Infinity, cap.lifetime);
+ }
+
+ let stats = this.#stats[type];
+ if (!Array.isArray(stats)) {
+ stats = [];
+ this.#stats[type] = stats;
+ }
+
+ // Validate existing stats:
+ //
+ // * Discard stats with invalid properties.
+ // * Collect and remove stats with intervals that aren't in the caps. This
+ // should only happen when caps are changed or removed.
+ // * For stats with intervals that are in the caps:
+ // * Keep track of the max `stat.count` across all stats so we can
+ // update the lifetime stat below.
+ // * Set `stat.maxCount` to the max count in the corresponding cap.
+ let orphanStats = [];
+ let maxCountInStats = 0;
+ for (let i = 0; i < stats.length; ) {
+ let stat = stats[i];
+ if (
+ typeof stat.intervalSeconds != "number" ||
+ typeof stat.startDateMs != "number" ||
+ typeof stat.count != "number" ||
+ typeof stat.maxCount != "number" ||
+ typeof stat.impressionDateMs != "number"
+ ) {
+ stats.splice(i, 1);
+ } else {
+ maxCountInStats = Math.max(maxCountInStats, stat.count);
+ let maxCount = maxCapCounts.get(stat.intervalSeconds);
+ if (maxCount === undefined) {
+ stats.splice(i, 1);
+ orphanStats.push(stat);
+ } else {
+ stat.maxCount = maxCount;
+ i++;
+ }
+ }
+ }
+
+ // Create stats for caps that don't already have corresponding stats.
+ for (let [intervalSeconds, maxCount] of maxCapCounts.entries()) {
+ if (!stats.some(s => s.intervalSeconds == intervalSeconds)) {
+ stats.push({
+ maxCount,
+ intervalSeconds,
+ startDateMs: Date.now(),
+ count: 0,
+ impressionDateMs: 0,
+ });
+ }
+ }
+
+ // Merge orphaned stats into other ones if possible. For each orphan, if
+ // its interval is no bigger than an existing stat's interval, then the
+ // orphan's count can contribute to the existing stat's count, so merge
+ // the two.
+ for (let orphan of orphanStats) {
+ for (let stat of stats) {
+ if (orphan.intervalSeconds <= stat.intervalSeconds) {
+ stat.count = Math.max(stat.count, orphan.count);
+ stat.startDateMs = Math.min(stat.startDateMs, orphan.startDateMs);
+ stat.impressionDateMs = Math.max(
+ stat.impressionDateMs,
+ orphan.impressionDateMs
+ );
+ }
+ }
+ }
+
+ // If the lifetime stat exists, make its count the max count found above.
+ // This is only necessary when the lifetime cap wasn't present before, but
+ // it doesn't hurt to always do it.
+ let lifetimeStat = stats.find(s => s.intervalSeconds == Infinity);
+ if (lifetimeStat) {
+ lifetimeStat.count = maxCountInStats;
+ }
+
+ // Sort the stats by interval ascending. This isn't necessary except that
+ // it guarantees an ordering for tests.
+ stats.sort((a, b) => a.intervalSeconds - b.intervalSeconds);
+ }
+
+ this.logger.debug(JSON.stringify({ newStats: this.#stats }));
+ }
+
+ /**
+ * Resets the counters of impression stats whose intervals have elapased.
+ */
+ #resetElapsedCounters() {
+ this.logger.info("Checking for elapsed impression cap intervals");
+ this.logger.debug(
+ JSON.stringify({
+ currentStats: this.#stats,
+ impression_caps: lazy.QuickSuggest.jsBackend.config.impression_caps,
+ })
+ );
+
+ let now = Date.now();
+ for (let [type, stats] of Object.entries(this.#stats)) {
+ for (let stat of stats) {
+ let elapsedMs = now - stat.startDateMs;
+ let intervalMs = 1000 * stat.intervalSeconds;
+ let elapsedIntervalCount = Math.floor(elapsedMs / intervalMs);
+ if (elapsedIntervalCount) {
+ // At least one interval period elapsed for the stat, so reset it. We
+ // may also need to record a telemetry event for the reset.
+ this.logger.info(
+ `Resetting impression counter for interval ${stat.intervalSeconds}s`
+ );
+ this.logger.debug(
+ JSON.stringify({ type, stat, elapsedMs, elapsedIntervalCount })
+ );
+
+ let newStartDateMs =
+ stat.startDateMs + elapsedIntervalCount * intervalMs;
+
+ // Compute the portion of `elapsedIntervalCount` that happened after
+ // startup. This will be the interval count we report in the telemetry
+ // event. By design we don't report intervals that elapsed while the
+ // app wasn't running. For example, if the user stopped using Firefox
+ // for a year, we don't want to report a year's worth of intervals.
+ //
+ // First, compute the count of intervals that elapsed before startup.
+ // This is the same arithmetic used above except here it's based on
+ // the startup date instead of `now`. Keep in mind that startup may be
+ // before the stat's start date. Then subtract that count from
+ // `elapsedIntervalCount` to get the portion after startup.
+ let startupDateMs = this._getStartupDateMs();
+ let elapsedIntervalCountBeforeStartup = Math.floor(
+ Math.max(0, startupDateMs - stat.startDateMs) / intervalMs
+ );
+ let elapsedIntervalCountAfterStartup =
+ elapsedIntervalCount - elapsedIntervalCountBeforeStartup;
+
+ if (elapsedIntervalCountAfterStartup) {
+ this.#recordCapEvent({
+ eventType: "reset",
+ suggestionType: type,
+ eventDateMs: newStartDateMs,
+ eventCount: elapsedIntervalCountAfterStartup,
+ stat: {
+ ...stat,
+ startDateMs:
+ stat.startDateMs +
+ elapsedIntervalCountBeforeStartup * intervalMs,
+ },
+ });
+ }
+
+ // Reset the stat.
+ stat.startDateMs = newStartDateMs;
+ stat.count = 0;
+ }
+ }
+ }
+
+ this.logger.debug(JSON.stringify({ newStats: this.#stats }));
+ }
+
+ /**
+ * Records an impression cap telemetry event.
+ *
+ * @param {object} options
+ * Options object
+ * @param {"hit" | "reset"} options.eventType
+ * One of: "hit", "reset"
+ * @param {string} options.suggestionType
+ * One of: "sponsored", "nonsponsored"
+ * @param {object} options.stat
+ * The stats object whose max count was hit or whose counter was reset.
+ * @param {number} options.eventCount
+ * The number of intervals that elapsed since the last event.
+ * @param {number} options.eventDateMs
+ * The `eventDate` that should be recorded in the event's `extra` object.
+ * We include this in `extra` even though events are timestamped because
+ * "reset" events are batched during periods where the user doesn't perform
+ * any searches and therefore impression counters are not reset.
+ */
+ #recordCapEvent({
+ eventType,
+ suggestionType,
+ stat,
+ eventCount = 1,
+ eventDateMs = Date.now(),
+ }) {
+ // All `extra` object values must be strings.
+ let extra = {
+ type: suggestionType,
+ eventDate: String(eventDateMs),
+ eventCount: String(eventCount),
+ };
+ for (let [statKey, value] of Object.entries(stat)) {
+ let extraKey = TELEMETRY_IMPRESSION_CAP_EXTRA_KEYS[statKey];
+ if (!extraKey) {
+ throw new Error("Unrecognized stats object key: " + statKey);
+ }
+ extra[extraKey] = String(value);
+ }
+ Services.telemetry.recordEvent(
+ lazy.QuickSuggest.TELEMETRY_EVENT_CATEGORY,
+ "impression_cap",
+ eventType,
+ "",
+ extra
+ );
+ }
+
+ /**
+ * Creates a repeating timer that resets impression counters and records
+ * related telemetry. Since counters are also reset when suggestions are
+ * triggered, the only point of this is to make sure we record reset telemetry
+ * events in a timely manner during periods when suggestions aren't triggered.
+ *
+ * @param {number} ms
+ * The number of milliseconds in the interval.
+ */
+ #setCountersResetInterval(ms = IMPRESSION_COUNTERS_RESET_INTERVAL_MS) {
+ if (this._impressionCountersResetInterval) {
+ lazy.clearInterval(this._impressionCountersResetInterval);
+ }
+ this._impressionCountersResetInterval = lazy.setInterval(
+ () => this.#resetElapsedCounters(),
+ ms
+ );
+ }
+
+ /**
+ * Gets the timestamp of app startup in ms since Unix epoch. This is only
+ * defined as its own method so tests can override it to simulate arbitrary
+ * startups.
+ *
+ * @returns {number}
+ * Startup timestamp in ms since Unix epoch.
+ */
+ _getStartupDateMs() {
+ return Services.startup.getStartupInfo().process.getTime();
+ }
+
+ get _test_stats() {
+ return this.#stats;
+ }
+
+ _test_reloadStats() {
+ this.#stats = null;
+ this.#loadStats();
+ }
+
+ _test_resetElapsedCounters() {
+ this.#resetElapsedCounters();
+ }
+
+ _test_setCountersResetInterval(ms) {
+ this.#setCountersResetInterval(ms);
+ }
+
+ // An object that keeps track of impression stats per sponsored and
+ // non-sponsored suggestion types. It looks like this:
+ //
+ // { sponsored: statsArray, nonsponsored: statsArray }
+ //
+ // The `statsArray` values are arrays of stats objects, one per impression
+ // cap, which look like this:
+ //
+ // { intervalSeconds, startDateMs, count, maxCount, impressionDateMs }
+ //
+ // {number} intervalSeconds
+ // The number of seconds in the corresponding cap's time interval.
+ // {number} startDateMs
+ // The timestamp at which the current interval period started and the
+ // object's `count` was reset to zero. This is a value returned from
+ // `Date.now()`. When the current date/time advances past `startDateMs +
+ // 1000 * intervalSeconds`, a new interval period will start and `count`
+ // will be reset to zero.
+ // {number} count
+ // The number of impressions during the current interval period.
+ // {number} maxCount
+ // The maximum number of impressions allowed during an interval period.
+ // This value is the same as the `max_count` value in the corresponding
+ // cap. It's stored in the stats object for convenience.
+ // {number} impressionDateMs
+ // The timestamp of the most recent impression, i.e., when `count` was
+ // last incremented.
+ //
+ // There are two types of impression caps: interval and lifetime. Interval
+ // caps are periodically reset, and lifetime caps are never reset. For stats
+ // objects corresponding to interval caps, `intervalSeconds` will be the
+ // `interval_s` value of the cap. For stats objects corresponding to lifetime
+ // caps, `intervalSeconds` will be `Infinity`.
+ //
+ // `#stats` is kept in sync with impression caps, and there is a one-to-one
+ // relationship between stats objects and caps. A stats object's corresponding
+ // cap is the one with the same suggestion type (sponsored or non-sponsored)
+ // and interval. See `#validateStats()` for more.
+ //
+ // Impression caps are stored in the remote settings config. See
+ // `SuggestBackendJs.config.impression_caps`.
+ #stats = {};
+
+ // Whether impression stats are currently being updated.
+ #updatingStats = false;
+}
diff --git a/browser/components/urlbar/private/MDNSuggestions.sys.mjs b/browser/components/urlbar/private/MDNSuggestions.sys.mjs
new file mode 100644
index 0000000000..7547b0adff
--- /dev/null
+++ b/browser/components/urlbar/private/MDNSuggestions.sys.mjs
@@ -0,0 +1,198 @@
+/* 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 { BaseFeature } from "resource:///modules/urlbar/private/BaseFeature.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ QuickSuggest: "resource:///modules/QuickSuggest.sys.mjs",
+ SuggestionsMap: "resource:///modules/urlbar/private/SuggestBackendJs.sys.mjs",
+ UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs",
+ UrlbarResult: "resource:///modules/UrlbarResult.sys.mjs",
+ UrlbarUtils: "resource:///modules/UrlbarUtils.sys.mjs",
+});
+
+const RESULT_MENU_COMMAND = {
+ HELP: "help",
+ NOT_INTERESTED: "not_interested",
+ NOT_RELEVANT: "not_relevant",
+};
+
+/**
+ * A feature that supports MDN suggestions.
+ */
+export class MDNSuggestions extends BaseFeature {
+ get shouldEnable() {
+ return (
+ lazy.UrlbarPrefs.get("mdn.featureGate") &&
+ lazy.UrlbarPrefs.get("suggest.mdn") &&
+ lazy.UrlbarPrefs.get("suggest.quicksuggest.nonsponsored")
+ );
+ }
+
+ get enablingPreferences() {
+ return [
+ "mdn.featureGate",
+ "suggest.mdn",
+ "suggest.quicksuggest.nonsponsored",
+ ];
+ }
+
+ get merinoProvider() {
+ return "mdn";
+ }
+
+ get rustSuggestionTypes() {
+ return ["Mdn"];
+ }
+
+ enable(enabled) {
+ if (enabled) {
+ lazy.QuickSuggest.jsBackend.register(this);
+ } else {
+ lazy.QuickSuggest.jsBackend.unregister(this);
+ this.#suggestionsMap?.clear();
+ }
+ }
+
+ queryRemoteSettings(searchString) {
+ const suggestions = this.#suggestionsMap?.get(searchString);
+ return suggestions
+ ? suggestions.map(suggestion => ({ ...suggestion }))
+ : [];
+ }
+
+ async onRemoteSettingsSync(rs) {
+ const records = await rs.get({ filters: { type: "mdn-suggestions" } });
+ if (!this.isEnabled) {
+ return;
+ }
+
+ const suggestionsMap = new lazy.SuggestionsMap();
+
+ for (const record of records) {
+ const { buffer } = await rs.attachments.download(record);
+ if (!this.isEnabled) {
+ return;
+ }
+
+ const results = JSON.parse(new TextDecoder("utf-8").decode(buffer));
+ await suggestionsMap.add(results, {
+ mapKeyword:
+ lazy.SuggestionsMap.MAP_KEYWORD_PREFIXES_STARTING_AT_FIRST_WORD,
+ });
+ if (!this.isEnabled) {
+ return;
+ }
+ }
+
+ this.#suggestionsMap = suggestionsMap;
+ }
+
+ async makeResult(queryContext, suggestion, searchString) {
+ if (!this.isEnabled) {
+ // The feature is disabled on the client, but Merino may still return
+ // mdn suggestions anyway, and we filter them out here.
+ return null;
+ }
+
+ // Set `is_top_pick` on the suggestion to tell the provider to set
+ // best-match related properties on the result.
+ suggestion.is_top_pick = true;
+
+ const url = new URL(suggestion.url);
+ url.searchParams.set("utm_medium", "firefox-desktop");
+ url.searchParams.set("utm_source", "firefox-suggest");
+ url.searchParams.set(
+ "utm_campaign",
+ "firefox-mdn-web-docs-suggestion-experiment"
+ );
+ url.searchParams.set("utm_content", "treatment");
+
+ const payload = {
+ icon: "chrome://global/skin/icons/mdn.svg",
+ url: url.href,
+ originalUrl: suggestion.url,
+ title: [suggestion.title, lazy.UrlbarUtils.HIGHLIGHT.TYPED],
+ description: suggestion.description,
+ shouldShowUrl: true,
+ bottomTextL10n: { id: "firefox-suggest-mdn-bottom-text" },
+ };
+
+ return Object.assign(
+ new lazy.UrlbarResult(
+ lazy.UrlbarUtils.RESULT_TYPE.URL,
+ lazy.UrlbarUtils.RESULT_SOURCE.OTHER_NETWORK,
+ ...lazy.UrlbarResult.payloadAndSimpleHighlights(
+ queryContext.tokens,
+ payload
+ )
+ ),
+ { showFeedbackMenu: true }
+ );
+ }
+
+ getResultCommands(result) {
+ return [
+ {
+ l10n: {
+ id: "firefox-suggest-command-dont-show-mdn",
+ },
+ children: [
+ {
+ name: RESULT_MENU_COMMAND.NOT_RELEVANT,
+ l10n: {
+ id: "firefox-suggest-command-not-relevant",
+ },
+ },
+ {
+ name: RESULT_MENU_COMMAND.NOT_INTERESTED,
+ l10n: {
+ id: "firefox-suggest-command-not-interested",
+ },
+ },
+ ],
+ },
+ { name: "separator" },
+ {
+ name: RESULT_MENU_COMMAND.HELP,
+ l10n: {
+ id: "urlbar-result-menu-learn-more-about-firefox-suggest",
+ },
+ },
+ ];
+ }
+
+ handleCommand(view, result, selType) {
+ switch (selType) {
+ case RESULT_MENU_COMMAND.HELP:
+ // "help" is handled by UrlbarInput, no need to do anything here.
+ break;
+ // selType == "dismiss" when the user presses the dismiss key shortcut.
+ case "dismiss":
+ case RESULT_MENU_COMMAND.NOT_RELEVANT:
+ // MDNSuggestions adds the UTM parameters to the original URL and
+ // returns it as payload.url in the result. However, as
+ // UrlbarProviderQuickSuggest filters suggestions with original URL of
+ // provided suggestions, need to use the original URL when adding to the
+ // block list.
+ lazy.QuickSuggest.blockedSuggestions.add(result.payload.originalUrl);
+ result.acknowledgeDismissalL10n = {
+ id: "firefox-suggest-dismissal-acknowledgment-one-mdn",
+ };
+ view.controller.removeResult(result);
+ break;
+ case RESULT_MENU_COMMAND.NOT_INTERESTED:
+ lazy.UrlbarPrefs.set("suggest.mdn", false);
+ result.acknowledgeDismissalL10n = {
+ id: "firefox-suggest-dismissal-acknowledgment-all-mdn",
+ };
+ view.controller.removeResult(result);
+ break;
+ }
+ }
+
+ #suggestionsMap = null;
+}
diff --git a/browser/components/urlbar/private/PocketSuggestions.sys.mjs b/browser/components/urlbar/private/PocketSuggestions.sys.mjs
new file mode 100644
index 0000000000..f15b210606
--- /dev/null
+++ b/browser/components/urlbar/private/PocketSuggestions.sys.mjs
@@ -0,0 +1,314 @@
+/* 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 { BaseFeature } from "resource:///modules/urlbar/private/BaseFeature.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ QuickSuggest: "resource:///modules/QuickSuggest.sys.mjs",
+ SuggestionsMap: "resource:///modules/urlbar/private/SuggestBackendJs.sys.mjs",
+ UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs",
+ UrlbarResult: "resource:///modules/UrlbarResult.sys.mjs",
+ UrlbarUtils: "resource:///modules/UrlbarUtils.sys.mjs",
+});
+
+const RESULT_MENU_COMMAND = {
+ HELP: "help",
+ NOT_INTERESTED: "not_interested",
+ NOT_RELEVANT: "not_relevant",
+ SHOW_LESS_FREQUENTLY: "show_less_frequently",
+};
+
+/**
+ * A feature that manages Pocket suggestions in remote settings.
+ */
+export class PocketSuggestions extends BaseFeature {
+ constructor() {
+ super();
+ this.#lowConfidenceSuggestionsMap = new lazy.SuggestionsMap();
+ this.#highConfidenceSuggestionsMap = new lazy.SuggestionsMap();
+ }
+
+ get shouldEnable() {
+ return (
+ lazy.UrlbarPrefs.get("pocketFeatureGate") &&
+ lazy.UrlbarPrefs.get("suggest.pocket") &&
+ lazy.UrlbarPrefs.get("suggest.quicksuggest.nonsponsored")
+ );
+ }
+
+ get enablingPreferences() {
+ return ["suggest.pocket", "suggest.quicksuggest.nonsponsored"];
+ }
+
+ get merinoProvider() {
+ return "pocket";
+ }
+
+ get rustSuggestionTypes() {
+ return ["Pocket"];
+ }
+
+ get showLessFrequentlyCount() {
+ let count = lazy.UrlbarPrefs.get("pocket.showLessFrequentlyCount") || 0;
+ return Math.max(count, 0);
+ }
+
+ get canShowLessFrequently() {
+ let cap =
+ lazy.UrlbarPrefs.get("pocketShowLessFrequentlyCap") ||
+ lazy.QuickSuggest.backend.config?.showLessFrequentlyCap ||
+ 0;
+ return !cap || this.showLessFrequentlyCount < cap;
+ }
+
+ enable(enabled) {
+ if (enabled) {
+ lazy.QuickSuggest.jsBackend.register(this);
+ } else {
+ lazy.QuickSuggest.jsBackend.unregister(this);
+ this.#lowConfidenceSuggestionsMap.clear();
+ this.#highConfidenceSuggestionsMap.clear();
+ }
+ }
+
+ async queryRemoteSettings(searchString) {
+ // If the search string matches high confidence suggestions, they should be
+ // treated as top picks. Otherwise try to match low confidence suggestions.
+ let is_top_pick = false;
+ let suggestions = this.#highConfidenceSuggestionsMap.get(searchString);
+ if (suggestions.length) {
+ is_top_pick = true;
+ } else {
+ suggestions = this.#lowConfidenceSuggestionsMap.get(searchString);
+ }
+
+ let lowerSearchString = searchString.toLocaleLowerCase();
+ return suggestions.map(suggestion => {
+ // Add `full_keyword` to each matched suggestion. It should be the longest
+ // keyword that starts with the user's search string.
+ let full_keyword = lowerSearchString;
+ let keywords = is_top_pick
+ ? suggestion.highConfidenceKeywords
+ : suggestion.lowConfidenceKeywords;
+ for (let keyword of keywords) {
+ if (
+ keyword.startsWith(lowerSearchString) &&
+ full_keyword.length < keyword.length
+ ) {
+ full_keyword = keyword;
+ }
+ }
+ return { ...suggestion, is_top_pick, full_keyword };
+ });
+ }
+
+ async onRemoteSettingsSync(rs) {
+ let records = await rs.get({ filters: { type: "pocket-suggestions" } });
+ if (!this.isEnabled) {
+ return;
+ }
+
+ let lowMap = new lazy.SuggestionsMap();
+ let highMap = new lazy.SuggestionsMap();
+
+ this.logger.debug(`Got ${records.length} records`);
+ for (let record of records) {
+ let { buffer } = await rs.attachments.download(record);
+ if (!this.isEnabled) {
+ return;
+ }
+
+ let suggestions = JSON.parse(new TextDecoder("utf-8").decode(buffer));
+ this.logger.debug(`Adding ${suggestions.length} suggestions`);
+
+ await lowMap.add(suggestions, {
+ keywordsProperty: "lowConfidenceKeywords",
+ mapKeyword:
+ lazy.SuggestionsMap.MAP_KEYWORD_PREFIXES_STARTING_AT_FIRST_WORD,
+ });
+ if (!this.isEnabled) {
+ return;
+ }
+
+ await highMap.add(suggestions, {
+ keywordsProperty: "highConfidenceKeywords",
+ });
+ if (!this.isEnabled) {
+ return;
+ }
+ }
+
+ this.#lowConfidenceSuggestionsMap = lowMap;
+ this.#highConfidenceSuggestionsMap = highMap;
+ }
+
+ makeResult(queryContext, suggestion, searchString) {
+ if (!this.isEnabled) {
+ // The feature is disabled on the client, but Merino may still return
+ // suggestions anyway, and we filter them out here.
+ return null;
+ }
+
+ // If the user hasn't clicked the "Show less frequently" command, the
+ // suggestion can be shown. Otherwise, the suggestion can be shown if the
+ // user typed more than one word with at least `showLessFrequentlyCount`
+ // characters after the first word, including spaces.
+ if (this.showLessFrequentlyCount) {
+ let spaceIndex = searchString.search(/\s/);
+ if (
+ spaceIndex < 0 ||
+ searchString.length - spaceIndex < this.showLessFrequentlyCount
+ ) {
+ return null;
+ }
+ }
+
+ if (suggestion.source == "rust") {
+ suggestion.is_top_pick = suggestion.isTopPick;
+ delete suggestion.isTopPick;
+
+ // The Rust component doesn't implement these properties. For now we use
+ // dummy values. See issue #5878 in application-services.
+ suggestion.description = suggestion.title;
+ suggestion.full_keyword = searchString;
+ }
+
+ let url = new URL(suggestion.url);
+ url.searchParams.set("utm_medium", "firefox-desktop");
+ url.searchParams.set("utm_source", "firefox-suggest");
+ url.searchParams.set(
+ "utm_campaign",
+ "pocket-collections-in-the-address-bar"
+ );
+ url.searchParams.set("utm_content", "treatment");
+
+ return Object.assign(
+ new lazy.UrlbarResult(
+ lazy.UrlbarUtils.RESULT_TYPE.URL,
+ lazy.UrlbarUtils.RESULT_SOURCE.OTHER_NETWORK,
+ ...lazy.UrlbarResult.payloadAndSimpleHighlights(queryContext.tokens, {
+ url: url.href,
+ originalUrl: suggestion.url,
+ title: [suggestion.title, lazy.UrlbarUtils.HIGHLIGHT.TYPED],
+ description: suggestion.is_top_pick ? suggestion.description : "",
+ // Use the favicon for non-best matches so the icon exactly matches
+ // the Pocket favicon in the user's history and tabs.
+ icon: suggestion.is_top_pick
+ ? "chrome://global/skin/icons/pocket.svg"
+ : "chrome://global/skin/icons/pocket-favicon.ico",
+ shouldShowUrl: true,
+ bottomTextL10n: {
+ id: "firefox-suggest-pocket-bottom-text",
+ args: {
+ keywordSubstringTyped: searchString,
+ keywordSubstringNotTyped: suggestion.full_keyword.substring(
+ searchString.length
+ ),
+ },
+ },
+ helpUrl: lazy.QuickSuggest.HELP_URL,
+ })
+ ),
+ {
+ isRichSuggestion: true,
+ richSuggestionIconSize: suggestion.is_top_pick ? 24 : 16,
+ showFeedbackMenu: true,
+ }
+ );
+ }
+
+ handleCommand(view, result, selType) {
+ switch (selType) {
+ case RESULT_MENU_COMMAND.HELP:
+ // "help" is handled by UrlbarInput, no need to do anything here.
+ break;
+ // selType == "dismiss" when the user presses the dismiss key shortcut.
+ case "dismiss":
+ case RESULT_MENU_COMMAND.NOT_RELEVANT:
+ // PocketSuggestions adds the UTM parameters to the original URL and
+ // returns it as payload.url in the result. However, as
+ // UrlbarProviderQuickSuggest filters suggestions with original URL of
+ // provided suggestions, need to use the original URL when adding to the
+ // block list.
+ lazy.QuickSuggest.blockedSuggestions.add(result.payload.originalUrl);
+ result.acknowledgeDismissalL10n = {
+ id: "firefox-suggest-dismissal-acknowledgment-one",
+ };
+ view.controller.removeResult(result);
+ break;
+ case RESULT_MENU_COMMAND.NOT_INTERESTED:
+ lazy.UrlbarPrefs.set("suggest.pocket", false);
+ result.acknowledgeDismissalL10n = {
+ id: "firefox-suggest-dismissal-acknowledgment-all",
+ };
+ view.controller.removeResult(result);
+ break;
+ case RESULT_MENU_COMMAND.SHOW_LESS_FREQUENTLY:
+ view.acknowledgeFeedback(result);
+ this.incrementShowLessFrequentlyCount();
+ if (!this.canShowLessFrequently) {
+ view.invalidateResultMenuCommands();
+ }
+ break;
+ }
+ }
+
+ getResultCommands(result) {
+ let commands = [];
+
+ if (!result.isBestMatch && this.canShowLessFrequently) {
+ commands.push({
+ name: RESULT_MENU_COMMAND.SHOW_LESS_FREQUENTLY,
+ l10n: {
+ id: "firefox-suggest-command-show-less-frequently",
+ },
+ });
+ }
+
+ commands.push(
+ {
+ l10n: {
+ id: "firefox-suggest-command-dont-show-this",
+ },
+ children: [
+ {
+ name: RESULT_MENU_COMMAND.NOT_RELEVANT,
+ l10n: {
+ id: "firefox-suggest-command-not-relevant",
+ },
+ },
+ {
+ name: RESULT_MENU_COMMAND.NOT_INTERESTED,
+ l10n: {
+ id: "firefox-suggest-command-not-interested",
+ },
+ },
+ ],
+ },
+ { name: "separator" },
+ {
+ name: RESULT_MENU_COMMAND.HELP,
+ l10n: {
+ id: "urlbar-result-menu-learn-more-about-firefox-suggest",
+ },
+ }
+ );
+
+ return commands;
+ }
+
+ incrementShowLessFrequentlyCount() {
+ if (this.canShowLessFrequently) {
+ lazy.UrlbarPrefs.set(
+ "pocket.showLessFrequentlyCount",
+ this.showLessFrequentlyCount + 1
+ );
+ }
+ }
+
+ #lowConfidenceSuggestionsMap;
+ #highConfidenceSuggestionsMap;
+}
diff --git a/browser/components/urlbar/private/SuggestBackendJs.sys.mjs b/browser/components/urlbar/private/SuggestBackendJs.sys.mjs
new file mode 100644
index 0000000000..4a91e41b59
--- /dev/null
+++ b/browser/components/urlbar/private/SuggestBackendJs.sys.mjs
@@ -0,0 +1,443 @@
+/* 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 { BaseFeature } from "resource:///modules/urlbar/private/BaseFeature.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ EventEmitter: "resource://gre/modules/EventEmitter.sys.mjs",
+ RemoteSettings: "resource://services-settings/remote-settings.sys.mjs",
+ UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs",
+ UrlbarUtils: "resource:///modules/UrlbarUtils.sys.mjs",
+});
+
+const RS_COLLECTION = "quicksuggest";
+
+// Entries are added to `SuggestionsMap` map in chunks, and each chunk will add
+// at most this many entries.
+const SUGGESTIONS_MAP_CHUNK_SIZE = 1000;
+
+const TELEMETRY_LATENCY = "FX_URLBAR_QUICK_SUGGEST_REMOTE_SETTINGS_LATENCY_MS";
+
+// See `SuggestionsMap.MAP_KEYWORD_PREFIXES_STARTING_AT_FIRST_WORD`. When a full
+// keyword starts with one of the prefixes in this list, the user must type the
+// entire prefix to start triggering matches based on that full keyword, instead
+// of only the first word.
+const KEYWORD_PREFIXES_TO_TREAT_AS_SINGLE_WORDS = ["how to"];
+
+/**
+ * The Suggest JS backend. Not used when the Rust backend is enabled.
+ */
+export class SuggestBackendJs extends BaseFeature {
+ constructor(...args) {
+ super(...args);
+ this.#emitter = new lazy.EventEmitter();
+ }
+
+ get shouldEnable() {
+ return !lazy.UrlbarPrefs.get("quickSuggestRustEnabled");
+ }
+
+ /**
+ * @returns {RemoteSettings}
+ * The underlying `RemoteSettings` client object.
+ */
+ get rs() {
+ return this.#rs;
+ }
+
+ /**
+ * @returns {EventEmitter}
+ * The client will emit events on this object.
+ */
+ get emitter() {
+ return this.#emitter;
+ }
+
+ /**
+ * @returns {object}
+ * Global quick suggest configuration stored in remote settings. When the
+ * config changes the `emitter` property will emit a "config-set" event. The
+ * config is an object that looks like this:
+ *
+ * {
+ * impression_caps: {
+ * nonsponsored: {
+ * lifetime,
+ * custom: [
+ * { interval_s, max_count },
+ * ],
+ * },
+ * sponsored: {
+ * lifetime,
+ * custom: [
+ * { interval_s, max_count },
+ * ],
+ * },
+ * },
+ * show_less_frequently_cap,
+ * }
+ */
+ get config() {
+ return this.#config;
+ }
+
+ /**
+ * @returns {Array}
+ * Array of `BasicFeature` instances.
+ */
+ get features() {
+ return [...this.#features];
+ }
+
+ enable(enabled) {
+ if (!enabled) {
+ this.#enableSettings(false);
+ } else if (this.#features.size) {
+ this.#enableSettings(true);
+ this.#syncAll();
+ }
+ }
+
+ /**
+ * Registers a quick suggest feature that uses remote settings.
+ *
+ * @param {BaseFeature} feature
+ * An instance of a `BaseFeature` subclass. See `BaseFeature` for methods
+ * that the subclass must implement.
+ */
+ register(feature) {
+ this.logger.debug("Registering feature: " + feature.name);
+ this.#features.add(feature);
+ if (this.isEnabled) {
+ if (this.#features.size == 1) {
+ this.#enableSettings(true);
+ }
+ this.#syncFeature(feature);
+ }
+ }
+
+ /**
+ * Unregisters a quick suggest feature that uses remote settings.
+ *
+ * @param {BaseFeature} feature
+ * An instance of a `BaseFeature` subclass.
+ */
+ unregister(feature) {
+ this.logger.debug("Unregistering feature: " + feature.name);
+ this.#features.delete(feature);
+ if (!this.#features.size) {
+ this.#enableSettings(false);
+ }
+ }
+
+ /**
+ * Queries remote settings suggestions from all registered features.
+ *
+ * @param {string} searchString
+ * The search string.
+ * @returns {Array}
+ * The remote settings suggestions. If there are no matches, an empty array
+ * is returned.
+ */
+ async query(searchString) {
+ let suggestions;
+ let stopwatchInstance = {};
+ TelemetryStopwatch.start(TELEMETRY_LATENCY, stopwatchInstance);
+ try {
+ suggestions = await this.#queryHelper(searchString);
+ TelemetryStopwatch.finish(TELEMETRY_LATENCY, stopwatchInstance);
+ } catch (error) {
+ TelemetryStopwatch.cancel(TELEMETRY_LATENCY, stopwatchInstance);
+ this.logger.error("Query error: " + error);
+ }
+
+ return suggestions || [];
+ }
+
+ async #queryHelper(searchString) {
+ this.logger.info("Handling query: " + JSON.stringify(searchString));
+
+ let results = await Promise.all(
+ [...this.#features].map(async feature => {
+ let suggestions = await feature.queryRemoteSettings(searchString);
+ return [feature, suggestions ?? []];
+ })
+ );
+
+ let allSuggestions = [];
+ for (let [feature, suggestions] of results) {
+ for (let suggestion of suggestions) {
+ // Features typically return suggestion objects straight from their
+ // suggestion maps. We don't want consumers to modify those objects
+ // since they are the source of truth (tests especially tend to do
+ // this), so return copies to consumers.
+ allSuggestions.push({
+ ...suggestion,
+ source: "remote-settings",
+ provider: feature.name,
+ });
+ }
+ }
+
+ return allSuggestions;
+ }
+
+ async #enableSettings(enabled) {
+ if (enabled && !this.#rs) {
+ this.logger.debug("Creating RemoteSettings client");
+ this.#onSettingsSync = event => this.#syncAll({ event });
+ this.#rs = lazy.RemoteSettings(RS_COLLECTION);
+ this.#rs.on("sync", this.#onSettingsSync);
+ await this.#syncConfig();
+ } else if (!enabled && this.#rs) {
+ this.logger.debug("Destroying RemoteSettings client");
+ this.#rs.off("sync", this.#onSettingsSync);
+ this.#rs = null;
+ this.#onSettingsSync = null;
+ }
+ }
+
+ async #syncConfig() {
+ this.logger.debug("Syncing config");
+ let rs = this.#rs;
+
+ let configArray = await rs.get({ filters: { type: "configuration" } });
+ if (rs != this.#rs) {
+ return;
+ }
+
+ this.logger.debug("Got config array: " + JSON.stringify(configArray));
+ this.#setConfig(configArray?.[0]?.configuration || {});
+ }
+
+ async #syncFeature(feature) {
+ this.logger.debug("Syncing feature: " + feature.name);
+ await feature.onRemoteSettingsSync(this.#rs);
+ }
+
+ async #syncAll({ event = null } = {}) {
+ this.logger.debug("Syncing all");
+ let rs = this.#rs;
+
+ // Remove local files of deleted records
+ if (event?.data?.deleted) {
+ await Promise.all(
+ event.data.deleted
+ .filter(d => d.attachment)
+ .map(entry =>
+ Promise.all([
+ this.#rs.attachments.deleteDownloaded(entry), // type: data
+ this.#rs.attachments.deleteFromDisk(entry), // type: icon
+ ])
+ )
+ );
+ if (rs != this.#rs) {
+ return;
+ }
+ }
+
+ let promises = [this.#syncConfig()];
+ for (let feature of this.#features) {
+ promises.push(this.#syncFeature(feature));
+ }
+ await Promise.all(promises);
+ }
+
+ /**
+ * Sets the quick suggest config and emits a "config-set" event.
+ *
+ * @param {object} config
+ * The config object.
+ */
+ #setConfig(config) {
+ config = lazy.UrlbarUtils.copySnakeKeysToCamel(config ?? {});
+ this.logger.debug("Setting config: " + JSON.stringify(config));
+ this.#config = config;
+ this.#emitter.emit("config-set");
+ }
+
+ async _test_syncAll() {
+ if (this.#rs) {
+ // `RemoteSettingsClient` won't start another import if it's already
+ // importing. Wait for it to finish before starting the new one.
+ await this.#rs._importingPromise;
+ await this.#syncAll();
+ }
+ }
+
+ // The `RemoteSettings` client.
+ #rs = null;
+
+ // Registered `BaseFeature` instances.
+ #features = new Set();
+
+ // Configuration data synced from remote settings. See the `config` getter.
+ #config = {};
+
+ #emitter = null;
+ #logger = null;
+ #onSettingsSync = null;
+}
+
+/**
+ * A wrapper around `Map` that handles quick suggest suggestions from remote
+ * settings. It maps keywords to suggestions. It has two benefits over `Map`:
+ *
+ * - The main benefit is that map entries are added in batches on idle to avoid
+ * blocking the main thread for too long, since there can be many suggestions
+ * and keywords.
+ * - A secondary benefit is that the interface is tailored to quick suggest
+ * suggestions, which have a `keywords` property.
+ */
+export class SuggestionsMap {
+ /**
+ * Returns the list of suggestions for a keyword.
+ *
+ * @param {string} keyword
+ * The keyword.
+ * @returns {Array}
+ * The array of suggestions for the keyword. If the keyword isn't in the
+ * map, the array will be empty.
+ */
+ get(keyword) {
+ let object = this.#suggestionsByKeyword.get(keyword.toLocaleLowerCase());
+ if (!object) {
+ return [];
+ }
+ return Array.isArray(object) ? object : [object];
+ }
+
+ /**
+ * Adds a list of suggestion objects to the results map. Each suggestion must
+ * have a property whose value is an array of keyword strings. The
+ * suggestion's keywords will be taken from this array either exactly as they
+ * are specified or by generating new keywords from them; see `mapKeyword`.
+ *
+ * @param {Array} suggestions
+ * Array of suggestion objects.
+ * @param {object} options
+ * Options object.
+ * @param {string} options.keywordsProperty
+ * The name of the keywords property in each suggestion.
+ * @param {Function} options.mapKeyword
+ * If null, the keywords for each suggestion will be taken from the keywords
+ * array exactly as they are specified. Otherwise, this function will be
+ * called for each string in the array, and it should return an array of
+ * strings. The suggestion's final list of keywords will be the union of all
+ * strings returned by this function. See also the `MAP_KEYWORD_*` consts.
+ */
+ async add(
+ suggestions,
+ { keywordsProperty = "keywords", mapKeyword = null } = {}
+ ) {
+ // There can be many suggestions, and each suggestion can have many
+ // keywords. To avoid blocking the main thread for too long, update the map
+ // in chunks, and to avoid blocking the UI and other higher priority work,
+ // do each chunk only when the main thread is idle. During each chunk, we'll
+ // add at most `chunkSize` entries to the map.
+ let suggestionIndex = 0;
+ let keywordIndex = 0;
+
+ // Keep adding chunks until all suggestions have been fully added.
+ while (suggestionIndex < suggestions.length) {
+ await new Promise(resolve => {
+ Services.tm.idleDispatchToMainThread(() => {
+ // Keep updating the map until the current chunk is done.
+ let indexInChunk = 0;
+ while (
+ indexInChunk < SuggestionsMap.chunkSize &&
+ suggestionIndex < suggestions.length
+ ) {
+ let suggestion = suggestions[suggestionIndex];
+ let keywords = suggestion[keywordsProperty];
+ if (keywordIndex == keywords.length) {
+ // We've added entries for all keywords of the current suggestion.
+ // Move on to the next suggestion.
+ suggestionIndex++;
+ keywordIndex = 0;
+ continue;
+ }
+
+ // As a convenience, allow `mapKeyword` to return a string even
+ // though the JSDoc says an array must be returned.
+ let originalKeyword = keywords[keywordIndex];
+ let mappedKeywords =
+ mapKeyword?.(originalKeyword) ?? originalKeyword;
+ if (typeof mappedKeywords == "string") {
+ mappedKeywords = [mappedKeywords];
+ }
+
+ for (let keyword of mappedKeywords) {
+ // If the keyword's only suggestion is `suggestion`, store it
+ // directly as the value. Otherwise store an array of unique
+ // suggestions. See the `#suggestionsByKeyword` comment.
+ let object = this.#suggestionsByKeyword.get(keyword);
+ if (!object) {
+ this.#suggestionsByKeyword.set(keyword, suggestion);
+ } else {
+ let isArray = Array.isArray(object);
+ if (!isArray && object != suggestion) {
+ this.#suggestionsByKeyword.set(keyword, [object, suggestion]);
+ } else if (isArray && !object.includes(suggestion)) {
+ object.push(suggestion);
+ }
+ }
+ }
+
+ keywordIndex++;
+ indexInChunk++;
+ }
+
+ // The current chunk is done.
+ resolve();
+ });
+ });
+ }
+ }
+
+ clear() {
+ this.#suggestionsByKeyword.clear();
+ }
+
+ /**
+ * @returns {Function}
+ * A `mapKeyword` function that maps a keyword to an array containing the
+ * keyword's first word plus every subsequent prefix of the keyword. The
+ * strings in `KEYWORD_PREFIXES_TO_TREAT_AS_SINGLE_WORDS` will modify this
+ * behavior: When a full keyword starts with one of the prefixes in that
+ * list, the generated prefixes will start at that prefix instead of the
+ * first word.
+ */
+ static get MAP_KEYWORD_PREFIXES_STARTING_AT_FIRST_WORD() {
+ return fullKeyword => {
+ let prefix = KEYWORD_PREFIXES_TO_TREAT_AS_SINGLE_WORDS.find(p =>
+ fullKeyword.startsWith(p + " ")
+ );
+ let spaceIndex = prefix ? prefix.length : fullKeyword.indexOf(" ");
+
+ let keywords = [fullKeyword];
+ if (spaceIndex >= 0) {
+ for (let i = spaceIndex; i < fullKeyword.length; i++) {
+ keywords.push(fullKeyword.substring(0, i));
+ }
+ }
+ return keywords;
+ };
+ }
+
+ // Maps each keyword in the dataset to one or more suggestions for the
+ // keyword. If only one suggestion uses a keyword, the keyword's value in the
+ // map will be the suggestion object. If more than one suggestion uses the
+ // keyword, the value will be an array of the suggestions. The reason for not
+ // always using an array is that we expect the vast majority of keywords to be
+ // used by only one suggestion, and since there are potentially very many
+ // keywords and suggestions and we keep them in memory all the time, we want
+ // to save as much memory as possible.
+ #suggestionsByKeyword = new Map();
+
+ // This is only defined as a property so that tests can override it.
+ static chunkSize = SUGGESTIONS_MAP_CHUNK_SIZE;
+}
diff --git a/browser/components/urlbar/private/SuggestBackendRust.sys.mjs b/browser/components/urlbar/private/SuggestBackendRust.sys.mjs
new file mode 100644
index 0000000000..fe54feaee8
--- /dev/null
+++ b/browser/components/urlbar/private/SuggestBackendRust.sys.mjs
@@ -0,0 +1,407 @@
+/* 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 { BaseFeature } from "resource:///modules/urlbar/private/BaseFeature.sys.mjs";
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ QuickSuggest: "resource:///modules/QuickSuggest.sys.mjs",
+ RemoteSettingsConfig: "resource://gre/modules/RustRemoteSettings.sys.mjs",
+ SuggestIngestionConstraints: "resource://gre/modules/RustSuggest.sys.mjs",
+ SuggestStore: "resource://gre/modules/RustSuggest.sys.mjs",
+ Suggestion: "resource://gre/modules/RustSuggest.sys.mjs",
+ SuggestionProvider: "resource://gre/modules/RustSuggest.sys.mjs",
+ SuggestionQuery: "resource://gre/modules/RustSuggest.sys.mjs",
+ UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs",
+ Utils: "resource://services-settings/Utils.sys.mjs",
+});
+
+XPCOMUtils.defineLazyServiceGetter(
+ lazy,
+ "timerManager",
+ "@mozilla.org/updates/timer-manager;1",
+ "nsIUpdateTimerManager"
+);
+
+const SUGGEST_STORE_BASENAME = "suggest.sqlite";
+
+// This ID is used to register our ingest timer with nsIUpdateTimerManager.
+const INGEST_TIMER_ID = "suggest-ingest";
+const INGEST_TIMER_LAST_UPDATE_PREF = `app.update.lastUpdateTime.${INGEST_TIMER_ID}`;
+
+// Maps from `suggestion.constructor` to the corresponding name of the
+// suggestion type. See `getSuggestionType()` for details.
+const gSuggestionTypesByCtor = new WeakMap();
+
+/**
+ * The Suggest Rust backend. Not used when the remote settings JS backend is
+ * enabled.
+ *
+ * This class returns suggestions served by the Rust component. These are the
+ * primary related architectural pieces (see bug 1851256 for details):
+ *
+ * (1) The `suggest` Rust component, which lives in the application-services
+ * repo [1] and is periodically vendored into mozilla-central [2] and then
+ * built into the Firefox binary.
+ * (2) `suggest.udl`, which is part of the Rust component's source files and
+ * defines the interface exposed to foreign-function callers like JS [3, 4].
+ * (3) `RustSuggest.sys.mjs` [5], which contains the JS bindings generated from
+ * `suggest.udl` by UniFFI. The classes defined in `RustSuggest.sys.mjs` are
+ * what we consume here in this file. If you have a question about the JS
+ * interface to the Rust component, try checking `RustSuggest.sys.mjs`, but
+ * as you get accustomed to UniFFI JS conventions you may find it simpler to
+ * refer directly to `suggest.udl`.
+ * (4) `config.toml` [6], which defines which functions in the JS bindings are
+ * sync and which are async. Functions default to the "worker" thread, which
+ * means they are async. Some functions are "main", which means they are
+ * sync. Async functions return promises. This information is reflected in
+ * `RustSuggest.sys.mjs` of course: If a function is "worker", its JS
+ * binding will return a promise, and if it's "main" it won't.
+ *
+ * [1] https://github.com/mozilla/application-services/tree/main/components/suggest
+ * [2] https://searchfox.org/mozilla-central/source/third_party/rust/suggest
+ * [3] https://github.com/mozilla/application-services/blob/main/components/suggest/src/suggest.udl
+ * [4] https://searchfox.org/mozilla-central/source/third_party/rust/suggest/src/suggest.udl
+ * [5] https://searchfox.org/mozilla-central/source/toolkit/components/uniffi-bindgen-gecko-js/components/generated/RustSuggest.sys.mjs
+ * [6] https://searchfox.org/mozilla-central/source/toolkit/components/uniffi-bindgen-gecko-js/config.toml
+ */
+export class SuggestBackendRust extends BaseFeature {
+ /**
+ * @returns {object}
+ * The global Suggest config from the Rust component as returned from
+ * `SuggestStore.fetchGlobalConfig()`.
+ */
+ get config() {
+ return this.#config || {};
+ }
+
+ /**
+ * @returns {Promise}
+ * If ingest is pending this will be resolved when it's done. Otherwise it
+ * was resolved when the previous ingest finished.
+ */
+ get ingestPromise() {
+ return this.#ingestPromise;
+ }
+
+ get shouldEnable() {
+ return lazy.UrlbarPrefs.get("quickSuggestRustEnabled");
+ }
+
+ enable(enabled) {
+ if (enabled) {
+ this.#init();
+ } else {
+ this.#uninit();
+ }
+ }
+
+ async query(searchString) {
+ this.logger.info("Handling query: " + JSON.stringify(searchString));
+
+ if (!this.#store) {
+ // There must have been an error creating `#store`.
+ this.logger.info("#store is null, returning");
+ return [];
+ }
+
+ // Build the list of enabled Rust providers to query.
+ let providers = this.#rustProviders.reduce(
+ (memo, { type, feature, provider }) => {
+ if (feature.isEnabled && feature.isRustSuggestionTypeEnabled(type)) {
+ this.logger.debug(
+ `Adding provider to query: '${type}' (${provider})`
+ );
+ memo.push(provider);
+ }
+ return memo;
+ },
+ []
+ );
+
+ let suggestions = await this.#store.query(
+ new lazy.SuggestionQuery({ keyword: searchString, providers })
+ );
+
+ for (let suggestion of suggestions) {
+ let type = getSuggestionType(suggestion);
+ if (!type) {
+ continue;
+ }
+
+ suggestion.source = "rust";
+ suggestion.provider = type;
+ suggestion.is_sponsored = type == "Amp" || type == "Yelp";
+ if (Array.isArray(suggestion.icon)) {
+ suggestion.icon_blob = new Blob(
+ [new Uint8Array(suggestion.icon)],
+ type == "Yelp" ? { type: "image/svg+xml" } : null
+ );
+ delete suggestion.icon;
+ }
+ }
+
+ this.logger.debug(
+ "Got suggestions: " + JSON.stringify(suggestions, null, 2)
+ );
+
+ return suggestions;
+ }
+
+ cancelQuery() {
+ this.#store?.interrupt();
+ }
+
+ /**
+ * Returns suggestion-type-specific configuration data set by the Rust
+ * backend.
+ *
+ * @param {string} type
+ * A Rust suggestion type name as defined in `suggest.udl`, e.g., "Amp",
+ * "Wikipedia", "Mdn", etc. See also `BaseFeature.rustSuggestionTypes`.
+ * @returns {object} config
+ * The config data for the type.
+ */
+ getConfigForSuggestionType(type) {
+ return this.#configsBySuggestionType.get(type);
+ }
+
+ /**
+ * nsITimerCallback
+ */
+ notify() {
+ this.logger.info("Ingest timer fired");
+ this.#ingest();
+ }
+
+ get #storePath() {
+ return PathUtils.join(
+ Services.dirsvc.get("ProfLD", Ci.nsIFile).path,
+ SUGGEST_STORE_BASENAME
+ );
+ }
+
+ /**
+ * @returns {Array}
+ * Each item in this array contains metadata related to a Rust suggestion
+ * type, the `BaseFeature` that manages the type, and the corresponding
+ * suggestion provider as defined by Rust. Items look like this:
+ * `{ type, feature, provider }`
+ *
+ * {string} type
+ * The Rust suggestion type name (the same type of string values that are
+ * defined in `BaseFeature.rustSuggestionTypes`).
+ * {BaseFeature} feature
+ * The feature that manages the suggestion type.
+ * {number} provider
+ * An integer value defined on the `SuggestionProvider` object in
+ * `RustSuggest.sys.mjs` that identifies the suggestion provider to
+ * Rust.
+ */
+ get #rustProviders() {
+ let items = [];
+ for (let [type, feature] of lazy.QuickSuggest
+ .featuresByRustSuggestionType) {
+ let key = type.toUpperCase();
+ if (!lazy.SuggestionProvider.hasOwnProperty(key)) {
+ this.logger.error(`SuggestionProvider["${key}"] is not defined!`);
+ continue;
+ }
+ items.push({ type, feature, provider: lazy.SuggestionProvider[key] });
+ }
+ return items;
+ }
+
+ async #init() {
+ // Create the store.
+ let path = this.#storePath;
+ this.logger.info("Initializing SuggestStore: " + path);
+ try {
+ this.#store = lazy.SuggestStore.init(
+ path,
+ this.#test_remoteSettingsConfig ??
+ new lazy.RemoteSettingsConfig({
+ collectionName: "quicksuggest",
+ bucketName: lazy.Utils.actualBucketName("main"),
+ serverUrl: lazy.Utils.SERVER_URL,
+ })
+ );
+ } catch (error) {
+ this.logger.error("Error initializing SuggestStore:");
+ this.logger.error(error);
+ return;
+ }
+
+ // Before registering the ingest timer, check the last-update pref, which is
+ // created by the timer manager the first time we register it. If the pref
+ // doesn't exist, this is the first time the Rust backend has been enabled
+ // in this profile. In that case, perform ingestion immediately to make
+ // automated and manual testing easier. Otherwise we'd need to wait at least
+ // 30s (`app.update.timerFirstInterval`) for the timer manager to call us
+ // back (and we'd also need to pass false for `skipFirst` below).
+ let lastIngestSecs = Services.prefs.getIntPref(
+ INGEST_TIMER_LAST_UPDATE_PREF,
+ 0
+ );
+
+ // Register the ingest timer.
+ lazy.timerManager.registerTimer(
+ INGEST_TIMER_ID,
+ this,
+ lazy.UrlbarPrefs.get("quicksuggest.rustIngestIntervalSeconds"),
+ true // skipFirst
+ );
+
+ if (lastIngestSecs) {
+ this.logger.info(
+ `Last ingest: ${lastIngestSecs}s since epoch. Not ingesting now`
+ );
+ } else {
+ this.logger.info("Last ingest time not found. Ingesting now");
+ await this.#ingest();
+ }
+ }
+
+ #uninit() {
+ this.#store = null;
+ this.#configsBySuggestionType.clear();
+ lazy.timerManager.unregisterTimer(INGEST_TIMER_ID);
+ }
+
+ async #ingest() {
+ let instance = (this.#ingestInstance = {});
+ await this.#ingestPromise;
+ if (instance != this.#ingestInstance) {
+ return;
+ }
+ await (this.#ingestPromise = this.#ingestHelper());
+ }
+
+ async #ingestHelper() {
+ if (!this.#store) {
+ return;
+ }
+
+ this.logger.info("Starting ingest and configs fetch");
+
+ // Do the ingest.
+ this.logger.debug("Starting ingest");
+ try {
+ await this.#store.ingest(new lazy.SuggestIngestionConstraints());
+ } catch (error) {
+ // Ingest can throw a `SuggestApiError` subclass called `Other` that has a
+ // custom `reason` message, which is very helpful for diagnosing problems
+ // with remote settings data in tests in particular.
+ this.logger.error("Ingest error: " + (error.reason ?? error));
+ }
+ this.logger.debug("Finished ingest");
+
+ if (!this.#store) {
+ this.logger.info("#store became null, returning from ingest");
+ return;
+ }
+
+ // Fetch the global config.
+ this.logger.debug("Fetching global config");
+ this.#config = await this.#store.fetchGlobalConfig();
+ this.logger.debug("Got global config: " + JSON.stringify(this.#config));
+
+ if (!this.#store) {
+ this.logger.info("#store became null, returning from ingest");
+ return;
+ }
+
+ // Fetch all provider configs. We do this for all features, even ones that
+ // are currently disabled, because they may become enabled before the next
+ // ingest.
+ this.logger.debug("Fetching provider configs");
+ await Promise.all(
+ this.#rustProviders.map(async ({ type, provider }) => {
+ let config = await this.#store.fetchProviderConfig(provider);
+ this.logger.debug(
+ `Got '${type}' provider config: ` + JSON.stringify(config)
+ );
+ this.#configsBySuggestionType.set(type, config);
+ })
+ );
+ this.logger.debug("Finished fetching provider configs");
+
+ this.logger.info("Finished ingest and configs fetch");
+ }
+
+ async _test_setRemoteSettingsConfig(config) {
+ this.#test_remoteSettingsConfig = config;
+
+ if (this.isEnabled) {
+ // Recreate the store and re-ingest.
+ Services.prefs.clearUserPref(INGEST_TIMER_LAST_UPDATE_PREF);
+ this.#uninit();
+ await this.#init();
+ }
+ }
+
+ async _test_ingest() {
+ await this.#ingest();
+ }
+
+ // The `SuggestStore` instance.
+ #store;
+
+ // Global Suggest config as returned from `SuggestStore.fetchGlobalConfig()`.
+ #config = {};
+
+ // Maps from suggestion type to provider config as returned from
+ // `SuggestStore.fetchProviderConfig()`.
+ #configsBySuggestionType = new Map();
+
+ #ingestPromise;
+ #ingestInstance;
+ #test_remoteSettingsConfig;
+}
+
+/**
+ * Returns the type of a suggestion.
+ *
+ * @param {Suggestion} suggestion
+ * A suggestion object, an instance of one of the `Suggestion` subclasses.
+ * @returns {string}
+ * The suggestion's type, e.g., "Amp", "Wikipedia", etc.
+ */
+function getSuggestionType(suggestion) {
+ // Suggestion objects served by the Rust component don't have any inherent
+ // type information other than the classes they are instances of. There's no
+ // `type` property, for example. There's a base `Suggestion` class and many
+ // `Suggestion` subclasses, one per type of suggestion. Each suggestion object
+ // is an instance of one of these subclasses. We derive a suggestion's type
+ // from the subclass it's an instance of.
+ //
+ // Unfortunately the subclasses are all anonymous, which means
+ // `suggestion.constructor.name` is always an empty string. (This is due to
+ // how UniFFI generates JS bindings.) Instead, the subclasses are defined as
+ // properties on the base `Suggestion` class. For example,
+ // `Suggestion.Wikipedia` is the (anonymous) Wikipedia suggestion class. To
+ // find a suggestion's subclass, we loop through the keys on `Suggestion`
+ // until we find the value the suggestion is an instance of. To avoid doing
+ // this every time, we cache the mapping from suggestion constructor to key
+ // the first time we encounter a new suggestion subclass.
+ let type = gSuggestionTypesByCtor.get(suggestion.constructor);
+ if (!type) {
+ type = Object.keys(lazy.Suggestion).find(
+ key => suggestion instanceof lazy.Suggestion[key]
+ );
+ if (type) {
+ gSuggestionTypesByCtor.set(suggestion.constructor, type);
+ } else {
+ this.logger.error(
+ "Unexpected error: Suggestion class not found on `Suggestion`. " +
+ "Did the Rust component or its JS bindings change? " +
+ "The suggestion is: " +
+ JSON.stringify(suggestion)
+ );
+ }
+ }
+ return type;
+}
diff --git a/browser/components/urlbar/private/Weather.sys.mjs b/browser/components/urlbar/private/Weather.sys.mjs
new file mode 100644
index 0000000000..c4dfa8c618
--- /dev/null
+++ b/browser/components/urlbar/private/Weather.sys.mjs
@@ -0,0 +1,896 @@
+/* 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 { BaseFeature } from "resource:///modules/urlbar/private/BaseFeature.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ clearTimeout: "resource://gre/modules/Timer.sys.mjs",
+ MerinoClient: "resource:///modules/MerinoClient.sys.mjs",
+ QuickSuggest: "resource:///modules/QuickSuggest.sys.mjs",
+ setTimeout: "resource://gre/modules/Timer.sys.mjs",
+ UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs",
+ UrlbarResult: "resource:///modules/UrlbarResult.sys.mjs",
+ UrlbarUtils: "resource:///modules/UrlbarUtils.sys.mjs",
+ UrlbarView: "resource:///modules/UrlbarView.sys.mjs",
+});
+
+const FETCH_DELAY_AFTER_COMING_ONLINE_MS = 3000; // 3s
+const FETCH_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes
+const MERINO_PROVIDER = "accuweather";
+const MERINO_TIMEOUT_MS = 5000; // 5s
+
+const HISTOGRAM_LATENCY = "FX_URLBAR_MERINO_LATENCY_WEATHER_MS";
+const HISTOGRAM_RESPONSE = "FX_URLBAR_MERINO_RESPONSE_WEATHER";
+
+const NOTIFICATIONS = {
+ CAPTIVE_PORTAL_LOGIN: "captive-portal-login-success",
+ LINK_STATUS_CHANGED: "network:link-status-changed",
+ OFFLINE_STATUS_CHANGED: "network:offline-status-changed",
+ WAKE: "wake_notification",
+};
+
+const RESULT_MENU_COMMAND = {
+ HELP: "help",
+ INACCURATE_LOCATION: "inaccurate_location",
+ NOT_INTERESTED: "not_interested",
+ NOT_RELEVANT: "not_relevant",
+ SHOW_LESS_FREQUENTLY: "show_less_frequently",
+};
+
+const WEATHER_PROVIDER_DISPLAY_NAME = "AccuWeather";
+
+const WEATHER_DYNAMIC_TYPE = "weather";
+const WEATHER_VIEW_TEMPLATE = {
+ attributes: {
+ selectable: true,
+ },
+ children: [
+ {
+ name: "currentConditions",
+ tag: "span",
+ children: [
+ {
+ name: "currently",
+ tag: "div",
+ },
+ {
+ name: "currentTemperature",
+ tag: "div",
+ children: [
+ {
+ name: "temperature",
+ tag: "span",
+ },
+ {
+ name: "weatherIcon",
+ tag: "img",
+ },
+ ],
+ },
+ ],
+ },
+ {
+ name: "summary",
+ tag: "span",
+ overflowable: true,
+ children: [
+ {
+ name: "top",
+ tag: "div",
+ children: [
+ {
+ name: "topNoWrap",
+ tag: "span",
+ children: [
+ { name: "title", tag: "span", classList: ["urlbarView-title"] },
+ {
+ name: "titleSeparator",
+ tag: "span",
+ classList: ["urlbarView-title-separator"],
+ },
+ ],
+ },
+ {
+ name: "url",
+ tag: "span",
+ classList: ["urlbarView-url"],
+ },
+ ],
+ },
+ {
+ name: "middle",
+ tag: "div",
+ children: [
+ {
+ name: "middleNoWrap",
+ tag: "span",
+ overflowable: true,
+ children: [
+ {
+ name: "summaryText",
+ tag: "span",
+ },
+ {
+ name: "summaryTextSeparator",
+ tag: "span",
+ },
+ {
+ name: "highLow",
+ tag: "span",
+ },
+ ],
+ },
+ {
+ name: "highLowWrap",
+ tag: "span",
+ },
+ ],
+ },
+ {
+ name: "bottom",
+ tag: "div",
+ },
+ ],
+ },
+ ],
+};
+
+/**
+ * A feature that periodically fetches weather suggestions from Merino.
+ */
+export class Weather extends BaseFeature {
+ constructor(...args) {
+ super(...args);
+ lazy.UrlbarResult.addDynamicResultType(WEATHER_DYNAMIC_TYPE);
+ lazy.UrlbarView.addDynamicViewTemplate(
+ WEATHER_DYNAMIC_TYPE,
+ WEATHER_VIEW_TEMPLATE
+ );
+ }
+
+ get shouldEnable() {
+ // The feature itself is enabled by setting these prefs regardless of
+ // whether any config is defined. This is necessary to allow the feature to
+ // sync the config from remote settings and Nimbus. Suggestion fetches will
+ // not start until the config has been either synced from remote settings or
+ // set by Nimbus.
+ return (
+ lazy.UrlbarPrefs.get("weatherFeatureGate") &&
+ lazy.UrlbarPrefs.get("suggest.weather")
+ );
+ }
+
+ get enablingPreferences() {
+ return ["suggest.weather"];
+ }
+
+ get rustSuggestionTypes() {
+ return ["Weather"];
+ }
+
+ isRustSuggestionTypeEnabled(type) {
+ // When weather keywords are defined in Nimbus, weather suggestions are
+ // served by UrlbarProviderWeather. Return false here so the quick suggest
+ // provider doesn't try to serve them too.
+ return !lazy.UrlbarPrefs.get("weatherKeywords");
+ }
+
+ getSuggestionTelemetryType(suggestion) {
+ return "weather";
+ }
+
+ /**
+ * @returns {object}
+ * The last weather suggestion fetched from Merino or null if none.
+ */
+ get suggestion() {
+ return this.#suggestion;
+ }
+
+ /**
+ * @returns {Set}
+ * The set of keywords that should trigger the weather suggestion. This will
+ * be null when the Rust backend is enabled and keywords are not defined by
+ * Nimbus because in that case Rust manages the keywords. Otherwise, it will
+ * also be null when no config is defined.
+ */
+ get keywords() {
+ return this.#keywords;
+ }
+
+ /**
+ * @returns {number}
+ * The minimum prefix length of a weather keyword the user must type to
+ * trigger the suggestion. Note that the strings returned from `keywords`
+ * already take this into account. The min length is determined from the
+ * first config source below whose value is non-zero. If no source has a
+ * non-zero value, zero will be returned, and `this.keywords` will contain
+ * only full keywords.
+ *
+ * 1. The `weather.minKeywordLength` pref, which is set when the user
+ * increments the min length
+ * 2. `weatherKeywordsMinimumLength` in Nimbus
+ * 3. `min_keyword_length` in the weather record in remote settings (i.e.,
+ * the weather config)
+ */
+ get minKeywordLength() {
+ let minLength =
+ lazy.UrlbarPrefs.get("weather.minKeywordLength") ||
+ lazy.UrlbarPrefs.get("weatherKeywordsMinimumLength") ||
+ this.#config.minKeywordLength ||
+ 0;
+ return Math.max(minLength, 0);
+ }
+
+ /**
+ * @returns {boolean}
+ * Weather the min keyword length can be incremented. A cap on the min
+ * length can be set in remote settings and Nimbus.
+ */
+ get canIncrementMinKeywordLength() {
+ let nimbusMax =
+ lazy.UrlbarPrefs.get("weatherKeywordsMinimumLengthCap") || 0;
+
+ let maxKeywordLength;
+ if (nimbusMax) {
+ // In Nimbus, the cap is the max keyword length.
+ maxKeywordLength = nimbusMax;
+ } else {
+ // In the RS config, the cap is the max number of times the user can click
+ // "Show less frequently". The max keyword length is therefore the initial
+ // min length plus the cap.
+ let min = this.#config.minKeywordLength;
+ let cap = lazy.QuickSuggest.backend.config?.showLessFrequentlyCap;
+ if (min && cap) {
+ maxKeywordLength = min + cap;
+ }
+ }
+
+ return !maxKeywordLength || this.minKeywordLength < maxKeywordLength;
+ }
+
+ update() {
+ let wasEnabled = this.isEnabled;
+ super.update();
+
+ // This method is called by `QuickSuggest` in a
+ // `NimbusFeatures.urlbar.onUpdate()` callback, when a change occurs to a
+ // Nimbus variable or to a pref that's a fallback for a Nimbus variable. A
+ // config-related variable or pref may have changed, so update keywords, but
+ // only if the feature was already enabled because if it wasn't,
+ // `enable(true)` was just called, which calls `#init()`, which calls
+ // `#updateKeywords()`.
+ if (wasEnabled && this.isEnabled) {
+ this.#updateKeywords();
+ }
+ }
+
+ enable(enabled) {
+ if (enabled) {
+ this.#init();
+ } else {
+ this.#uninit();
+ }
+ }
+
+ /**
+ * Increments the minimum prefix length of a weather keyword the user must
+ * type to trigger the suggestion, if possible. A cap on the min length can be
+ * set in remote settings and Nimbus, and if the cap has been reached, the
+ * length is not incremented.
+ */
+ incrementMinKeywordLength() {
+ if (this.canIncrementMinKeywordLength) {
+ lazy.UrlbarPrefs.set(
+ "weather.minKeywordLength",
+ this.minKeywordLength + 1
+ );
+ }
+ }
+
+ /**
+ * Returns a promise that resolves when all pending fetches finish, if there
+ * are pending fetches. If there aren't, the promise resolves when all pending
+ * fetches starting with the next fetch finish.
+ *
+ * @returns {Promise}
+ */
+ waitForFetches() {
+ if (!this.#waitForFetchesDeferred) {
+ this.#waitForFetchesDeferred = Promise.withResolvers();
+ }
+ return this.#waitForFetchesDeferred.promise;
+ }
+
+ async onRemoteSettingsSync(rs) {
+ this.logger.debug("Loading weather config from remote settings");
+ let records = await rs.get({ filters: { type: "weather" } });
+ if (!this.isEnabled) {
+ return;
+ }
+
+ this.logger.debug("Got weather records: " + JSON.stringify(records));
+ this.#rsConfig = lazy.UrlbarUtils.copySnakeKeysToCamel(
+ records?.[0]?.weather || {}
+ );
+ this.#updateKeywords();
+ }
+
+ makeResult(queryContext, suggestion, searchString) {
+ // The Rust component doesn't enforce a minimum keyword length, so discard
+ // the suggestion if the search string isn't long enough. This conditional
+ // will always be false for the JS backend since in that case keywords are
+ // never shorter than `minKeywordLength`.
+ if (searchString.length < this.minKeywordLength) {
+ return null;
+ }
+
+ // The Rust component will return a dummy suggestion if the query matches a
+ // weather keyword. Here in this method we replace it with the actual cached
+ // weather suggestion from Merino. If there is no cached suggestion, discard
+ // the Rust suggestion.
+ if (!this.suggestion) {
+ return null;
+ }
+
+ if (suggestion.source == "rust") {
+ if (lazy.UrlbarPrefs.get("weatherKeywords")) {
+ // This shouldn't happen since this feature won't enable Rust weather
+ // suggestions in this case, but just to be safe, discard the suggestion
+ // if keywords are defined in Nimbus.
+ return null;
+ }
+ // Replace the dummy Rust suggestion with the actual weather suggestion
+ // from Merino.
+ suggestion = this.suggestion;
+ }
+
+ let unit = Services.locale.regionalPrefsLocales[0] == "en-US" ? "f" : "c";
+ return Object.assign(
+ new lazy.UrlbarResult(
+ lazy.UrlbarUtils.RESULT_TYPE.DYNAMIC,
+ lazy.UrlbarUtils.RESULT_SOURCE.SEARCH,
+ {
+ url: suggestion.url,
+ iconId: suggestion.current_conditions.icon_id,
+ helpUrl: lazy.QuickSuggest.HELP_URL,
+ requestId: suggestion.request_id,
+ dynamicType: WEATHER_DYNAMIC_TYPE,
+ city: suggestion.city_name,
+ temperatureUnit: unit,
+ temperature: suggestion.current_conditions.temperature[unit],
+ currentConditions: suggestion.current_conditions.summary,
+ forecast: suggestion.forecast.summary,
+ high: suggestion.forecast.high[unit],
+ low: suggestion.forecast.low[unit],
+ shouldNavigate: true,
+ }
+ ),
+ {
+ showFeedbackMenu: true,
+ suggestedIndex: searchString ? 1 : 0,
+ }
+ );
+ }
+
+ getViewUpdate(result) {
+ let uppercaseUnit = result.payload.temperatureUnit.toUpperCase();
+ return {
+ currently: {
+ l10n: {
+ id: "firefox-suggest-weather-currently",
+ cacheable: true,
+ },
+ },
+ temperature: {
+ l10n: {
+ id: "firefox-suggest-weather-temperature",
+ args: {
+ value: result.payload.temperature,
+ unit: uppercaseUnit,
+ },
+ cacheable: true,
+ excludeArgsFromCacheKey: true,
+ },
+ },
+ weatherIcon: {
+ attributes: { iconId: result.payload.iconId },
+ },
+ title: {
+ l10n: {
+ id: "firefox-suggest-weather-title",
+ args: { city: result.payload.city },
+ cacheable: true,
+ excludeArgsFromCacheKey: true,
+ },
+ },
+ url: {
+ textContent: result.payload.url,
+ },
+ summaryText: {
+ l10n: {
+ id: "firefox-suggest-weather-summary-text",
+ args: {
+ currentConditions: result.payload.currentConditions,
+ forecast: result.payload.forecast,
+ },
+ cacheable: true,
+ excludeArgsFromCacheKey: true,
+ },
+ },
+ highLow: {
+ l10n: {
+ id: "firefox-suggest-weather-high-low",
+ args: {
+ high: result.payload.high,
+ low: result.payload.low,
+ unit: uppercaseUnit,
+ },
+ cacheable: true,
+ excludeArgsFromCacheKey: true,
+ },
+ },
+ highLowWrap: {
+ l10n: {
+ id: "firefox-suggest-weather-high-low",
+ args: {
+ high: result.payload.high,
+ low: result.payload.low,
+ unit: uppercaseUnit,
+ },
+ },
+ },
+ bottom: {
+ l10n: {
+ id: "firefox-suggest-weather-sponsored",
+ args: { provider: WEATHER_PROVIDER_DISPLAY_NAME },
+ cacheable: true,
+ },
+ },
+ };
+ }
+
+ getResultCommands(result) {
+ let commands = [
+ {
+ name: RESULT_MENU_COMMAND.INACCURATE_LOCATION,
+ l10n: {
+ id: "firefox-suggest-weather-command-inaccurate-location",
+ },
+ },
+ ];
+
+ if (this.canIncrementMinKeywordLength) {
+ commands.push({
+ name: RESULT_MENU_COMMAND.SHOW_LESS_FREQUENTLY,
+ l10n: {
+ id: "firefox-suggest-command-show-less-frequently",
+ },
+ });
+ }
+
+ commands.push(
+ {
+ l10n: {
+ id: "firefox-suggest-command-dont-show-this",
+ },
+ children: [
+ {
+ name: RESULT_MENU_COMMAND.NOT_RELEVANT,
+ l10n: {
+ id: "firefox-suggest-command-not-relevant",
+ },
+ },
+ {
+ name: RESULT_MENU_COMMAND.NOT_INTERESTED,
+ l10n: {
+ id: "firefox-suggest-command-not-interested",
+ },
+ },
+ ],
+ },
+ { name: "separator" },
+ {
+ name: RESULT_MENU_COMMAND.HELP,
+ l10n: {
+ id: "urlbar-result-menu-learn-more-about-firefox-suggest",
+ },
+ }
+ );
+
+ return commands;
+ }
+
+ handleCommand(view, result, selType) {
+ switch (selType) {
+ case RESULT_MENU_COMMAND.HELP:
+ // "help" is handled by UrlbarInput, no need to do anything here.
+ break;
+ // selType == "dismiss" when the user presses the dismiss key shortcut.
+ case "dismiss":
+ case RESULT_MENU_COMMAND.NOT_INTERESTED:
+ case RESULT_MENU_COMMAND.NOT_RELEVANT:
+ this.logger.info("Dismissing weather result");
+ lazy.UrlbarPrefs.set("suggest.weather", false);
+ result.acknowledgeDismissalL10n = {
+ id: "firefox-suggest-dismissal-acknowledgment-all",
+ };
+ view.controller.removeResult(result);
+ break;
+ case RESULT_MENU_COMMAND.INACCURATE_LOCATION:
+ // Currently the only way we record this feedback is in the Glean
+ // engagement event. As with all commands, it will be recorded with an
+ // `engagement_type` value that is the command's name, in this case
+ // `inaccurate_location`.
+ view.acknowledgeFeedback(result);
+ break;
+ case RESULT_MENU_COMMAND.SHOW_LESS_FREQUENTLY:
+ view.acknowledgeFeedback(result);
+ this.incrementMinKeywordLength();
+ if (!this.canIncrementMinKeywordLength) {
+ view.invalidateResultMenuCommands();
+ }
+ break;
+ }
+ }
+
+ get #config() {
+ let { rustBackend } = lazy.QuickSuggest;
+ let config = rustBackend.isEnabled
+ ? rustBackend.getConfigForSuggestionType(this.rustSuggestionTypes[0])
+ : this.#rsConfig;
+ return config || {};
+ }
+
+ get #vpnDetected() {
+ if (lazy.UrlbarPrefs.get("weather.ignoreVPN")) {
+ return false;
+ }
+
+ let linkService =
+ this._test_linkService ||
+ Cc["@mozilla.org/network/network-link-service;1"].getService(
+ Ci.nsINetworkLinkService
+ );
+
+ // `platformDNSIndications` throws `NS_ERROR_NOT_IMPLEMENTED` on all
+ // platforms except Windows, so we can't detect a VPN on any other platform.
+ try {
+ return (
+ linkService.platformDNSIndications &
+ Ci.nsINetworkLinkService.VPN_DETECTED
+ );
+ } catch (e) {}
+ return false;
+ }
+
+ #init() {
+ // On feature init, we only update keywords and listen for changes that
+ // affect keywords. Suggestion fetches will not start until either keywords
+ // exist or Rust is enabled.
+ this.#updateKeywords();
+ lazy.UrlbarPrefs.addObserver(this);
+ lazy.QuickSuggest.jsBackend.register(this);
+ }
+
+ #uninit() {
+ this.#stopFetching();
+ lazy.QuickSuggest.jsBackend.unregister(this);
+ lazy.UrlbarPrefs.removeObserver(this);
+ this.#keywords = null;
+ }
+
+ #startFetching() {
+ if (this.#merino) {
+ this.logger.debug("Suggestion fetching already started");
+ return;
+ }
+
+ this.logger.debug("Starting suggestion fetching");
+
+ this.#merino = new lazy.MerinoClient(this.constructor.name);
+ this.#fetch();
+ for (let notif of Object.values(NOTIFICATIONS)) {
+ Services.obs.addObserver(this, notif);
+ }
+ }
+
+ #stopFetching() {
+ if (!this.#merino) {
+ this.logger.debug("Suggestion fetching already stopped");
+ return;
+ }
+
+ this.logger.debug("Stopping suggestion fetching");
+
+ for (let notif of Object.values(NOTIFICATIONS)) {
+ Services.obs.removeObserver(this, notif);
+ }
+ lazy.clearTimeout(this.#fetchTimer);
+ this.#merino = null;
+ this.#suggestion = null;
+ this.#fetchTimer = 0;
+ }
+
+ async #fetch() {
+ this.logger.info("Fetching suggestion");
+
+ if (this.#vpnDetected) {
+ // A VPN is detected, so Merino will not be able to accurately determine
+ // the user's location. Set the suggestion to null. We treat this as if
+ // the network is offline (see below). When the VPN is disconnected, a
+ // `network:link-status-changed` notification will be sent, triggering a
+ // new fetch.
+ this.logger.info("VPN detected, not fetching");
+ this.#suggestion = null;
+ if (!this.#pendingFetchCount) {
+ this.#waitForFetchesDeferred?.resolve();
+ this.#waitForFetchesDeferred = null;
+ }
+ return;
+ }
+
+ // This `Weather` instance may be uninitialized while awaiting the fetch or
+ // even uninitialized and re-initialized a number of times. Multiple fetches
+ // may also happen at once. Ignore the fetch below if `#merino` changes or
+ // another fetch happens in the meantime.
+ let merino = this.#merino;
+ let instance = (this.#fetchInstance = {});
+
+ this.#restartFetchTimer();
+ this.#lastFetchTimeMs = Date.now();
+ this.#pendingFetchCount++;
+
+ let suggestions;
+ try {
+ suggestions = await merino.fetch({
+ query: "",
+ providers: [MERINO_PROVIDER],
+ timeoutMs: this.#timeoutMs,
+ extraLatencyHistogram: HISTOGRAM_LATENCY,
+ extraResponseHistogram: HISTOGRAM_RESPONSE,
+ });
+ } finally {
+ this.#pendingFetchCount--;
+ }
+
+ // Reset the Merino client's session so different fetches use different
+ // sessions. A single session is intended to represent a single user
+ // engagement in the urlbar, which this is not. Practically this isn't
+ // necessary since the client automatically resets the session on a timer
+ // whose period is much shorter than our fetch period, but there's no reason
+ // to keep it ticking in the meantime.
+ merino.resetSession();
+
+ if (merino != this.#merino || instance != this.#fetchInstance) {
+ this.logger.info("Fetch finished but is out of date, ignoring");
+ } else {
+ let suggestion = suggestions?.[0];
+ if (!suggestion) {
+ // No suggestion was received. The network may be offline or there may
+ // be some other problem. Set the suggestion to null: Better to show
+ // nothing than outdated weather information. When the network comes
+ // back online, one or more network notifications will be sent,
+ // triggering a new fetch.
+ this.logger.info("No suggestion received");
+ this.#suggestion = null;
+ } else {
+ this.logger.info("Got suggestion");
+ this.logger.debug(JSON.stringify({ suggestion }));
+ this.#suggestion = { ...suggestion, source: "merino" };
+ }
+ }
+
+ if (!this.#pendingFetchCount) {
+ this.#waitForFetchesDeferred?.resolve();
+ this.#waitForFetchesDeferred = null;
+ }
+ }
+
+ #restartFetchTimer(ms = this.#fetchIntervalMs) {
+ this.logger.debug(
+ "Restarting fetch timer: " +
+ JSON.stringify({ ms, fetchIntervalMs: this.#fetchIntervalMs })
+ );
+
+ lazy.clearTimeout(this.#fetchTimer);
+ this.#fetchTimer = lazy.setTimeout(() => {
+ this.logger.debug("Fetch timer fired");
+ this.#fetch();
+ }, ms);
+ this._test_fetchTimerMs = ms;
+ }
+
+ #onMaybeCameOnline() {
+ this.logger.debug("Maybe came online");
+
+ // If the suggestion is null, we were offline the last time we tried to
+ // fetch, at the start of the current fetch period. Otherwise the suggestion
+ // was fetched successfully at the start of the current fetch period and is
+ // therefore still fresh.
+ if (!this.suggestion) {
+ // Multiple notifications can occur at once when the network comes online,
+ // and we don't want to do separate fetches for each. Start the timer with
+ // a small timeout. If another notification happens in the meantime, we'll
+ // start it again.
+ this.#restartFetchTimer(this.#fetchDelayAfterComingOnlineMs);
+ }
+ }
+
+ #onWake() {
+ // Calculate the elapsed time between the last fetch and now, and the
+ // remaining interval in the current fetch period.
+ let elapsedMs = Date.now() - this.#lastFetchTimeMs;
+ let remainingIntervalMs = this.#fetchIntervalMs - elapsedMs;
+ this.logger.debug(
+ "Wake: " +
+ JSON.stringify({
+ elapsedMs,
+ remainingIntervalMs,
+ fetchIntervalMs: this.#fetchIntervalMs,
+ })
+ );
+
+ // Regardless of the elapsed time, we need to restart the fetch timer
+ // because it didn't tick while the computer was asleep. If the elapsed time
+ // >= the fetch interval, the remaining interval will be negative and we
+ // need to fetch now, but do it after a brief delay in case other
+ // notifications occur soon when the network comes online. If the elapsed
+ // time < the fetch interval, the suggestion is still fresh so there's no
+ // need to fetch. Just restart the timer with the remaining interval.
+ if (remainingIntervalMs <= 0) {
+ remainingIntervalMs = this.#fetchDelayAfterComingOnlineMs;
+ }
+ this.#restartFetchTimer(remainingIntervalMs);
+ }
+
+ #updateKeywords() {
+ this.logger.debug("Starting keywords update");
+
+ let nimbusKeywords = lazy.UrlbarPrefs.get("weatherKeywords");
+
+ // If the Rust backend is enabled and weather keywords aren't defined in
+ // Nimbus, Rust will manage the keywords.
+ if (lazy.UrlbarPrefs.get("quickSuggestRustEnabled") && !nimbusKeywords) {
+ this.logger.debug(
+ "Rust enabled, no keywords in Nimbus. " +
+ "Starting fetches and deferring to Rust."
+ );
+ this.#keywords = null;
+ this.#startFetching();
+ return;
+ }
+
+ // If the JS backend is enabled but no keywords are defined, we can't
+ // possibly serve a weather suggestion.
+ if (
+ !lazy.UrlbarPrefs.get("quickSuggestRustEnabled") &&
+ !this.#config.keywords &&
+ !nimbusKeywords
+ ) {
+ this.logger.debug(
+ "Rust disabled, no keywords in RS or Nimbus. Stopping fetches."
+ );
+ this.#keywords = null;
+ this.#stopFetching();
+ return;
+ }
+
+ // At this point, keywords exist and this feature will manage them.
+ let fullKeywords = nimbusKeywords || this.#config.keywords;
+ let minLength = this.minKeywordLength;
+ this.logger.debug(
+ "Updating keywords: " + JSON.stringify({ fullKeywords, minLength })
+ );
+
+ if (!minLength) {
+ this.logger.debug("Min length is undefined or zero, using full keywords");
+ this.#keywords = new Set(fullKeywords);
+ } else {
+ // Create keywords that are prefixes of the full keywords starting at the
+ // specified minimum length.
+ this.#keywords = new Set();
+ for (let full of fullKeywords) {
+ for (let i = minLength; i <= full.length; i++) {
+ this.#keywords.add(full.substring(0, i));
+ }
+ }
+ }
+
+ this.#startFetching();
+ }
+
+ onPrefChanged(pref) {
+ if (pref == "weather.minKeywordLength") {
+ this.#updateKeywords();
+ }
+ }
+
+ observe(subject, topic, data) {
+ this.logger.debug(
+ "Observed notification: " + JSON.stringify({ topic, data })
+ );
+
+ switch (topic) {
+ case NOTIFICATIONS.CAPTIVE_PORTAL_LOGIN:
+ this.#onMaybeCameOnline();
+ break;
+ case NOTIFICATIONS.LINK_STATUS_CHANGED:
+ // This notificaton means the user's connection status changed. See
+ // nsINetworkLinkService.
+ if (data != "down") {
+ this.#onMaybeCameOnline();
+ }
+ break;
+ case NOTIFICATIONS.OFFLINE_STATUS_CHANGED:
+ // This notificaton means the user toggled the "Work Offline" pref.
+ // See nsIIOService.
+ if (data != "offline") {
+ this.#onMaybeCameOnline();
+ }
+ break;
+ case NOTIFICATIONS.WAKE:
+ this.#onWake();
+ break;
+ }
+ }
+
+ get _test_fetchDelayAfterComingOnlineMs() {
+ return this.#fetchDelayAfterComingOnlineMs;
+ }
+ set _test_fetchDelayAfterComingOnlineMs(ms) {
+ this.#fetchDelayAfterComingOnlineMs =
+ ms < 0 ? FETCH_DELAY_AFTER_COMING_ONLINE_MS : ms;
+ }
+
+ get _test_fetchIntervalMs() {
+ return this.#fetchIntervalMs;
+ }
+ set _test_fetchIntervalMs(ms) {
+ this.#fetchIntervalMs = ms < 0 ? FETCH_INTERVAL_MS : ms;
+ }
+
+ get _test_fetchTimer() {
+ return this.#fetchTimer;
+ }
+
+ get _test_lastFetchTimeMs() {
+ return this.#lastFetchTimeMs;
+ }
+
+ get _test_merino() {
+ return this.#merino;
+ }
+
+ get _test_pendingFetchCount() {
+ return this.#pendingFetchCount;
+ }
+
+ async _test_fetch() {
+ await this.#fetch();
+ }
+
+ _test_setSuggestionToNull() {
+ this.#suggestion = null;
+ }
+
+ _test_setTimeoutMs(ms) {
+ this.#timeoutMs = ms < 0 ? MERINO_TIMEOUT_MS : ms;
+ }
+
+ #fetchDelayAfterComingOnlineMs = FETCH_DELAY_AFTER_COMING_ONLINE_MS;
+ #fetchInstance = null;
+ #fetchIntervalMs = FETCH_INTERVAL_MS;
+ #fetchTimer = 0;
+ #keywords = null;
+ #lastFetchTimeMs = 0;
+ #merino = null;
+ #pendingFetchCount = 0;
+ #rsConfig = null;
+ #suggestion = null;
+ #timeoutMs = MERINO_TIMEOUT_MS;
+ #waitForFetchesDeferred = null;
+}
diff --git a/browser/components/urlbar/private/YelpSuggestions.sys.mjs b/browser/components/urlbar/private/YelpSuggestions.sys.mjs
new file mode 100644
index 0000000000..546c7ce216
--- /dev/null
+++ b/browser/components/urlbar/private/YelpSuggestions.sys.mjs
@@ -0,0 +1,264 @@
+/* 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 { BaseFeature } from "resource:///modules/urlbar/private/BaseFeature.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ QuickSuggest: "resource:///modules/QuickSuggest.sys.mjs",
+ MerinoClient: "resource:///modules/MerinoClient.sys.mjs",
+ UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs",
+ UrlbarResult: "resource:///modules/UrlbarResult.sys.mjs",
+ UrlbarUtils: "resource:///modules/UrlbarUtils.sys.mjs",
+});
+
+const RESULT_MENU_COMMAND = {
+ HELP: "help",
+ INACCURATE_LOCATION: "inaccurate_location",
+ NOT_INTERESTED: "not_interested",
+ NOT_RELEVANT: "not_relevant",
+ SHOW_LESS_FREQUENTLY: "show_less_frequently",
+};
+
+/**
+ * A feature for Yelp suggestions.
+ */
+export class YelpSuggestions extends BaseFeature {
+ get shouldEnable() {
+ return (
+ lazy.UrlbarPrefs.get("suggest.quicksuggest.sponsored") &&
+ lazy.UrlbarPrefs.get("yelpFeatureGate") &&
+ lazy.UrlbarPrefs.get("suggest.yelp")
+ );
+ }
+
+ get enablingPreferences() {
+ return ["suggest.quicksuggest.sponsored", "suggest.yelp"];
+ }
+
+ get rustSuggestionTypes() {
+ return ["Yelp"];
+ }
+
+ get showLessFrequentlyCount() {
+ const count = lazy.UrlbarPrefs.get("yelp.showLessFrequentlyCount") || 0;
+ return Math.max(count, 0);
+ }
+
+ get canShowLessFrequently() {
+ const cap =
+ lazy.UrlbarPrefs.get("yelpShowLessFrequentlyCap") ||
+ lazy.QuickSuggest.backend.config?.showLessFrequentlyCap ||
+ 0;
+ return !cap || this.showLessFrequentlyCount < cap;
+ }
+
+ getSuggestionTelemetryType(suggestion) {
+ return "yelp";
+ }
+
+ enable(enabled) {
+ if (!enabled) {
+ this.#merino = null;
+ }
+ }
+
+ async makeResult(queryContext, suggestion, searchString) {
+ // If the user clicked "Show less frequently" at least once or if the
+ // subject wasn't typed in full, then apply the min length threshold and
+ // return null if the entire search string is too short.
+ if (
+ (this.showLessFrequentlyCount || !suggestion.subjectExactMatch) &&
+ searchString.length < this.#minKeywordLength
+ ) {
+ return null;
+ }
+
+ suggestion.is_top_pick = lazy.UrlbarPrefs.get("yelpSuggestPriority");
+
+ let url = new URL(suggestion.url);
+ let title = suggestion.title;
+ if (!url.searchParams.has(suggestion.locationParam)) {
+ let city = await this.#fetchCity();
+
+ // If we can't get city from Merino, rely on Yelp own.
+ if (city) {
+ url.searchParams.set(suggestion.locationParam, city);
+
+ if (!suggestion.hasLocationSign) {
+ title += " in";
+ }
+
+ title += ` ${city}`;
+ }
+ }
+
+ url.searchParams.set("utm_medium", "partner");
+ url.searchParams.set("utm_source", "mozilla");
+
+ let resultProperties = {
+ isRichSuggestion: true,
+ richSuggestionIconSize: 38,
+ showFeedbackMenu: true,
+ };
+ if (!suggestion.is_top_pick) {
+ resultProperties.richSuggestionIconSize = 16;
+ resultProperties.isSuggestedIndexRelativeToGroup = true;
+ resultProperties.suggestedIndex = lazy.UrlbarPrefs.get(
+ "yelpSuggestNonPriorityIndex"
+ );
+ }
+
+ return Object.assign(
+ new lazy.UrlbarResult(
+ lazy.UrlbarUtils.RESULT_TYPE.URL,
+ lazy.UrlbarUtils.RESULT_SOURCE.SEARCH,
+ ...lazy.UrlbarResult.payloadAndSimpleHighlights(queryContext.tokens, {
+ url: url.toString(),
+ originalUrl: suggestion.url,
+ title: [title, lazy.UrlbarUtils.HIGHLIGHT.TYPED],
+ shouldShowUrl: true,
+ bottomTextL10n: { id: "firefox-suggest-yelp-bottom-text" },
+ })
+ ),
+ resultProperties
+ );
+ }
+
+ getResultCommands(result) {
+ let commands = [
+ {
+ name: RESULT_MENU_COMMAND.INACCURATE_LOCATION,
+ l10n: {
+ id: "firefox-suggest-weather-command-inaccurate-location",
+ },
+ },
+ ];
+
+ if (this.canShowLessFrequently) {
+ commands.push({
+ name: RESULT_MENU_COMMAND.SHOW_LESS_FREQUENTLY,
+ l10n: {
+ id: "firefox-suggest-command-show-less-frequently",
+ },
+ });
+ }
+
+ commands.push(
+ {
+ l10n: {
+ id: "firefox-suggest-command-dont-show-this",
+ },
+ children: [
+ {
+ name: RESULT_MENU_COMMAND.NOT_RELEVANT,
+ l10n: {
+ id: "firefox-suggest-command-not-relevant",
+ },
+ },
+ {
+ name: RESULT_MENU_COMMAND.NOT_INTERESTED,
+ l10n: {
+ id: "firefox-suggest-command-not-interested",
+ },
+ },
+ ],
+ },
+ { name: "separator" },
+ {
+ name: RESULT_MENU_COMMAND.HELP,
+ l10n: {
+ id: "urlbar-result-menu-learn-more-about-firefox-suggest",
+ },
+ }
+ );
+
+ return commands;
+ }
+
+ handleCommand(view, result, selType, searchString) {
+ switch (selType) {
+ case RESULT_MENU_COMMAND.HELP:
+ // "help" is handled by UrlbarInput, no need to do anything here.
+ break;
+ case RESULT_MENU_COMMAND.INACCURATE_LOCATION:
+ // Currently the only way we record this feedback is in the Glean
+ // engagement event. As with all commands, it will be recorded with an
+ // `engagement_type` value that is the command's name, in this case
+ // `inaccurate_location`.
+ view.acknowledgeFeedback(result);
+ break;
+ // selType == "dismiss" when the user presses the dismiss key shortcut.
+ case "dismiss":
+ case RESULT_MENU_COMMAND.NOT_RELEVANT:
+ lazy.QuickSuggest.blockedSuggestions.add(result.payload.originalUrl);
+ result.acknowledgeDismissalL10n = {
+ id: "firefox-suggest-dismissal-acknowledgment-one-yelp",
+ };
+ view.controller.removeResult(result);
+ break;
+ case RESULT_MENU_COMMAND.NOT_INTERESTED:
+ lazy.UrlbarPrefs.set("suggest.yelp", false);
+ result.acknowledgeDismissalL10n = {
+ id: "firefox-suggest-dismissal-acknowledgment-all-yelp",
+ };
+ view.controller.removeResult(result);
+ break;
+ case RESULT_MENU_COMMAND.SHOW_LESS_FREQUENTLY:
+ view.acknowledgeFeedback(result);
+ this.incrementShowLessFrequentlyCount();
+ if (!this.canShowLessFrequently) {
+ view.invalidateResultMenuCommands();
+ }
+ lazy.UrlbarPrefs.set("yelp.minKeywordLength", searchString.length + 1);
+ break;
+ }
+ }
+
+ incrementShowLessFrequentlyCount() {
+ if (this.canShowLessFrequently) {
+ lazy.UrlbarPrefs.set(
+ "yelp.showLessFrequentlyCount",
+ this.showLessFrequentlyCount + 1
+ );
+ }
+ }
+
+ get #minKeywordLength() {
+ // It's unusual to get both a Nimbus variable and its fallback pref at the
+ // same time, but we have a good reason. To recap, if a variable doesn't
+ // have a value, then the value of its fallback will be returned; otherwise
+ // the variable value will be returned. That's usually what we want, but for
+ // Yelp, we set the pref each time the user clicks "show less frequently",
+ // and we want the variable to act only as an initial min length. In other
+ // words, if the pref has a user value (because we set it), use it;
+ // otherwise use the initial value defined by the variable.
+ return Math.max(
+ lazy.UrlbarPrefs.get("yelpMinKeywordLength") || 0,
+ lazy.UrlbarPrefs.get("yelp.minKeywordLength") || 0,
+ 0
+ );
+ }
+
+ async #fetchCity() {
+ if (!this.#merino) {
+ this.#merino = new lazy.MerinoClient(this.constructor.name);
+ }
+
+ let results = await this.#merino.fetch({
+ providers: ["geolocation"],
+ query: "",
+ });
+
+ if (!results.length) {
+ return null;
+ }
+
+ let { city, region } = results[0].custom_details.geolocation;
+ return [city, region].filter(loc => !!loc).join(", ");
+ }
+
+ #merino = null;
+}