diff options
Diffstat (limited to 'browser/components/urlbar/private/PocketSuggestions.sys.mjs')
-rw-r--r-- | browser/components/urlbar/private/PocketSuggestions.sys.mjs | 314 |
1 files changed, 314 insertions, 0 deletions
diff --git a/browser/components/urlbar/private/PocketSuggestions.sys.mjs b/browser/components/urlbar/private/PocketSuggestions.sys.mjs new file mode 100644 index 0000000000..f15b210606 --- /dev/null +++ b/browser/components/urlbar/private/PocketSuggestions.sys.mjs @@ -0,0 +1,314 @@ +/* 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 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 + ? "chrome://global/skin/icons/pocket.svg" + : "chrome://global/skin/icons/pocket-favicon.ico", + 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; +} |