summaryrefslogtreecommitdiffstats
path: root/browser/components/urlbar/private/AddonSuggestions.sys.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'browser/components/urlbar/private/AddonSuggestions.sys.mjs')
-rw-r--r--browser/components/urlbar/private/AddonSuggestions.sys.mjs424
1 files changed, 424 insertions, 0 deletions
diff --git a/browser/components/urlbar/private/AddonSuggestions.sys.mjs b/browser/components/urlbar/private/AddonSuggestions.sys.mjs
new file mode 100644
index 0000000000..7232ad99f8
--- /dev/null
+++ b/browser/components/urlbar/private/AddonSuggestions.sys.mjs
@@ -0,0 +1,424 @@
+/* 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, {
+ AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
+ QuickSuggest: "resource:///modules/QuickSuggest.sys.mjs",
+ QuickSuggestRemoteSettings:
+ "resource:///modules/urlbar/private/QuickSuggestRemoteSettings.sys.mjs",
+ SuggestionsMap:
+ "resource:///modules/urlbar/private/QuickSuggestRemoteSettings.sys.mjs",
+ UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs",
+ UrlbarResult: "resource:///modules/UrlbarResult.sys.mjs",
+ UrlbarUtils: "resource:///modules/UrlbarUtils.sys.mjs",
+ UrlbarView: "resource:///modules/UrlbarView.sys.mjs",
+});
+
+const VIEW_TEMPLATE = {
+ attributes: {
+ selectable: true,
+ },
+ children: [
+ {
+ name: "content",
+ tag: "span",
+ overflowable: true,
+ children: [
+ {
+ name: "icon",
+ tag: "img",
+ },
+ {
+ name: "header",
+ tag: "span",
+ children: [
+ {
+ name: "title",
+ tag: "span",
+ classList: ["urlbarView-title"],
+ },
+ {
+ name: "separator",
+ tag: "span",
+ classList: ["urlbarView-title-separator"],
+ },
+ {
+ name: "url",
+ tag: "span",
+ classList: ["urlbarView-url"],
+ },
+ ],
+ },
+ {
+ name: "description",
+ tag: "span",
+ },
+ {
+ name: "footer",
+ tag: "span",
+ children: [
+ {
+ name: "ratingContainer",
+ tag: "span",
+ children: [
+ {
+ classList: ["urlbarView-dynamic-addons-rating"],
+ name: "rating0",
+ tag: "span",
+ },
+ {
+ classList: ["urlbarView-dynamic-addons-rating"],
+ name: "rating1",
+ tag: "span",
+ },
+ {
+ classList: ["urlbarView-dynamic-addons-rating"],
+ name: "rating2",
+ tag: "span",
+ },
+ {
+ classList: ["urlbarView-dynamic-addons-rating"],
+ name: "rating3",
+ tag: "span",
+ },
+ {
+ classList: ["urlbarView-dynamic-addons-rating"],
+ name: "rating4",
+ tag: "span",
+ },
+ ],
+ },
+ {
+ name: "reviews",
+ tag: "span",
+ },
+ ],
+ },
+ ],
+ },
+ ],
+};
+
+const RESULT_MENU_COMMAND = {
+ HELP: "help",
+ NOT_INTERESTED: "not_interested",
+ NOT_RELEVANT: "not_relevant",
+ SHOW_LESS_FREQUENTLY: "show_less_frequently",
+};
+
+/**
+ * A feature that supports Addon suggestions.
+ */
+export class AddonSuggestions extends BaseFeature {
+ constructor() {
+ super();
+ lazy.UrlbarResult.addDynamicResultType("addons");
+ lazy.UrlbarView.addDynamicViewTemplate("addons", VIEW_TEMPLATE);
+ }
+
+ get shouldEnable() {
+ return (
+ lazy.UrlbarPrefs.get("addonsFeatureGate") &&
+ lazy.UrlbarPrefs.get("suggest.addons") &&
+ lazy.UrlbarPrefs.get("suggest.quicksuggest.nonsponsored")
+ );
+ }
+
+ get enablingPreferences() {
+ return ["suggest.addons", "suggest.quicksuggest.nonsponsored"];
+ }
+
+ enable(enabled) {
+ if (enabled) {
+ lazy.QuickSuggestRemoteSettings.register(this);
+ } else {
+ lazy.QuickSuggestRemoteSettings.unregister(this);
+ }
+ }
+
+ queryRemoteSettings(searchString) {
+ const suggestions = this.#suggestionsMap?.get(searchString);
+ if (!suggestions) {
+ return [];
+ }
+
+ return suggestions.map(suggestion => ({
+ icon: suggestion.icon,
+ url: suggestion.url,
+ title: suggestion.title,
+ description: suggestion.description,
+ rating: suggestion.rating,
+ number_of_ratings: suggestion.number_of_ratings,
+ guid: suggestion.guid,
+ score: suggestion.score,
+ is_top_pick: suggestion.is_top_pick,
+ }));
+ }
+
+ async onRemoteSettingsSync(rs) {
+ const records = await rs.get({ filters: { type: "amo-suggestions" } });
+ if (rs != lazy.QuickSuggestRemoteSettings.rs) {
+ return;
+ }
+
+ const suggestionsMap = new lazy.SuggestionsMap();
+
+ for (const record of records) {
+ const { buffer } = await rs.attachments.download(record);
+ if (rs != lazy.QuickSuggestRemoteSettings.rs) {
+ return;
+ }
+
+ const results = JSON.parse(new TextDecoder("utf-8").decode(buffer));
+
+ // The keywords in remote settings are full keywords. Map each one to an
+ // array containing the full keyword's first word plus every subsequent
+ // prefix of the full keyword.
+ await suggestionsMap.add(results, fullKeyword => {
+ let keywords = [fullKeyword];
+ let spaceIndex = fullKeyword.search(/\s/);
+ if (spaceIndex >= 0) {
+ for (let i = spaceIndex; i < fullKeyword.length; i++) {
+ keywords.push(fullKeyword.substring(0, i));
+ }
+ }
+ return keywords;
+ });
+ if (rs != lazy.QuickSuggestRemoteSettings.rs) {
+ return;
+ }
+ }
+
+ this.#suggestionsMap = suggestionsMap;
+ }
+
+ async makeResult(queryContext, suggestion, searchString) {
+ if (!this.isEnabled) {
+ // The feature is disabled on the client, but Merino may still return
+ // addon 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 is_top_pick is not specified, handle it as top pick suggestion.
+ suggestion.is_top_pick = suggestion.is_top_pick ?? true;
+
+ const { guid, rating, number_of_ratings } =
+ suggestion.source === "remote-settings"
+ ? suggestion
+ : suggestion.custom_details.amo;
+
+ const addon = await lazy.AddonManager.getAddonByID(guid);
+ if (addon) {
+ // Addon suggested is already installed.
+ return null;
+ }
+
+ const payload = {
+ source: suggestion.source,
+ icon: suggestion.icon,
+ url: suggestion.url,
+ title: suggestion.title,
+ description: suggestion.description,
+ rating: Number(rating),
+ reviews: Number(number_of_ratings),
+ helpUrl: lazy.QuickSuggest.HELP_URL,
+ shouldNavigate: true,
+ dynamicType: "addons",
+ telemetryType: "amo",
+ };
+
+ return Object.assign(
+ new lazy.UrlbarResult(
+ lazy.UrlbarUtils.RESULT_TYPE.DYNAMIC,
+ lazy.UrlbarUtils.RESULT_SOURCE.SEARCH,
+ ...lazy.UrlbarResult.payloadAndSimpleHighlights(
+ queryContext.tokens,
+ payload
+ )
+ ),
+ { showFeedbackMenu: true }
+ );
+ }
+
+ getViewUpdate(result) {
+ const treatment = lazy.UrlbarPrefs.get("addonsUITreatment");
+ const rating = result.payload.rating;
+
+ return {
+ content: {
+ attributes: { treatment },
+ },
+ icon: {
+ attributes: {
+ src: result.payload.icon,
+ },
+ },
+ url: {
+ textContent: result.payload.url,
+ },
+ title: {
+ textContent: result.payload.title,
+ },
+ description: {
+ textContent: result.payload.description,
+ },
+ rating0: {
+ attributes: {
+ fill: this.#getRatingStar(0, rating),
+ },
+ },
+ rating1: {
+ attributes: {
+ fill: this.#getRatingStar(1, rating),
+ },
+ },
+ rating2: {
+ attributes: {
+ fill: this.#getRatingStar(2, rating),
+ },
+ },
+ rating3: {
+ attributes: {
+ fill: this.#getRatingStar(3, rating),
+ },
+ },
+ rating4: {
+ attributes: {
+ fill: this.#getRatingStar(4, rating),
+ },
+ },
+ reviews: {
+ l10n:
+ treatment === "b"
+ ? { id: "firefox-suggest-addons-recommended" }
+ : {
+ id: "firefox-suggest-addons-reviews",
+ args: {
+ quantity: result.payload.reviews,
+ },
+ },
+ },
+ };
+ }
+
+ getResultCommands(result) {
+ const commands = [];
+
+ if (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;
+ }
+
+ handlePossibleCommand(queryContext, 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_INTERESTED:
+ case RESULT_MENU_COMMAND.NOT_RELEVANT:
+ lazy.UrlbarPrefs.set("suggest.addons", false);
+ queryContext.view.acknowledgeDismissal(result);
+ break;
+ case RESULT_MENU_COMMAND.SHOW_LESS_FREQUENTLY:
+ queryContext.view.acknowledgeFeedback(result);
+ this.incrementShowLessFrequentlyCount();
+ break;
+ }
+ }
+
+ #getRatingStar(nth, rating) {
+ // 0 <= x < 0.25 = empty
+ // 0.25 <= x < 0.75 = half
+ // 0.75 <= x <= 1 = full
+ // ... et cetera, until x <= 5.
+ const distanceToFull = rating - nth;
+ if (distanceToFull < 0.25) {
+ return "empty";
+ }
+ if (distanceToFull < 0.75) {
+ return "half";
+ }
+ return "full";
+ }
+
+ incrementShowLessFrequentlyCount() {
+ if (this.canShowLessFrequently) {
+ lazy.UrlbarPrefs.set(
+ "addons.showLessFrequentlyCount",
+ this.showLessFrequentlyCount + 1
+ );
+ }
+ }
+
+ get showLessFrequentlyCount() {
+ const count = lazy.UrlbarPrefs.get("addons.showLessFrequentlyCount") || 0;
+ return Math.max(count, 0);
+ }
+
+ get canShowLessFrequently() {
+ const cap =
+ lazy.UrlbarPrefs.get("addonsShowLessFrequentlyCap") ||
+ lazy.QuickSuggestRemoteSettings.config.show_less_frequently_cap ||
+ 0;
+ return !cap || this.showLessFrequentlyCount < cap;
+ }
+
+ #suggestionsMap = null;
+}