Bug 1943473 - Always set isSponsored on Suggest results and refactor "can add suggestion?" logic. r=daisuke

This makes a few changes:

Make sure we always set `result.payload.isSponsored` and base it on
`SuggestProvider.isSuggestionSponsored()` so that it's really clear. With that
change, we don't need to set `suggestion.is_sponsored` anymore. (I'm trying to
stop modifying suggestion objects so much because it's hard to follow.)

Don't call `feature.makeResult()` if the feature is disabled. I'm kind of
surprised we don't do this already, but it's always worked out in the end due to
a few reasons: (1) Some `feature.makeResult()` implementations return null if
their prefs are disabled (effectively duplicating their `shouldEnable` logic),
(2) we don't query the Rust component for disabled suggestions, and (3)
`#canAddSuggestion()` returns null if sponsored/nonsponsored suggestions are
disabled.

Replace `#canAddSuggestion()` with `#canAddResult()`. The logic can be
simplified and is easier to follow if we always deal with results instead of
suggestions. Examples: (1) We can check `result.payload.isSponsored` instead of
having to also set and check `suggestion.is_sponsored`. (2) When checking for
blocked suggestions, we can check `result.payload.originalUrl` instead of
leaking `suggestion.rawUrl` from the Rust component.

Differential Revision: https://phabricator.services.mozilla.com/D235389
This commit is contained in:
Drew Willcoxon
2025-01-27 23:24:41 +00:00
parent e84ef7f0ed
commit d7e7109d75
10 changed files with 96 additions and 87 deletions

View File

