389 lines
12 KiB
JavaScript
389 lines
12 KiB
JavaScript
/* 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 "resource:///modules/urlbar/private/SuggestFeature.sys.mjs";
|
|
|
|
const lazy = {};
|
|
|
|
ChromeUtils.defineESModuleGetters(lazy, {
|
|
AmpMatchingStrategy:
|
|
"moz-src:///toolkit/components/uniffi-bindgen-gecko-js/components/generated/RustSuggest.sys.mjs",
|
|
CONTEXTUAL_SERVICES_PING_TYPES:
|
|
"resource:///modules/PartnerLinkAttribution.sys.mjs",
|
|
ContextId: "moz-src:///browser/modules/ContextId.sys.mjs",
|
|
QuickSuggest: "resource:///modules/QuickSuggest.sys.mjs",
|
|
rawSuggestionUrlMatches:
|
|
"moz-src:///toolkit/components/uniffi-bindgen-gecko-js/components/generated/RustSuggest.sys.mjs",
|
|
Region: "resource://gre/modules/Region.sys.mjs",
|
|
UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs",
|
|
UrlbarResult: "resource:///modules/UrlbarResult.sys.mjs",
|
|
UrlbarUtils: "resource:///modules/UrlbarUtils.sys.mjs",
|
|
});
|
|
|
|
const TIMESTAMP_TEMPLATE = "%YYYYMMDDHH%";
|
|
const TIMESTAMP_LENGTH = 10;
|
|
const TIMESTAMP_REGEXP = /^\d{10}$/;
|
|
|
|
/**
|
|
* A feature that manages AMP suggestions.
|
|
*/
|
|
export class AmpSuggestions extends SuggestProvider {
|
|
get enablingPreferences() {
|
|
return ["suggest.quicksuggest.sponsored"];
|
|
}
|
|
|
|
get primaryUserControlledPreference() {
|
|
// AMP suggestions can't be toggled separately from sponsored suggestions.
|
|
return null;
|
|
}
|
|
|
|
get merinoProvider() {
|
|
return "adm";
|
|
}
|
|
|
|
get rustSuggestionType() {
|
|
return "Amp";
|
|
}
|
|
|
|
get rustProviderConstraints() {
|
|
let intValue = lazy.UrlbarPrefs.get("ampMatchingStrategy");
|
|
if (!intValue) {
|
|
// If the value is zero or otherwise falsey, use the usual default
|
|
// exact-keyword strategy by returning null here.
|
|
return null;
|
|
}
|
|
if (!Object.values(lazy.AmpMatchingStrategy).includes(intValue)) {
|
|
this.logger.error(
|
|
"Unknown AmpMatchingStrategy value, using default strategy",
|
|
{ intValue }
|
|
);
|
|
return null;
|
|
}
|
|
return {
|
|
ampAlternativeMatching: intValue,
|
|
};
|
|
}
|
|
|
|
isSuggestionSponsored() {
|
|
return true;
|
|
}
|
|
|
|
getSuggestionTelemetryType() {
|
|
return "adm_sponsored";
|
|
}
|
|
|
|
enable(enabled) {
|
|
if (enabled) {
|
|
GleanPings.quickSuggest.setEnabled(true);
|
|
GleanPings.quickSuggestDeletionRequest.setEnabled(true);
|
|
} else {
|
|
// Submit the `deletion-request` ping. Both it and the `quick-suggest`
|
|
// ping must remain enabled in order for it to be successfully submitted
|
|
// and uploaded. That's fine: It's harmless for both pings to remain
|
|
// enabled until shutdown, and they won't be submitted again since AMP
|
|
// suggestions are now disabled. On restart they won't be enabled again.
|
|
this.#submitQuickSuggestDeletionRequestPing();
|
|
}
|
|
}
|
|
|
|
makeResult(queryContext, suggestion) {
|
|
let originalUrl;
|
|
if (suggestion.source == "rust") {
|
|
// The Rust backend replaces URL timestamp templates for us, and it
|
|
// includes the original URL as `rawUrl`.
|
|
originalUrl = suggestion.rawUrl;
|
|
} else {
|
|
// Replace URL timestamp templates, but first save the original URL.
|
|
originalUrl = suggestion.url;
|
|
this.#replaceSuggestionTemplates(suggestion);
|
|
|
|
// Normalize the Merino suggestion so it has camelCased properties like
|
|
// Rust suggestions.
|
|
suggestion = {
|
|
title: suggestion.title,
|
|
url: suggestion.url,
|
|
fullKeyword: suggestion.full_keyword,
|
|
impressionUrl: suggestion.impression_url,
|
|
clickUrl: suggestion.click_url,
|
|
blockId: suggestion.block_id,
|
|
advertiser: suggestion.advertiser,
|
|
iabCategory: suggestion.iab_category,
|
|
requestId: suggestion.request_id,
|
|
};
|
|
}
|
|
|
|
let payload = {
|
|
originalUrl,
|
|
url: suggestion.url,
|
|
title: suggestion.title,
|
|
requestId: suggestion.requestId,
|
|
urlTimestampIndex: suggestion.urlTimestampIndex,
|
|
sponsoredImpressionUrl: suggestion.impressionUrl,
|
|
sponsoredClickUrl: suggestion.clickUrl,
|
|
sponsoredBlockId: suggestion.blockId,
|
|
sponsoredAdvertiser: suggestion.advertiser,
|
|
sponsoredIabCategory: suggestion.iabCategory,
|
|
isBlockable: true,
|
|
isManageable: true,
|
|
};
|
|
|
|
let isTopPick =
|
|
lazy.UrlbarPrefs.get("quickSuggestAmpTopPickCharThreshold") &&
|
|
lazy.UrlbarPrefs.get("quickSuggestAmpTopPickCharThreshold") <=
|
|
queryContext.trimmedLowerCaseSearchString.length;
|
|
|
|
payload.qsSuggestion = [
|
|
suggestion.fullKeyword,
|
|
isTopPick
|
|
? lazy.UrlbarUtils.HIGHLIGHT.TYPED
|
|
: lazy.UrlbarUtils.HIGHLIGHT.SUGGESTED,
|
|
];
|
|
|
|
let result = new lazy.UrlbarResult(
|
|
lazy.UrlbarUtils.RESULT_TYPE.URL,
|
|
lazy.UrlbarUtils.RESULT_SOURCE.SEARCH,
|
|
...lazy.UrlbarResult.payloadAndSimpleHighlights(
|
|
queryContext.tokens,
|
|
payload
|
|
)
|
|
);
|
|
|
|
result.isRichSuggestion = true;
|
|
if (isTopPick) {
|
|
result.isBestMatch = true;
|
|
result.suggestedIndex = 1;
|
|
} else {
|
|
if (lazy.UrlbarPrefs.get("quickSuggestSponsoredPriority")) {
|
|
result.isBestMatch = true;
|
|
result.suggestedIndex = 1;
|
|
} else {
|
|
result.richSuggestionIconSize = 16;
|
|
}
|
|
result.payload.descriptionL10n = {
|
|
id: "urlbar-result-action-sponsored",
|
|
};
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
onImpression(state, queryContext, controller, featureResults, details) {
|
|
// For the purpose of the `quick-suggest` impression ping, "impression"
|
|
// means that one of these suggestions was visible at the time of an
|
|
// engagement regardless of the engagement type or engagement result, so
|
|
// submit the ping if `state` is "engagement".
|
|
if (state == "engagement") {
|
|
for (let result of featureResults) {
|
|
this.#submitQuickSuggestImpressionPing({
|
|
result,
|
|
queryContext,
|
|
details,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
onEngagement(queryContext, controller, details, _searchString) {
|
|
let { result } = details;
|
|
|
|
// Handle commands. These suggestions support the Dismissal and Manage
|
|
// commands. Dismissal is the only one we need to handle here. `UrlbarInput`
|
|
// handles Manage.
|
|
if (details.selType == "dismiss") {
|
|
lazy.QuickSuggest.dismissResult(result);
|
|
controller.removeResult(result);
|
|
}
|
|
|
|
// A `quick-suggest` impression ping must always be submitted on engagement
|
|
// regardless of engagement type. Normally we do that in `onImpression()`,
|
|
// but that's not called when the session remains ongoing, so in that case,
|
|
// submit the impression ping now.
|
|
if (details.isSessionOngoing) {
|
|
this.#submitQuickSuggestImpressionPing({ queryContext, result, details });
|
|
}
|
|
|
|
// Submit the `quick-suggest` engagement ping.
|
|
let pingData;
|
|
switch (details.selType) {
|
|
case "quicksuggest":
|
|
pingData = {
|
|
pingType: lazy.CONTEXTUAL_SERVICES_PING_TYPES.QS_SELECTION,
|
|
reportingUrl: result.payload.sponsoredClickUrl,
|
|
};
|
|
break;
|
|
case "dismiss":
|
|
pingData = {
|
|
pingType: lazy.CONTEXTUAL_SERVICES_PING_TYPES.QS_BLOCK,
|
|
iabCategory: result.payload.sponsoredIabCategory,
|
|
};
|
|
break;
|
|
}
|
|
if (pingData) {
|
|
this.#submitQuickSuggestPing({ queryContext, result, ...pingData });
|
|
}
|
|
}
|
|
|
|
isUrlEquivalentToResultUrl(url, result) {
|
|
// If the URLs aren't the same length, they can't be equivalent.
|
|
let resultURL = result.payload.url;
|
|
if (resultURL.length != url.length) {
|
|
return false;
|
|
}
|
|
|
|
if (result.payload.source == "rust") {
|
|
// Rust has its own equivalence function.
|
|
return lazy.rawSuggestionUrlMatches(result.payload.originalUrl, url);
|
|
}
|
|
|
|
// If the result URL doesn't have a timestamp, then do a straight string
|
|
// comparison.
|
|
let { urlTimestampIndex } = result.payload;
|
|
if (typeof urlTimestampIndex != "number" || urlTimestampIndex < 0) {
|
|
return resultURL == url;
|
|
}
|
|
|
|
// Compare the first parts of the strings before the timestamps.
|
|
if (
|
|
resultURL.substring(0, urlTimestampIndex) !=
|
|
url.substring(0, urlTimestampIndex)
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
// Compare the second parts of the strings after the timestamps.
|
|
let remainderIndex = urlTimestampIndex + TIMESTAMP_LENGTH;
|
|
if (resultURL.substring(remainderIndex) != url.substring(remainderIndex)) {
|
|
return false;
|
|
}
|
|
|
|
// Test the timestamp against the regexp.
|
|
let maybeTimestamp = url.substring(
|
|
urlTimestampIndex,
|
|
urlTimestampIndex + TIMESTAMP_LENGTH
|
|
);
|
|
return TIMESTAMP_REGEXP.test(maybeTimestamp);
|
|
}
|
|
|
|
async #submitQuickSuggestPing({
|
|
queryContext,
|
|
result,
|
|
pingType,
|
|
...pingData
|
|
}) {
|
|
if (queryContext.isPrivate) {
|
|
return;
|
|
}
|
|
|
|
let allPingData = {
|
|
pingType,
|
|
// Suggest initialization awaits `Region.init()`, so safe to assume it's
|
|
// already been initialized here.
|
|
country: lazy.Region.home,
|
|
...pingData,
|
|
matchType: result.isBestMatch ? "best-match" : "firefox-suggest",
|
|
// Always use lowercase to make the reporting consistent.
|
|
advertiser: result.payload.sponsoredAdvertiser.toLocaleLowerCase(),
|
|
blockId: result.payload.sponsoredBlockId,
|
|
improveSuggestExperience: lazy.UrlbarPrefs.get(
|
|
"quicksuggest.dataCollection.enabled"
|
|
),
|
|
// `position` is 1-based, unlike `rowIndex`, which is zero-based.
|
|
position: result.rowIndex + 1,
|
|
suggestedIndex: result.suggestedIndex.toString(),
|
|
suggestedIndexRelativeToGroup: !!result.isSuggestedIndexRelativeToGroup,
|
|
requestId: result.payload.requestId,
|
|
source: result.payload.source,
|
|
contextId: await lazy.ContextId.request(),
|
|
};
|
|
|
|
for (let [gleanKey, value] of Object.entries(allPingData)) {
|
|
let glean = Glean.quickSuggest[gleanKey];
|
|
if (value !== undefined && value !== "") {
|
|
glean.set(value);
|
|
}
|
|
}
|
|
GleanPings.quickSuggest.submit();
|
|
}
|
|
|
|
#submitQuickSuggestImpressionPing({ queryContext, result, details }) {
|
|
this.#submitQuickSuggestPing({
|
|
result,
|
|
queryContext,
|
|
pingType: lazy.CONTEXTUAL_SERVICES_PING_TYPES.QS_IMPRESSION,
|
|
isClicked:
|
|
// `selType` == "quicksuggest" if the result itself was clicked. It will
|
|
// be a command name if a command was clicked, e.g., "dismiss".
|
|
result == details.result && details.selType == "quicksuggest",
|
|
reportingUrl: result.payload.sponsoredImpressionUrl,
|
|
});
|
|
}
|
|
|
|
async #submitQuickSuggestDeletionRequestPing() {
|
|
if (lazy.ContextId.rotationEnabled) {
|
|
// The ContextId module will take care of sending the appropriate
|
|
// deletion requests if rotation is enabled.
|
|
lazy.ContextId.forceRotation();
|
|
} else {
|
|
Glean.quickSuggest.contextId.set(await lazy.ContextId.request());
|
|
GleanPings.quickSuggestDeletionRequest.submit();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Some AMP suggestion URL properties include timestamp templates that must be
|
|
* replaced with timestamps at query time. This method replaces them in place.
|
|
*
|
|
* Example URL with template:
|
|
*
|
|
* http://example.com/foo?bar=%YYYYMMDDHH%
|
|
*
|
|
* It will be replaced with a timestamp like this:
|
|
*
|
|
* http://example.com/foo?bar=2021111610
|
|
*
|
|
* @param {object} suggestion
|
|
* An AMP suggestion.
|
|
*/
|
|
#replaceSuggestionTemplates(suggestion) {
|
|
let now = new Date();
|
|
let timestampParts = [
|
|
now.getFullYear(),
|
|
now.getMonth() + 1,
|
|
now.getDate(),
|
|
now.getHours(),
|
|
];
|
|
let timestamp = timestampParts
|
|
.map(n => n.toString().padStart(2, "0"))
|
|
.join("");
|
|
for (let key of ["url", "click_url"]) {
|
|
let value = suggestion[key];
|
|
if (!value) {
|
|
continue;
|
|
}
|
|
|
|
let timestampIndex = value.indexOf(TIMESTAMP_TEMPLATE);
|
|
if (timestampIndex >= 0) {
|
|
if (key == "url") {
|
|
suggestion.urlTimestampIndex = timestampIndex;
|
|
}
|
|
// We could use replace() here but we need the timestamp index for
|
|
// `suggestion.urlTimestampIndex`, and since we already have that, avoid
|
|
// another O(n) substring search and manually replace the template with
|
|
// the timestamp.
|
|
suggestion[key] =
|
|
value.substring(0, timestampIndex) +
|
|
timestamp +
|
|
value.substring(timestampIndex + TIMESTAMP_TEMPLATE.length);
|
|
}
|
|
}
|
|
}
|
|
|
|
static get TIMESTAMP_TEMPLATE() {
|
|
return TIMESTAMP_TEMPLATE;
|
|
}
|
|
|
|
static get TIMESTAMP_LENGTH() {
|
|
return TIMESTAMP_LENGTH;
|
|
}
|
|
}
|