Source code
Revision control
Copy as Markdown
Other Tools
/* 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);
}
}