/* 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 { SuggestProvider } from "moz-src:///browser/components/urlbar/private/SuggestFeature.sys.mjs"; const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { QuickSuggest: "moz-src:///browser/components/urlbar/QuickSuggest.sys.mjs", UrlbarPrefs: "moz-src:///browser/components/urlbar/UrlbarPrefs.sys.mjs", UrlbarResult: "moz-src:///browser/components/urlbar/UrlbarResult.sys.mjs", UrlbarSearchUtils: "moz-src:///browser/components/urlbar/UrlbarSearchUtils.sys.mjs", UrlbarUtils: "moz-src:///browser/components/urlbar/UrlbarUtils.sys.mjs", }); /** * A Suggest feature that manages realtime suggestions (a.k.a. "carrots"), * including both opt-in and online suggestions for a given realtime type. It is * intended to be subclassed rather than used as is. * * Each subclass should manage one realtime type. If the user has not opted in * to online suggestions, this class will serve the realtime type's opt-in * suggestion. Once the user has opted in, it will switch to serving online * Merino suggestions for the realtime type. */ export class RealtimeSuggestProvider extends SuggestProvider { // The following methods must be overridden. /** * The type of the realtime suggestion provider. * * @type {string} */ get realtimeType() { throw new Error("Trying to access the base class, must be overridden"); } getViewTemplateForDescriptionTop(_item, _index) { throw new Error("Trying to access the base class, must be overridden"); } getViewTemplateForDescriptionBottom(_item, _index) { throw new Error("Trying to access the base class, must be overridden"); } getViewUpdateForPayloadItem(_item, _index) { throw new Error("Trying to access the base class, must be overridden"); } // The following getters depend on `realtimeType` and should be overridden as // necessary. /** * @returns {string[]} * The opt-in suggestion is a dynamic Rust suggestion. `suggestion_type` in * the RS record is `${this.realtimeType}_opt_in` by default. */ get dynamicRustSuggestionTypes() { return [this.realtimeType + "_opt_in"]; } /** * @returns {string} * The online suggestions are served by Merino. The Merino provider is * `this.realtimeType` by default. */ get merinoProvider() { return this.realtimeType; } get baseTelemetryType() { return this.realtimeType; } get realtimeTypeForFtl() { return this.realtimeType.replace(/([A-Z])/g, "-$1").toLowerCase(); } get featureGatePref() { return this.realtimeType + "FeatureGate"; } get suggestPref() { return "suggest." + this.realtimeType; } get minKeywordLengthPref() { return this.realtimeType + ".minKeywordLength"; } get showLessFrequentlyCountPref() { return this.realtimeType + ".showLessFrequentlyCount"; } get optInIcon() { return `chrome://browser/skin/illustrations/${this.realtimeType}-opt-in.svg`; } get optInTitleL10n() { return { id: `urlbar-result-${this.realtimeTypeForFtl}-opt-in-title`, }; } get optInDescriptionL10n() { return { id: `urlbar-result-${this.realtimeTypeForFtl}-opt-in-description`, parseMarkup: true, }; } get notInterestedCommandL10n() { return { id: "urlbar-result-menu-dont-show-" + this.realtimeTypeForFtl, }; } get acknowledgeDismissalL10n() { return { id: "urlbar-result-dismissal-acknowledgment-" + this.realtimeTypeForFtl, }; } get ariaGroupL10n() { return { id: "urlbar-result-aria-group-" + this.realtimeTypeForFtl, attribute: "aria-label", }; } get isSponsored() { return false; } /** * @returns {string} * The dynamic result type that will be set in the Merino result's payload * as `result.payload.dynamicType`. Note that "dynamic" here refers to the * concept of dynamic result types as used in the view and * `UrlbarUtils.RESULT_TYPE.DYNAMIC`, not Rust dynamic suggestions. * * If you override this, make sure the value starts with "realtime-" because * there are CSS rules that depend on that. */ get dynamicResultType() { return "realtime-" + this.realtimeType; } // The following methods can be overridden but hopefully it's not necessary. get rustSuggestionType() { return "Dynamic"; } get enablingPreferences() { return [ "suggest.quicksuggest.all", "suggest.realtimeOptIn", "quicksuggest.realtimeOptIn.dismissTypes", "quicksuggest.realtimeOptIn.notNowTimeSeconds", "quicksuggest.realtimeOptIn.notNowReshowAfterPeriodDays", "quickSuggestOnlineAvailable", "quicksuggest.online.enabled", this.featureGatePref, this.suggestPref, // We could include the sponsored pref only if `this.isSponsored` is true, // but for maximum flexibility `this.isSponsored` is only a fallback for // when individual suggestions do not have an `isSponsored` property. // Since individual suggestions may be sponsored or not, we include the // pref here. "suggest.quicksuggest.sponsored", ]; } get primaryUserControlledPreferences() { return [ "suggest.realtimeOptIn", "quicksuggest.realtimeOptIn.dismissTypes", "quicksuggest.realtimeOptIn.notNowTimeSeconds", "quicksuggest.realtimeOptIn.notNowReshowAfterPeriodDays", this.suggestPref, ]; } get shouldEnable() { if ( !lazy.UrlbarPrefs.get(this.featureGatePref) || !lazy.UrlbarPrefs.get("quickSuggestOnlineAvailable") || !lazy.UrlbarPrefs.get("suggest.quicksuggest.all") ) { // The feature gate is disabled, online suggestions aren't available, or // all Suggest suggestions are disabled. Don't show opt-in or online // suggestions for this realtime type. return false; } if (lazy.UrlbarPrefs.get("quicksuggest.online.enabled")) { // Online suggestions are enabled. Show this realtime type if the user // didn't disable it. return lazy.UrlbarPrefs.get(this.suggestPref); } if (!lazy.UrlbarPrefs.get("suggest.realtimeOptIn")) { // The user dismissed opt-in suggestions for all realtime types. return false; } let dismissTypes = lazy.UrlbarPrefs.get( "quicksuggest.realtimeOptIn.dismissTypes" ); if (dismissTypes.has(this.realtimeType)) { // The user dismissed opt-in suggestions for this realtime type. return false; } let notNowTimeSeconds = lazy.UrlbarPrefs.get( "quicksuggest.realtimeOptIn.notNowTimeSeconds" ); if (!notNowTimeSeconds) { return true; } let notNowReshowAfterPeriodDays = lazy.UrlbarPrefs.get( "quicksuggest.realtimeOptIn.notNowReshowAfterPeriodDays" ); let timeSecs = notNowReshowAfterPeriodDays * 24 * 60 * 60; return Date.now() / 1000 - notNowTimeSeconds > timeSecs; } isSuggestionSponsored(suggestion) { switch (suggestion.source) { case "merino": if (suggestion.hasOwnProperty("is_sponsored")) { return !!suggestion.is_sponsored; } break; case "rust": if (suggestion.data?.result?.payload?.hasOwnProperty("isSponsored")) { return suggestion.data.result.payload.isSponsored; } break; } return this.isSponsored; } /** * The telemetry type for a suggestion from this provider. (This string does * not include the `${source}_` prefix, e.g., "rust_".) * * Since realtime providers serve two types of suggestions, the opt-in and the * online suggestion, this will return two possible telemetry types depending * on the passed-in suggestion. Telemetry types for each are: * * Opt-in suggestion: `${this.baseTelemetryType}_opt_in` * Online suggestion: this.baseTelemetryType * * Individual suggestions can override these telemetry types, but that's * expected to be uncommon. * * @param {object} suggestion * A suggestion from this provider. * @returns {string} * The suggestion's telemetry type. */ getSuggestionTelemetryType(suggestion) { switch (suggestion.source) { case "merino": if (suggestion.hasOwnProperty("telemetry_type")) { return suggestion.telemetry_type; } break; case "rust": if (suggestion.data?.result?.payload?.hasOwnProperty("telemetryType")) { return suggestion.data.result.payload.telemetryType; } return this.baseTelemetryType + "_opt_in"; } return this.baseTelemetryType; } filterSuggestions(suggestions) { // The Rust opt-in suggestion can always be matched regardless of whether // online is enabled, so return only Merino suggestions when it is enabled. if (lazy.UrlbarPrefs.get("quicksuggest.online.enabled")) { return suggestions.filter(s => s.source == "merino"); } return suggestions; } makeResult(queryContext, suggestion, searchString) { // For maximum flexibility individual suggestions can indicate whether they // are sponsored or not, despite `this.isSponsored`, which is a fallback. if ( !lazy.UrlbarPrefs.get("suggest.quicksuggest.all") || (this.isSuggestionSponsored(suggestion) && !lazy.UrlbarPrefs.get("suggest.quicksuggest.sponsored")) ) { return null; } switch (suggestion.source) { case "merino": return this.makeMerinoResult(queryContext, suggestion, searchString); case "rust": return this.makeOptInResult(queryContext, suggestion); } return null; } makeMerinoResult( queryContext, suggestion, searchString, additionalOptions = {} ) { if (!this.isEnabled) { return null; } if ( this.showLessFrequentlyCount && searchString.length < this.#minKeywordLength ) { return null; } let values = suggestion.custom_details?.[this.merinoProvider]?.values; if (!values?.length) { return null; } let engine; if (values.some(v => v.query)) { engine = lazy.UrlbarSearchUtils.getDefaultEngine(queryContext.isPrivate); if (!engine) { return null; } } let result = new lazy.UrlbarResult({ type: lazy.UrlbarUtils.RESULT_TYPE.DYNAMIC, source: lazy.UrlbarUtils.RESULT_SOURCE.SEARCH, isBestMatch: true, ...additionalOptions, payload: { items: values.map((v, i) => this.makePayloadItem(v, i)), dynamicType: this.dynamicResultType, engine: engine?.name, }, }); return result; } /** * Returns the object that should be stored as `result.payload.items[i]` for * the Merino result. The default implementation here returns the * corresponding value in the suggestion. * * It's useful to override this if there's a significant amount of logic * that's used by the different code paths of the view update. In that case, * you can override this method, perform the logic, store the results in the * item, and then your different view update paths can all use it. * * @param {object} value * The value in the suggestion's `values` array. * @param {number} _index * The index of the value in the array. * @returns {object} * The object that should be stored in `result.payload.items[_index]`. */ makePayloadItem(value, _index) { return value; } makeOptInResult(queryContext, _suggestion) { let notNowTypes = lazy.UrlbarPrefs.get( "quicksuggest.realtimeOptIn.notNowTypes" ); let splitButtonMain = notNowTypes.has(this.realtimeType) ? { command: "dismiss", l10n: { id: "urlbar-result-realtime-opt-in-dismiss", }, } : { command: "not_now", l10n: { id: "urlbar-result-realtime-opt-in-not-now", }, }; return new lazy.UrlbarResult({ type: lazy.UrlbarUtils.RESULT_TYPE.TIP, source: lazy.UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, isBestMatch: true, payload: { // This `type` is the tip type, required for `TIP` results. type: "realtime_opt_in", icon: this.optInIcon, titleL10n: this.optInTitleL10n, descriptionL10n: this.optInDescriptionL10n, descriptionLearnMoreTopic: lazy.QuickSuggest.HELP_TOPIC, buttons: [ { command: "opt_in", l10n: { id: "urlbar-result-realtime-opt-in-allow", }, input: queryContext.searchString, attributes: { primary: "", }, }, { ...splitButtonMain, menu: [ { name: "not_interested", l10n: { id: "urlbar-result-realtime-opt-in-dismiss-all", }, }, ], }, ], }, }); } getViewTemplate(result) { let { items } = result.payload; let hasMultipleItems = items.length > 1; return { name: "root", overflowable: true, attributes: { selectable: hasMultipleItems ? null : "", role: hasMultipleItems ? "group" : "option", }, classList: ["urlbarView-realtime-root"], children: items.map((item, i) => ({ name: `item_${i}`, tag: "span", classList: ["urlbarView-realtime-item"], attributes: { selectable: !hasMultipleItems ? null : "", role: hasMultipleItems ? "option" : "presentation", }, children: [ // Create an image inside a container so that the image appears inset // into a square. This is atypical because we normally use only an // image and give it padding and a background color to achieve that // effect, but that only works when the image size is fixed. // Unfortunately Merino serves market icons of different sizes due to // its reliance on a third-party API. { name: `image_container_${i}`, tag: "span", classList: ["urlbarView-realtime-image-container"], children: this.getViewTemplateForImage(item, i), }, { tag: "span", classList: ["urlbarView-realtime-description"], children: [ { tag: "div", classList: ["urlbarView-realtime-description-top"], children: this.getViewTemplateForDescriptionTop(item, i), }, { tag: "div", classList: ["urlbarView-realtime-description-bottom"], children: this.getViewTemplateForDescriptionBottom(item, i), }, ], }, ], })), }; } /** * Returns the view template inside the `image_container`. This default * implementation creates an `img` element. Override it if you need something * else. * * @param {object} _item * An item from the `result.payload.items` array. * @param {number} index * The index of the item in the array. * @returns {Array} * View template for the image, an array of objects. */ getViewTemplateForImage(_item, index) { return [ { name: `image_${index}`, tag: "img", classList: ["urlbarView-realtime-image"], }, ]; } getViewUpdate(result) { let { items } = result.payload; let hasMultipleItems = items.length > 1; let update = { root: { dataset: { // This `url` or `query` will be used when there's only one item. url: items[0].url, query: items[0].query, }, l10n: hasMultipleItems ? this.ariaGroupL10n : null, }, }; for (let i = 0; i < items.length; i++) { let item = items[i]; Object.assign(update, this.getViewUpdateForPayloadItem(item, i)); // These `url` or `query`s will be used when there are multiple items. let itemName = `item_${i}`; update[itemName] ??= {}; update[itemName].dataset ??= {}; update[itemName].dataset.url ??= item.url; update[itemName].dataset.query ??= item.query; } return update; } getResultCommands(result) { if (result.payload.source == "rust") { // The opt-in result should not have a result menu. return null; } /** @type {UrlbarResultCommand[]} */ let commands = []; if (this.canShowLessFrequently) { commands.push({ name: "show_less_frequently", l10n: { id: "urlbar-result-menu-show-less-frequently", }, }); } commands.push( { name: "not_interested", l10n: this.notInterestedCommandL10n, }, { name: "separator" }, { name: "manage", l10n: { id: "urlbar-result-menu-manage-firefox-suggest", }, }, { name: "help", l10n: { id: "urlbar-result-menu-learn-more", }, } ); return commands; } onEngagement(queryContext, controller, details, searchString) { switch (details.result.payload.source) { case "merino": this.onMerinoEngagement( queryContext, controller, details, searchString ); break; case "rust": this.onOptInEngagement(queryContext, controller, details, searchString); break; } } onMerinoEngagement(queryContext, controller, details, searchString) { let { result } = details; switch (details.selType) { case "help": case "manage": { // "help" and "manage" are handled by UrlbarInput, no need to do // anything here. break; } case "not_interested": { lazy.UrlbarPrefs.set(this.suggestPref, false); result.acknowledgeDismissalL10n = this.acknowledgeDismissalL10n; controller.removeResult(result); break; } case "show_less_frequently": { controller.view.acknowledgeFeedback(result); this.incrementShowLessFrequentlyCount(); if (!this.canShowLessFrequently) { controller.view.invalidateResultMenuCommands(); } lazy.UrlbarPrefs.set( this.minKeywordLengthPref, searchString.length + 1 ); break; } } } onOptInEngagement(queryContext, controller, details, _searchString) { switch (details.selType) { case "opt_in": lazy.UrlbarPrefs.set("quicksuggest.online.enabled", true); controller.input.startQuery({ allowAutofill: false }); break; case "not_now": { lazy.UrlbarPrefs.set( "quicksuggest.realtimeOptIn.notNowTimeSeconds", Date.now() / 1000 ); lazy.UrlbarPrefs.add( "quicksuggest.realtimeOptIn.notNowTypes", this.realtimeType ); controller.removeResult(details.result); break; } case "dismiss": { lazy.UrlbarPrefs.add( "quicksuggest.realtimeOptIn.dismissTypes", this.realtimeType ); details.result.acknowledgeDismissalL10n = this.acknowledgeDismissalL10n; controller.removeResult(details.result); break; } case "not_interested": { lazy.UrlbarPrefs.set("suggest.realtimeOptIn", false); details.result.acknowledgeDismissalL10n = { id: "urlbar-result-dismissal-acknowledgment-all", }; controller.removeResult(details.result); break; } } } incrementShowLessFrequentlyCount() { if (this.canShowLessFrequently) { lazy.UrlbarPrefs.set( this.showLessFrequentlyCountPref, this.showLessFrequentlyCount + 1 ); } } get showLessFrequentlyCount() { const pref = this.showLessFrequentlyCountPref; const count = lazy.UrlbarPrefs.get(pref) || 0; return Math.max(count, 0); } get canShowLessFrequently() { const cap = lazy.UrlbarPrefs.get("realtimeShowLessFrequentlyCap") || lazy.QuickSuggest.config.showLessFrequentlyCap || 0; return !cap || this.showLessFrequentlyCount < cap; } get #minKeywordLength() { let hasUserValue = Services.prefs.prefHasUserValue( "browser.urlbar." + this.minKeywordLengthPref ); let nimbusValue = lazy.UrlbarPrefs.get("realtimeMinKeywordLength"); let minLength = hasUserValue || nimbusValue === null ? lazy.UrlbarPrefs.get(this.minKeywordLengthPref) : nimbusValue; return Math.max(minLength, 0); } }