diff options
Diffstat (limited to 'browser/components/urlbar/private')
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; +} |