summaryrefslogtreecommitdiffstats
path: root/browser/components/urlbar/private/PocketSuggestions.sys.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'browser/components/urlbar/private/PocketSuggestions.sys.mjs')
-rw-r--r--browser/components/urlbar/private/PocketSuggestions.sys.mjs314
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;
+}