@@ -135,16 +135,16 @@ class ProviderQuickSuggest extends UrlbarProvider {
break; break;
} }
let canAdd = await this.#canAddSuggestion(suggestion); let result = await this.#makeResult(queryContext, suggestion);
if (instance != this.queryInstance) { if (instance != this.queryInstance) {
return; return;
} }
if (canAdd) { if (result) {
let result = await this.#makeResult(queryContext, suggestion); let canAdd = await this.#canAddResult(result);
if (instance != this.queryInstance) { if (instance != this.queryInstance) {
return; return;
} }
if (result) { if (canAdd) {
addCallback(this, result); addCallback(this, result);
if (!result.isHiddenExposure) { if (!result.isHiddenExposure) {
remainingCount--; remainingCount--;
@@ -163,8 +163,9 @@ class ProviderQuickSuggest extends UrlbarProvider {
for (let i = 0; i < suggestions.length; i++) { for (let i = 0; i < suggestions.length; i++) {
let suggestion = suggestions[i]; let suggestion = suggestions[i];
// Discard suggestions that don't have the required keys, which are used // Discard the suggestion if it doesn't have the properties required to
// to look up their features. Normally this shouldn't ever happen. // get the feature that manages it. Each backend should set these, so this
// should never happen.
if (!requiredKeys.every(key => suggestion[key])) { if (!requiredKeys.every(key => suggestion[key])) {
this.logger.error("Suggestion is missing one or more required keys", { this.logger.error("Suggestion is missing one or more required keys", {
requiredKeys, requiredKeys,
@@ -173,14 +174,7 @@ class ProviderQuickSuggest extends UrlbarProvider {
continue; continue;
} }
// Set `is_sponsored` before continuing because // Ensure the suggestion has a score.
// `#getSuggestionTelemetryType()` and other things depend on it.
let feature = this.#getFeature(suggestion);
if (!suggestion.hasOwnProperty("is_sponsored")) {
suggestion.is_sponsored = !!feature?.isSuggestionSponsored(suggestion);
}
// Ensure all suggestions have scores.
// //
// Step 1: Set a default score if the suggestion doesn't have one. // Step 1: Set a default score if the suggestion doesn't have one.
if (typeof suggestion.score != "number" || isNaN(suggestion.score)) { if (typeof suggestion.score != "number" || isNaN(suggestion.score)) {
@@ -206,6 +200,8 @@ class ProviderQuickSuggest extends UrlbarProvider {
} }
// Save some state used below to build the final list of suggestions. // Save some state used below to build the final list of suggestions.
// `feature` will be null if the suggestion isn't managed by one.
let feature = this.#getFeature(suggestion);
let featureSuggestions = suggestionsByFeature.get(feature); let featureSuggestions = suggestionsByFeature.get(feature);
if (!featureSuggestions) { if (!featureSuggestions) {
featureSuggestions = []; featureSuggestions = [];
@@ -216,7 +212,7 @@ class ProviderQuickSuggest extends UrlbarProvider {
} }
// Let each feature filter its suggestions. // Let each feature filter its suggestions.
suggestions = ( let filteredSuggestions = (
await Promise.all( await Promise.all(
[...suggestionsByFeature].map(([feature, featureSuggestions]) => [...suggestionsByFeature].map(([feature, featureSuggestions]) =>
feature feature
@@ -228,14 +224,14 @@ class ProviderQuickSuggest extends UrlbarProvider {
// Sort the suggestions. When scores are equal, sort by original index to // Sort the suggestions. When scores are equal, sort by original index to
// ensure a stable sort. // ensure a stable sort.
suggestions.sort((a, b) => { filteredSuggestions.sort((a, b) => {
return ( return (
b.score - a.score || b.score - a.score ||
indexesBySuggestion.get(a) - indexesBySuggestion.get(b) indexesBySuggestion.get(a) - indexesBySuggestion.get(b)
); );
}); });
return suggestions; return filteredSuggestions;
} }
onImpression(state, queryContext, controller, resultsAndIndexes, details) { onImpression(state, queryContext, controller, resultsAndIndexes, details) {
@@ -370,35 +366,32 @@ class ProviderQuickSuggest extends UrlbarProvider {
} }
async #makeResult(queryContext, suggestion) { async #makeResult(queryContext, suggestion) {
let result; let result = null;
let feature = this.#getFeature(suggestion); let feature = this.#getFeature(suggestion);
if (!feature) { if (!feature) {
// We specifically allow Merino to serve suggestion types that Firefox result = this.#makeUnmanagedResult(queryContext, suggestion);
// doesn't know about so that we can experiment with new types without } else if (feature.isEnabled) {
// requiring changes in Firefox. No other source should return unknown
// suggestion types with the possible exception of the ML backend: Its
// models are stored in remote settings and it may return newer intents
// that aren't recognized by older Firefoxes.
if (suggestion.source != "merino") {
return null;
}
result = this.#makeDefaultResult(queryContext, suggestion);
} else {
result = await feature.makeResult( result = await feature.makeResult(
queryContext, queryContext,
suggestion, suggestion,
this._trimmedSearchString this._trimmedSearchString
); );
if (!result) {
// Feature might return null, if the feature is disabled and so on.
return null;
}
} }
// See `#getFeature()` for possible values of `source` and `provider`. if (!result) {
return null;
}
// Set important properties that every Suggest result should have. See
// `#getFeature()` for possible values of `source` and `provider`. If the
// suggestion isn't managed by a feature, then it's from Merino and has
// `is_sponsored` set if it's sponsored. (Merino uses snake_case.)
result.payload.source = suggestion.source; result.payload.source = suggestion.source;
result.payload.provider = suggestion.provider; result.payload.provider = suggestion.provider;
result.payload.telemetryType = this.#getSuggestionTelemetryType(suggestion); result.payload.telemetryType = this.#getSuggestionTelemetryType(suggestion);
result.payload.isSponsored = feature
? feature.isSuggestionSponsored(suggestion)
: !!suggestion.is_sponsored;
// Handle icons here so each feature doesn't have to do it, but use `||=` to // Handle icons here so each feature doesn't have to do it, but use `||=` to
// let them do it if they need to. // let them do it if they need to.
@@ -420,7 +413,7 @@ class ProviderQuickSuggest extends UrlbarProvider {
result.suggestedIndex = suggestion.position; result.suggestedIndex = suggestion.position;
} else { } else {
result.isSuggestedIndexRelativeToGroup = true; result.isSuggestedIndexRelativeToGroup = true;
if (!suggestion.is_sponsored) { if (!result.payload.isSponsored) {
result.suggestedIndex = lazy.UrlbarPrefs.get( result.suggestedIndex = lazy.UrlbarPrefs.get(
"quickSuggestNonSponsoredIndex" "quickSuggestNonSponsoredIndex"
); );
@@ -431,11 +424,12 @@ class ProviderQuickSuggest extends UrlbarProvider {
queryContext.isPrivate queryContext.isPrivate
).supportsResponseType(lazy.SearchUtils.URL_TYPE.SUGGEST_JSON) ).supportsResponseType(lazy.SearchUtils.URL_TYPE.SUGGEST_JSON)
) { ) {
// Show sponsored suggestions somewhere other than the bottom of the // Allow sponsored suggestions to be shown somewhere other than the
// Suggest section only if search suggestions are shown first, the // bottom of the Suggest section (-1, the `else` branch below) only if
// search suggestions provider is active for the current context (it // search suggestions are shown first, the search suggestions provider
// will not be active if search suggestions are disabled, among other // is active for the current context (it will not be active if search
// reasons), and the default engine supports suggestions. // suggestions are disabled, among other reasons), and the default
// engine supports suggestions.
result.suggestedIndex = lazy.UrlbarPrefs.get( result.suggestedIndex = lazy.UrlbarPrefs.get(
"quickSuggestSponsoredIndex" "quickSuggestSponsoredIndex"
); );
@@ -448,10 +442,32 @@ class ProviderQuickSuggest extends UrlbarProvider {
return result; return result;
} }
#makeDefaultResult(queryContext, suggestion) { /**
* Returns a new result for an unmanaged suggestion. An "unmanaged" suggestion
* is a suggestion without a feature.
*
* Merino is the only backend allowed to serve unmanaged suggestions, for a
* couple of reasons: (1) Some suggestion types aren't that complicated and
* can be handled in a default manner, for example dynamic Wikipedia
* suggestions. (2) It allows us to experiment with new suggestion types
* without requiring any changes to Firefox.
*
* @param {UrlbarQueryContext} queryContext
* The query context.
* @param {object} suggestion
* The suggestion.
* @returns {UrlbarResult|null}
* A new result for the suggestion or null if the suggestion is not from
* Merino.
*/
#makeUnmanagedResult(queryContext, suggestion) {
if (suggestion.source != "merino") {
return null;
}
// Note that Merino uses snake_case keys.
let payload = { let payload = {
url: suggestion.url, url: suggestion.url,
isSponsored: suggestion.is_sponsored,
isBlockable: true, isBlockable: true,
blockL10n: { blockL10n: {
id: "urlbar-result-menu-dismiss-firefox-suggest", id: "urlbar-result-menu-dismiss-firefox-suggest",
@@ -554,62 +570,49 @@ class ProviderQuickSuggest extends UrlbarProvider {
} }
/** /**
* Returns whether a given suggestion can be added for a query, assuming the * Returns whether a given result can be added for a query, assuming the
* provider itself should be active. * provider itself should be active.
* *
* @param {object} suggestion * @param {UrlbarResult} result
* The suggestion to check. * The result to check.
* @returns {boolean} * @returns {boolean}
* Whether the suggestion can be added. * Whether the result can be added.
*/ */
async #canAddSuggestion(suggestion) { async #canAddResult(result) {
this.logger.debug("Checking if suggestion can be added", suggestion); // Discard the result if it's not managed by a feature and its sponsored
// state isn't allowed.
// Return false if suggestions are disabled. Always allow Rust exposure //
// suggestions. // This isn't necessary when the result is managed because in that case: If
// its feature is disabled, we didn't create a result in the first place; if
// its feature is enabled, we delegate responsibility to it for either
// creating or not creating its results.
//
// Also note that it's possible for suggestion types to be considered
// neither sponsored nor nonsponsored. In other words, the decision to add
// them or not does not depend on the prefs in this conditional. Such types
// should always be managed. Exposure suggestions are an example.
let feature = this.#getFeatureByResult(result);
if ( if (
((suggestion.is_sponsored && !feature &&
((result.payload.isSponsored &&
!lazy.UrlbarPrefs.get("suggest.quicksuggest.sponsored")) || !lazy.UrlbarPrefs.get("suggest.quicksuggest.sponsored")) ||
(!suggestion.is_sponsored && (!result.payload.isSponsored &&
!lazy.UrlbarPrefs.get("suggest.quicksuggest.nonsponsored"))) && !lazy.UrlbarPrefs.get("suggest.quicksuggest.nonsponsored")))
(suggestion.source != "rust" || suggestion.provider != "Exposure")
) { ) {
this.logger.debug("Suggestions disabled, not adding suggestion");
return false; return false;
} }
// Return false if an impression cap has been hit. // Discard the result if its URL is blocked. For some Suggest results, `url`
if ( // is a value that is modified at query time and that is potentially unique
(suggestion.is_sponsored && // per query. For example, it might contain timestamps or query-related
lazy.UrlbarPrefs.get("quickSuggestImpressionCapsSponsoredEnabled")) || // search params. Those results will also have an `originalUrl` that is the
(!suggestion.is_sponsored && // unmodified URL, and it should be used for blocking purposes.
lazy.UrlbarPrefs.get("quickSuggestImpressionCapsNonSponsoredEnabled")) let url = result.payload.originalUrl || result.payload.url;
) { if (await lazy.QuickSuggest.blockedSuggestions.has(url)) {
let type = suggestion.is_sponsored ? "sponsored" : "nonsponsored";
let hitStats = lazy.QuickSuggest.impressionCaps.getHitStats(type);
if (hitStats) {
this.logger.debug("Impression cap(s) hit, not adding suggestion", {
type,
hitStats,
});
return false;
}
}
// Return false if the suggestion is blocked based on its URL. Suggestions
// from the JS backend define a single `url` property. Suggestions from the
// Rust backend are more complicated: Sponsored suggestions define `rawUrl`,
// which may contain timestamp templates, while non-sponsored suggestions
// define only `url`. Blocking should always be based on URLs with timestamp
// templates, where applicable, so check `rawUrl` and then `url`, in that
// order.
let { blockedSuggestions } = lazy.QuickSuggest;
if (await blockedSuggestions.has(suggestion.rawUrl ?? suggestion.url)) {
this.logger.debug("Suggestion blocked, not adding suggestion"); this.logger.debug("Suggestion blocked, not adding suggestion");
return false; return false;
} }
this.logger.debug("Suggestion can be added");
return true; return true;
} }

View File

@@ -102,7 +102,6 @@ export class AmpSuggestions extends SuggestProvider {
suggestion = { suggestion = {
title: suggestion.title, title: suggestion.title,
url: suggestion.url, url: suggestion.url,
is_sponsored: suggestion.is_sponsored,
fullKeyword: suggestion.full_keyword, fullKeyword: suggestion.full_keyword,
impressionUrl: suggestion.impression_url, impressionUrl: suggestion.impression_url,
clickUrl: suggestion.click_url, clickUrl: suggestion.click_url,
@@ -117,7 +116,6 @@ export class AmpSuggestions extends SuggestProvider {
originalUrl, originalUrl,
url: suggestion.url, url: suggestion.url,
title: suggestion.title, title: suggestion.title,
isSponsored: suggestion.is_sponsored,
requestId: suggestion.requestId, requestId: suggestion.requestId,
urlTimestampIndex: suggestion.urlTimestampIndex, urlTimestampIndex: suggestion.urlTimestampIndex,
sponsoredImpressionUrl: suggestion.impressionUrl, sponsoredImpressionUrl: suggestion.impressionUrl,

View File

@@ -46,7 +46,6 @@ export class OfflineWikipediaSuggestions extends SuggestProvider {
suggestion.fullKeyword, suggestion.fullKeyword,
lazy.UrlbarUtils.HIGHLIGHT.SUGGESTED, lazy.UrlbarUtils.HIGHLIGHT.SUGGESTED,
], ],
isSponsored: suggestion.is_sponsored,
sponsoredAdvertiser: "Wikipedia", sponsoredAdvertiser: "Wikipedia",
sponsoredIabCategory: "5 - Education", sponsoredIabCategory: "5 - Education",
isBlockable: true, isBlockable: true,

View File

@@ -832,6 +832,7 @@ class _QuickSuggestTestUtils {
originalUrl, originalUrl,
icon, icon,
displayUrl: url.replace(/^https:\/\//, ""), displayUrl: url.replace(/^https:\/\//, ""),
isSponsored: false,
shouldShowUrl: true, shouldShowUrl: true,
bottomTextL10n: { id: "firefox-suggest-addons-recommended" }, bottomTextL10n: { id: "firefox-suggest-addons-recommended" },
helpUrl: lazy.QuickSuggest.HELP_URL, helpUrl: lazy.QuickSuggest.HELP_URL,
@@ -869,6 +870,7 @@ class _QuickSuggestTestUtils {
url: finalUrl.href, url: finalUrl.href,
originalUrl: url, originalUrl: url,
displayUrl: finalUrl.href.replace(/^https:\/\//, ""), displayUrl: finalUrl.href.replace(/^https:\/\//, ""),
isSponsored: false,
description, description,
icon: "chrome://global/skin/icons/mdn.svg", icon: "chrome://global/skin/icons/mdn.svg",
shouldShowUrl: true, shouldShowUrl: true,
@@ -928,6 +930,7 @@ class _QuickSuggestTestUtils {
}, },
source, source,
provider, provider,
isSponsored: true,
telemetryType: "weather", telemetryType: "weather",
}, },
}; };

View File

@@ -480,6 +480,7 @@ function makeExpectedResult(exposureSuggestionType) {
dynamicType: "exposure", dynamicType: "exposure",
provider: "Exposure", provider: "Exposure",
telemetryType: "exposure", telemetryType: "exposure",
isSponsored: false,
}, },
}; };
} }

View File

@@ -254,6 +254,7 @@ function makeExpectedExposureResult(exposureSuggestionType) {
dynamicType: "exposure", dynamicType: "exposure",
provider: "Exposure", provider: "Exposure",
telemetryType: "exposure", telemetryType: "exposure",
isSponsored: false,
}, },
}; };
} }

View File

@@ -857,6 +857,7 @@ function makeExpectedResult({
totalReviews, totalReviews,
fakespotGrade, fakespotGrade,
fakespotProvider, fakespotProvider,
isSponsored: true,
dynamicType: "fakespot", dynamicType: "fakespot",
icon: null, icon: null,
}, },

View File

@@ -529,6 +529,7 @@ function makeExpectedResult({
icon: isTopPick icon: isTopPick
? "chrome://global/skin/icons/pocket.svg" ? "chrome://global/skin/icons/pocket.svg"
: "chrome://global/skin/icons/pocket-favicon.ico", : "chrome://global/skin/icons/pocket-favicon.ico",
isSponsored: false,
helpUrl: QuickSuggest.HELP_URL, helpUrl: QuickSuggest.HELP_URL,
shouldShowUrl: true, shouldShowUrl: true,
bottomTextL10n: { bottomTextL10n: {

View File

@@ -1128,6 +1128,7 @@ function makeExpectedResult({
title, title,
displayUrl, displayUrl,
icon: null, icon: null,
isSponsored: true,
}, },
}; };
} }

View File

@@ -588,6 +588,7 @@ function makeExpectedResult({
title, title,
displayUrl, displayUrl,
icon: null, icon: null,
isSponsored: true,
}, },
}; };
} }