forked from mirrors/gecko-dev
304 lines
9.7 KiB
JavaScript
304 lines
9.7 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 { 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) {
|
|
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,
|
|
isBlockable: true,
|
|
blockL10n: {
|
|
id: "urlbar-result-menu-dismiss-firefox-suggest",
|
|
},
|
|
isManageable: true,
|
|
};
|
|
|
|
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;
|
|
}
|