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/. */
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
});
const RESULT_MENU_COMMAND = {
HELP: "help",
NOT_INTERESTED: "not_interested",
NOT_RELEVANT: "not_relevant",
SHOW_LESS_FREQUENTLY: "show_less_frequently",
};
/**
* A feature that manages Pocket suggestions in remote settings.
*/
export class PocketSuggestions extends BaseFeature {
constructor() {
super();
this.#lowConfidenceSuggestionsMap = new lazy.SuggestionsMap();
this.#highConfidenceSuggestionsMap = new lazy.SuggestionsMap();
}
get shouldEnable() {
return (
lazy.UrlbarPrefs.get("pocketFeatureGate") &&
lazy.UrlbarPrefs.get("suggest.pocket") &&
lazy.UrlbarPrefs.get("suggest.quicksuggest.nonsponsored")
);
}
get enablingPreferences() {
return ["suggest.pocket", "suggest.quicksuggest.nonsponsored"];
}
get merinoProvider() {
return "pocket";
}
get rustSuggestionTypes() {
return ["Pocket"];
}
get showLessFrequentlyCount() {
let count = lazy.UrlbarPrefs.get("pocket.showLessFrequentlyCount") || 0;
return Math.max(count, 0);
}
get canShowLessFrequently() {
let cap =
lazy.UrlbarPrefs.get("pocketShowLessFrequentlyCap") ||
lazy.QuickSuggest.backend.config?.showLessFrequentlyCap ||
0;
return !cap || this.showLessFrequentlyCount < cap;
}
enable(enabled) {
if (enabled) {
lazy.QuickSuggest.jsBackend.register(this);
} else {
lazy.QuickSuggest.jsBackend.unregister(this);
this.#lowConfidenceSuggestionsMap.clear();
this.#highConfidenceSuggestionsMap.clear();
}
}
async queryRemoteSettings(searchString) {
// If the search string matches high confidence suggestions, they should be
// treated as top picks. Otherwise try to match low confidence suggestions.
let is_top_pick = false;
let suggestions = this.#highConfidenceSuggestionsMap.get(searchString);
if (suggestions.length) {
is_top_pick = true;
} else {
suggestions = this.#lowConfidenceSuggestionsMap.get(searchString);
}
let lowerSearchString = searchString.toLocaleLowerCase();
return suggestions.map(suggestion => {
// Add `full_keyword` to each matched suggestion. It should be the longest
// keyword that starts with the user's search string.
let full_keyword = lowerSearchString;
let keywords = is_top_pick
? suggestion.highConfidenceKeywords
: suggestion.lowConfidenceKeywords;
for (let keyword of keywords) {
if (
keyword.startsWith(lowerSearchString) &&
full_keyword.length < keyword.length
) {
full_keyword = keyword;
}
}
return { ...suggestion, is_top_pick, full_keyword };
});
}
async onRemoteSettingsSync(rs) {
let records = await rs.get({ filters: { type: "pocket-suggestions" } });
if (!this.isEnabled) {
return;
}
let lowMap = new lazy.SuggestionsMap();
let highMap = new lazy.SuggestionsMap();
this.logger.debug(`Got ${records.length} records`);
for (let record of records) {
let { buffer } = await rs.attachments.download(record);
if (!this.isEnabled) {
return;
}
let suggestions = JSON.parse(new TextDecoder("utf-8").decode(buffer));
this.logger.debug(`Adding ${suggestions.length} suggestions`);
await lowMap.add(suggestions, {
keywordsProperty: "lowConfidenceKeywords",
mapKeyword:
lazy.SuggestionsMap.MAP_KEYWORD_PREFIXES_STARTING_AT_FIRST_WORD,
});
if (!this.isEnabled) {
return;
}
await highMap.add(suggestions, {
keywordsProperty: "highConfidenceKeywords",
});
if (!this.isEnabled) {
return;
}
}
this.#lowConfidenceSuggestionsMap = lowMap;
this.#highConfidenceSuggestionsMap = highMap;
}
makeResult(queryContext, suggestion, searchString) {
if (!this.isEnabled) {
// The feature is disabled on the client, but Merino may still return
// suggestions anyway, and we filter them out here.
return null;
}
// If the user hasn't clicked the "Show less frequently" command, the
// suggestion can be shown. Otherwise, the suggestion can be shown if the
// user typed more than one word with at least `showLessFrequentlyCount`
// characters after the first word, including spaces.
if (this.showLessFrequentlyCount) {
let spaceIndex = searchString.search(/\s/);
if (
spaceIndex < 0 ||
searchString.length - spaceIndex < this.showLessFrequentlyCount
) {
return null;
}
}
if (suggestion.source == "rust") {
suggestion.is_top_pick = suggestion.isTopPick;
delete suggestion.isTopPick;
// The Rust component doesn't implement these properties. For now we use
// dummy values. See issue #5878 in application-services.
suggestion.description = suggestion.title;
suggestion.full_keyword = searchString;
}
let url = new URL(suggestion.url);
url.searchParams.set("utm_medium", "firefox-desktop");
url.searchParams.set("utm_source", "firefox-suggest");
url.searchParams.set(
"utm_campaign",
"pocket-collections-in-the-address-bar"
);
url.searchParams.set("utm_content", "treatment");
return Object.assign(
new lazy.UrlbarResult(
lazy.UrlbarUtils.RESULT_TYPE.URL,
lazy.UrlbarUtils.RESULT_SOURCE.OTHER_NETWORK,
...lazy.UrlbarResult.payloadAndSimpleHighlights(queryContext.tokens, {
url: url.href,
originalUrl: suggestion.url,
title: [suggestion.title, lazy.UrlbarUtils.HIGHLIGHT.TYPED],
description: suggestion.is_top_pick ? suggestion.description : "",
// Use the favicon for non-best matches so the icon exactly matches
// the Pocket favicon in the user's history and tabs.
icon: suggestion.is_top_pick
shouldShowUrl: true,
bottomTextL10n: {
id: "firefox-suggest-pocket-bottom-text",
args: {
keywordSubstringTyped: searchString,
keywordSubstringNotTyped: suggestion.full_keyword.substring(
searchString.length
),
},
},
helpUrl: lazy.QuickSuggest.HELP_URL,
})
),
{
isRichSuggestion: true,
richSuggestionIconSize: suggestion.is_top_pick ? 24 : 16,
showFeedbackMenu: true,
}
);
}
handleCommand(view, result, selType) {
switch (selType) {
case RESULT_MENU_COMMAND.HELP:
// "help" is handled by UrlbarInput, no need to do anything here.
break;
// selType == "dismiss" when the user presses the dismiss key shortcut.
case "dismiss":
case RESULT_MENU_COMMAND.NOT_RELEVANT:
// PocketSuggestions adds the UTM parameters to the original URL and
// returns it as payload.url in the result. However, as
// UrlbarProviderQuickSuggest filters suggestions with original URL of
// provided suggestions, need to use the original URL when adding to the
// block list.
lazy.QuickSuggest.blockedSuggestions.add(result.payload.originalUrl);
result.acknowledgeDismissalL10n = {
id: "firefox-suggest-dismissal-acknowledgment-one",
};
view.controller.removeResult(result);
break;
case RESULT_MENU_COMMAND.NOT_INTERESTED:
lazy.UrlbarPrefs.set("suggest.pocket", false);
result.acknowledgeDismissalL10n = {
id: "firefox-suggest-dismissal-acknowledgment-all",
};
view.controller.removeResult(result);
break;
case RESULT_MENU_COMMAND.SHOW_LESS_FREQUENTLY:
view.acknowledgeFeedback(result);
this.incrementShowLessFrequentlyCount();
if (!this.canShowLessFrequently) {
view.invalidateResultMenuCommands();
}
break;
}
}
getResultCommands(result) {
let commands = [];
if (!result.isBestMatch && this.canShowLessFrequently) {
commands.push({
name: RESULT_MENU_COMMAND.SHOW_LESS_FREQUENTLY,
l10n: {
id: "firefox-suggest-command-show-less-frequently",
},
});
}
commands.push(
{
l10n: {
id: "firefox-suggest-command-dont-show-this",
},
children: [
{
name: RESULT_MENU_COMMAND.NOT_RELEVANT,
l10n: {
id: "firefox-suggest-command-not-relevant",
},
},
{
name: RESULT_MENU_COMMAND.NOT_INTERESTED,
l10n: {
id: "firefox-suggest-command-not-interested",
},
},
],
},
{ name: "separator" },
{
name: RESULT_MENU_COMMAND.HELP,
l10n: {
id: "urlbar-result-menu-learn-more-about-firefox-suggest",
},
}
);
return commands;
}
incrementShowLessFrequentlyCount() {
if (this.canShowLessFrequently) {
lazy.UrlbarPrefs.set(
"pocket.showLessFrequentlyCount",
this.showLessFrequentlyCount + 1
);
}
}
#lowConfidenceSuggestionsMap;
#highConfidenceSuggestionsMap;
}