tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

RealtimeSuggestProvider.sys.mjs (21005B)


      1 /* This Source Code Form is subject to the terms of the Mozilla Public
      2 * License, v. 2.0. If a copy of the MPL was not distributed with this
      3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
      4 
      5 import { SuggestProvider } from "moz-src:///browser/components/urlbar/private/SuggestFeature.sys.mjs";
      6 
      7 const lazy = {};
      8 
      9 ChromeUtils.defineESModuleGetters(lazy, {
     10  QuickSuggest: "moz-src:///browser/components/urlbar/QuickSuggest.sys.mjs",
     11  UrlbarPrefs: "moz-src:///browser/components/urlbar/UrlbarPrefs.sys.mjs",
     12  UrlbarResult: "moz-src:///browser/components/urlbar/UrlbarResult.sys.mjs",
     13  UrlbarSearchUtils:
     14    "moz-src:///browser/components/urlbar/UrlbarSearchUtils.sys.mjs",
     15  UrlbarUtils: "moz-src:///browser/components/urlbar/UrlbarUtils.sys.mjs",
     16 });
     17 
     18 /**
     19 * A Suggest feature that manages realtime suggestions (a.k.a. "carrots"),
     20 * including both opt-in and online suggestions for a given realtime type. It is
     21 * intended to be subclassed rather than used as is.
     22 *
     23 * Each subclass should manage one realtime type. If the user has not opted in
     24 * to online suggestions, this class will serve the realtime type's opt-in
     25 * suggestion. Once the user has opted in, it will switch to serving online
     26 * Merino suggestions for the realtime type.
     27 */
     28 export class RealtimeSuggestProvider extends SuggestProvider {
     29  // The following methods must be overridden.
     30 
     31  /**
     32   * The type of the realtime suggestion provider.
     33   *
     34   * @type {string}
     35   */
     36  get realtimeType() {
     37    throw new Error("Trying to access the base class, must be overridden");
     38  }
     39 
     40  getViewTemplateForDescriptionTop(_item, _index) {
     41    throw new Error("Trying to access the base class, must be overridden");
     42  }
     43 
     44  getViewTemplateForDescriptionBottom(_item, _index) {
     45    throw new Error("Trying to access the base class, must be overridden");
     46  }
     47 
     48  getViewUpdateForPayloadItem(_item, _index) {
     49    throw new Error("Trying to access the base class, must be overridden");
     50  }
     51 
     52  // The following getters depend on `realtimeType` and should be overridden as
     53  // necessary.
     54 
     55  /**
     56   * @returns {string[]}
     57   *   The opt-in suggestion is a dynamic Rust suggestion. `suggestion_type` in
     58   *   the RS record is `${this.realtimeType}_opt_in` by default.
     59   */
     60  get dynamicRustSuggestionTypes() {
     61    return [this.realtimeType + "_opt_in"];
     62  }
     63 
     64  /**
     65   * @returns {string}
     66   *   The online suggestions are served by Merino. The Merino provider is
     67   *   `this.realtimeType` by default.
     68   */
     69  get merinoProvider() {
     70    return this.realtimeType;
     71  }
     72 
     73  get baseTelemetryType() {
     74    return this.realtimeType;
     75  }
     76 
     77  get realtimeTypeForFtl() {
     78    return this.realtimeType.replace(/([A-Z])/g, "-$1").toLowerCase();
     79  }
     80 
     81  get featureGatePref() {
     82    return this.realtimeType + "FeatureGate";
     83  }
     84 
     85  get suggestPref() {
     86    return "suggest." + this.realtimeType;
     87  }
     88 
     89  get minKeywordLengthPref() {
     90    return this.realtimeType + ".minKeywordLength";
     91  }
     92 
     93  get showLessFrequentlyCountPref() {
     94    return this.realtimeType + ".showLessFrequentlyCount";
     95  }
     96 
     97  get optInIcon() {
     98    return `chrome://browser/skin/illustrations/${this.realtimeType}-opt-in.svg`;
     99  }
    100 
    101  get optInTitleL10n() {
    102    return {
    103      id: `urlbar-result-${this.realtimeTypeForFtl}-opt-in-title`,
    104    };
    105  }
    106 
    107  get optInDescriptionL10n() {
    108    return {
    109      id: `urlbar-result-${this.realtimeTypeForFtl}-opt-in-description`,
    110      parseMarkup: true,
    111    };
    112  }
    113 
    114  get notInterestedCommandL10n() {
    115    return {
    116      id: "urlbar-result-menu-dont-show-" + this.realtimeTypeForFtl,
    117    };
    118  }
    119 
    120  get acknowledgeDismissalL10n() {
    121    return {
    122      id: "urlbar-result-dismissal-acknowledgment-" + this.realtimeTypeForFtl,
    123    };
    124  }
    125 
    126  get ariaGroupL10n() {
    127    return {
    128      id: "urlbar-result-aria-group-" + this.realtimeTypeForFtl,
    129      attribute: "aria-label",
    130    };
    131  }
    132 
    133  get isSponsored() {
    134    return false;
    135  }
    136 
    137  /**
    138   * @returns {string}
    139   *   The dynamic result type that will be set in the Merino result's payload
    140   *   as `result.payload.dynamicType`. Note that "dynamic" here refers to the
    141   *   concept of dynamic result types as used in the view and
    142   *   `UrlbarUtils.RESULT_TYPE.DYNAMIC`, not Rust dynamic suggestions.
    143   *
    144   *   If you override this, make sure the value starts with "realtime-" because
    145   *   there are CSS rules that depend on that.
    146   */
    147  get dynamicResultType() {
    148    return "realtime-" + this.realtimeType;
    149  }
    150 
    151  // The following methods can be overridden but hopefully it's not necessary.
    152 
    153  get rustSuggestionType() {
    154    return "Dynamic";
    155  }
    156 
    157  get enablingPreferences() {
    158    return [
    159      "suggest.quicksuggest.all",
    160      "suggest.realtimeOptIn",
    161      "quicksuggest.realtimeOptIn.dismissTypes",
    162      "quicksuggest.realtimeOptIn.notNowTimeSeconds",
    163      "quicksuggest.realtimeOptIn.notNowReshowAfterPeriodDays",
    164      "quickSuggestOnlineAvailable",
    165      "quicksuggest.online.enabled",
    166      this.featureGatePref,
    167      this.suggestPref,
    168 
    169      // We could include the sponsored pref only if `this.isSponsored` is true,
    170      // but for maximum flexibility `this.isSponsored` is only a fallback for
    171      // when individual suggestions do not have an `isSponsored` property.
    172      // Since individual suggestions may be sponsored or not, we include the
    173      // pref here.
    174      "suggest.quicksuggest.sponsored",
    175    ];
    176  }
    177 
    178  get primaryUserControlledPreferences() {
    179    return [
    180      "suggest.realtimeOptIn",
    181      "quicksuggest.realtimeOptIn.dismissTypes",
    182      "quicksuggest.realtimeOptIn.notNowTimeSeconds",
    183      "quicksuggest.realtimeOptIn.notNowReshowAfterPeriodDays",
    184      this.suggestPref,
    185    ];
    186  }
    187 
    188  get shouldEnable() {
    189    if (
    190      !lazy.UrlbarPrefs.get(this.featureGatePref) ||
    191      !lazy.UrlbarPrefs.get("quickSuggestOnlineAvailable") ||
    192      !lazy.UrlbarPrefs.get("suggest.quicksuggest.all")
    193    ) {
    194      // The feature gate is disabled, online suggestions aren't available, or
    195      // all Suggest suggestions are disabled. Don't show opt-in or online
    196      // suggestions for this realtime type.
    197      return false;
    198    }
    199 
    200    if (lazy.UrlbarPrefs.get("quicksuggest.online.enabled")) {
    201      // Online suggestions are enabled. Show this realtime type if the user
    202      // didn't disable it.
    203      return lazy.UrlbarPrefs.get(this.suggestPref);
    204    }
    205 
    206    if (!lazy.UrlbarPrefs.get("suggest.realtimeOptIn")) {
    207      // The user dismissed opt-in suggestions for all realtime types.
    208      return false;
    209    }
    210 
    211    let dismissTypes = lazy.UrlbarPrefs.get(
    212      "quicksuggest.realtimeOptIn.dismissTypes"
    213    );
    214    if (dismissTypes.has(this.realtimeType)) {
    215      // The user dismissed opt-in suggestions for this realtime type.
    216      return false;
    217    }
    218 
    219    let notNowTimeSeconds = lazy.UrlbarPrefs.get(
    220      "quicksuggest.realtimeOptIn.notNowTimeSeconds"
    221    );
    222    if (!notNowTimeSeconds) {
    223      return true;
    224    }
    225 
    226    let notNowReshowAfterPeriodDays = lazy.UrlbarPrefs.get(
    227      "quicksuggest.realtimeOptIn.notNowReshowAfterPeriodDays"
    228    );
    229 
    230    let timeSecs = notNowReshowAfterPeriodDays * 24 * 60 * 60;
    231    return Date.now() / 1000 - notNowTimeSeconds > timeSecs;
    232  }
    233 
    234  isSuggestionSponsored(suggestion) {
    235    switch (suggestion.source) {
    236      case "merino":
    237        if (suggestion.hasOwnProperty("is_sponsored")) {
    238          return !!suggestion.is_sponsored;
    239        }
    240        break;
    241      case "rust":
    242        if (suggestion.data?.result?.payload?.hasOwnProperty("isSponsored")) {
    243          return suggestion.data.result.payload.isSponsored;
    244        }
    245        break;
    246    }
    247    return this.isSponsored;
    248  }
    249 
    250  /**
    251   * The telemetry type for a suggestion from this provider. (This string does
    252   * not include the `${source}_` prefix, e.g., "rust_".)
    253   *
    254   * Since realtime providers serve two types of suggestions, the opt-in and the
    255   * online suggestion, this will return two possible telemetry types depending
    256   * on the passed-in suggestion. Telemetry types for each are:
    257   *
    258   *   Opt-in suggestion: `${this.baseTelemetryType}_opt_in`
    259   *   Online suggestion: this.baseTelemetryType
    260   *
    261   * Individual suggestions can override these telemetry types, but that's
    262   * expected to be uncommon.
    263   *
    264   * @param {object} suggestion
    265   *   A suggestion from this provider.
    266   * @returns {string}
    267   *   The suggestion's telemetry type.
    268   */
    269  getSuggestionTelemetryType(suggestion) {
    270    switch (suggestion.source) {
    271      case "merino":
    272        if (suggestion.hasOwnProperty("telemetry_type")) {
    273          return suggestion.telemetry_type;
    274        }
    275        break;
    276      case "rust":
    277        if (suggestion.data?.result?.payload?.hasOwnProperty("telemetryType")) {
    278          return suggestion.data.result.payload.telemetryType;
    279        }
    280        return this.baseTelemetryType + "_opt_in";
    281    }
    282    return this.baseTelemetryType;
    283  }
    284 
    285  filterSuggestions(suggestions) {
    286    // The Rust opt-in suggestion can always be matched regardless of whether
    287    // online is enabled, so return only Merino suggestions when it is enabled.
    288    if (lazy.UrlbarPrefs.get("quicksuggest.online.enabled")) {
    289      return suggestions.filter(s => s.source == "merino");
    290    }
    291    return suggestions;
    292  }
    293 
    294  makeResult(queryContext, suggestion, searchString) {
    295    // For maximum flexibility individual suggestions can indicate whether they
    296    // are sponsored or not, despite `this.isSponsored`, which is a fallback.
    297    if (
    298      !lazy.UrlbarPrefs.get("suggest.quicksuggest.all") ||
    299      (this.isSuggestionSponsored(suggestion) &&
    300        !lazy.UrlbarPrefs.get("suggest.quicksuggest.sponsored"))
    301    ) {
    302      return null;
    303    }
    304 
    305    switch (suggestion.source) {
    306      case "merino":
    307        return this.makeMerinoResult(queryContext, suggestion, searchString);
    308      case "rust":
    309        return this.makeOptInResult(queryContext, suggestion);
    310    }
    311    return null;
    312  }
    313 
    314  makeMerinoResult(
    315    queryContext,
    316    suggestion,
    317    searchString,
    318    additionalOptions = {}
    319  ) {
    320    if (!this.isEnabled) {
    321      return null;
    322    }
    323 
    324    if (
    325      this.showLessFrequentlyCount &&
    326      searchString.length < this.#minKeywordLength
    327    ) {
    328      return null;
    329    }
    330 
    331    let values = suggestion.custom_details?.[this.merinoProvider]?.values;
    332    if (!values?.length) {
    333      return null;
    334    }
    335 
    336    let engine;
    337    if (values.some(v => v.query)) {
    338      engine = lazy.UrlbarSearchUtils.getDefaultEngine(queryContext.isPrivate);
    339      if (!engine) {
    340        return null;
    341      }
    342    }
    343 
    344    let result = new lazy.UrlbarResult({
    345      type: lazy.UrlbarUtils.RESULT_TYPE.DYNAMIC,
    346      source: lazy.UrlbarUtils.RESULT_SOURCE.SEARCH,
    347      isBestMatch: true,
    348      hideRowLabel: true,
    349      ...additionalOptions,
    350      payload: {
    351        items: values.map((v, i) => this.makePayloadItem(v, i)),
    352        dynamicType: this.dynamicResultType,
    353        engine: engine?.name,
    354      },
    355    });
    356 
    357    return result;
    358  }
    359 
    360  /**
    361   * Returns the object that should be stored as `result.payload.items[i]` for
    362   * the Merino result. The default implementation here returns the
    363   * corresponding value in the suggestion.
    364   *
    365   * It's useful to override this if there's a significant amount of logic
    366   * that's used by the different code paths of the view update. In that case,
    367   * you can override this method, perform the logic, store the results in the
    368   * item, and then your different view update paths can all use it.
    369   *
    370   * @param {object} value
    371   *   The value in the suggestion's `values` array.
    372   * @param {number} _index
    373   *   The index of the value in the array.
    374   * @returns {object}
    375   *   The object that should be stored in `result.payload.items[_index]`.
    376   */
    377  makePayloadItem(value, _index) {
    378    return value;
    379  }
    380 
    381  makeOptInResult(queryContext, _suggestion) {
    382    let notNowTypes = lazy.UrlbarPrefs.get(
    383      "quicksuggest.realtimeOptIn.notNowTypes"
    384    );
    385    let splitButtonMain = notNowTypes.has(this.realtimeType)
    386      ? {
    387          command: "dismiss",
    388          l10n: {
    389            id: "urlbar-result-realtime-opt-in-dismiss",
    390          },
    391        }
    392      : {
    393          command: "not_now",
    394          l10n: {
    395            id: "urlbar-result-realtime-opt-in-not-now",
    396          },
    397        };
    398 
    399    return new lazy.UrlbarResult({
    400      type: lazy.UrlbarUtils.RESULT_TYPE.TIP,
    401      source: lazy.UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL,
    402      isBestMatch: true,
    403      hideRowLabel: true,
    404      payload: {
    405        // This `type` is the tip type, required for `TIP` results.
    406        type: "realtime_opt_in",
    407        icon: this.optInIcon,
    408        titleL10n: this.optInTitleL10n,
    409        descriptionL10n: this.optInDescriptionL10n,
    410        descriptionLearnMoreTopic: lazy.QuickSuggest.HELP_TOPIC,
    411        buttons: [
    412          {
    413            command: "opt_in",
    414            l10n: {
    415              id: "urlbar-result-realtime-opt-in-allow",
    416            },
    417            input: queryContext.searchString,
    418            attributes: {
    419              primary: "",
    420            },
    421          },
    422          {
    423            ...splitButtonMain,
    424            menu: [
    425              {
    426                name: "not_interested",
    427                l10n: {
    428                  id: "urlbar-result-realtime-opt-in-dismiss-all",
    429                },
    430              },
    431            ],
    432          },
    433        ],
    434      },
    435    });
    436  }
    437 
    438  getViewTemplate(result) {
    439    let { items } = result.payload;
    440    let hasMultipleItems = items.length > 1;
    441    return {
    442      name: "root",
    443      overflowable: true,
    444      attributes: {
    445        selectable: hasMultipleItems ? null : "",
    446        role: hasMultipleItems ? "group" : "option",
    447      },
    448      classList: ["urlbarView-realtime-root"],
    449      children: items.map((item, i) => ({
    450        name: `item_${i}`,
    451        tag: "span",
    452        classList: ["urlbarView-realtime-item"],
    453        attributes: {
    454          selectable: !hasMultipleItems ? null : "",
    455          role: hasMultipleItems ? "option" : "presentation",
    456        },
    457        children: [
    458          // Create an image inside a container so that the image appears inset
    459          // into a square. This is atypical because we normally use only an
    460          // image and give it padding and a background color to achieve that
    461          // effect, but that only works when the image size is fixed.
    462          // Unfortunately Merino serves market icons of different sizes due to
    463          // its reliance on a third-party API.
    464          {
    465            name: `image_container_${i}`,
    466            tag: "span",
    467            classList: ["urlbarView-realtime-image-container"],
    468            children: this.getViewTemplateForImage(item, i),
    469          },
    470 
    471          {
    472            tag: "span",
    473            classList: ["urlbarView-realtime-description"],
    474            children: [
    475              {
    476                tag: "div",
    477                classList: ["urlbarView-realtime-description-top"],
    478                children: this.getViewTemplateForDescriptionTop(item, i),
    479              },
    480              {
    481                tag: "div",
    482                classList: ["urlbarView-realtime-description-bottom"],
    483                children: this.getViewTemplateForDescriptionBottom(item, i),
    484              },
    485            ],
    486          },
    487        ],
    488      })),
    489    };
    490  }
    491 
    492  /**
    493   * Returns the view template inside the `image_container`. This default
    494   * implementation creates an `img` element. Override it if you need something
    495   * else.
    496   *
    497   * @param {object} _item
    498   *   An item from the `result.payload.items` array.
    499   * @param {number} index
    500   *   The index of the item in the array.
    501   * @returns {Array}
    502   *   View template for the image, an array of objects.
    503   */
    504  getViewTemplateForImage(_item, index) {
    505    return [
    506      {
    507        name: `image_${index}`,
    508        tag: "img",
    509        classList: ["urlbarView-realtime-image"],
    510      },
    511    ];
    512  }
    513 
    514  getViewUpdate(result) {
    515    let { items } = result.payload;
    516    let hasMultipleItems = items.length > 1;
    517 
    518    let update = {
    519      root: {
    520        dataset: {
    521          // This `url` or `query` will be used when there's only one item.
    522          url: items[0].url,
    523          query: items[0].query,
    524        },
    525        l10n: hasMultipleItems ? this.ariaGroupL10n : null,
    526      },
    527    };
    528 
    529    for (let i = 0; i < items.length; i++) {
    530      let item = items[i];
    531      Object.assign(update, this.getViewUpdateForPayloadItem(item, i));
    532 
    533      // These `url` or `query`s will be used when there are multiple items.
    534      let itemName = `item_${i}`;
    535      update[itemName] ??= {};
    536      update[itemName].dataset ??= {};
    537      update[itemName].dataset.url ??= item.url;
    538      update[itemName].dataset.query ??= item.query;
    539    }
    540 
    541    return update;
    542  }
    543 
    544  getResultCommands(result) {
    545    if (result.payload.source == "rust") {
    546      // The opt-in result should not have a result menu.
    547      return null;
    548    }
    549 
    550    /** @type {UrlbarResultCommand[]} */
    551    let commands = [
    552      {
    553        name: "not_interested",
    554        l10n: this.notInterestedCommandL10n,
    555      },
    556    ];
    557 
    558    if (this.canShowLessFrequently) {
    559      commands.push({
    560        name: "show_less_frequently",
    561        l10n: {
    562          id: "urlbar-result-menu-show-less-frequently",
    563        },
    564      });
    565    }
    566 
    567    commands.push(
    568      { name: "separator" },
    569      {
    570        name: "manage",
    571        l10n: {
    572          id: "urlbar-result-menu-manage-firefox-suggest",
    573        },
    574      },
    575      {
    576        name: "help",
    577        l10n: {
    578          id: "urlbar-result-menu-learn-more-about-firefox-suggest",
    579        },
    580      }
    581    );
    582 
    583    return commands;
    584  }
    585 
    586  onEngagement(queryContext, controller, details, searchString) {
    587    switch (details.result.payload.source) {
    588      case "merino":
    589        this.onMerinoEngagement(
    590          queryContext,
    591          controller,
    592          details,
    593          searchString
    594        );
    595        break;
    596      case "rust":
    597        this.onOptInEngagement(queryContext, controller, details, searchString);
    598        break;
    599    }
    600  }
    601 
    602  onMerinoEngagement(queryContext, controller, details, searchString) {
    603    let { result } = details;
    604    switch (details.selType) {
    605      case "help":
    606      case "manage": {
    607        // "help" and "manage" are handled by UrlbarInput, no need to do
    608        // anything here.
    609        break;
    610      }
    611      case "not_interested": {
    612        lazy.UrlbarPrefs.set(this.suggestPref, false);
    613        result.acknowledgeDismissalL10n = this.acknowledgeDismissalL10n;
    614        controller.removeResult(result);
    615        break;
    616      }
    617      case "show_less_frequently": {
    618        controller.view.acknowledgeFeedback(result);
    619        this.incrementShowLessFrequentlyCount();
    620        if (!this.canShowLessFrequently) {
    621          controller.view.invalidateResultMenuCommands();
    622        }
    623        lazy.UrlbarPrefs.set(
    624          this.minKeywordLengthPref,
    625          searchString.length + 1
    626        );
    627        break;
    628      }
    629    }
    630  }
    631 
    632  onOptInEngagement(queryContext, controller, details, _searchString) {
    633    switch (details.selType) {
    634      case "opt_in":
    635        lazy.UrlbarPrefs.set("quicksuggest.online.enabled", true);
    636        controller.input.startQuery({ allowAutofill: false });
    637        break;
    638      case "not_now": {
    639        lazy.UrlbarPrefs.set(
    640          "quicksuggest.realtimeOptIn.notNowTimeSeconds",
    641          Date.now() / 1000
    642        );
    643        lazy.UrlbarPrefs.add(
    644          "quicksuggest.realtimeOptIn.notNowTypes",
    645          this.realtimeType
    646        );
    647        controller.removeResult(details.result);
    648        break;
    649      }
    650      case "dismiss": {
    651        lazy.UrlbarPrefs.add(
    652          "quicksuggest.realtimeOptIn.dismissTypes",
    653          this.realtimeType
    654        );
    655        details.result.acknowledgeDismissalL10n = this.acknowledgeDismissalL10n;
    656        controller.removeResult(details.result);
    657        break;
    658      }
    659      case "not_interested": {
    660        lazy.UrlbarPrefs.set("suggest.realtimeOptIn", false);
    661        details.result.acknowledgeDismissalL10n = {
    662          id: "urlbar-result-dismissal-acknowledgment-all",
    663        };
    664        controller.removeResult(details.result);
    665        break;
    666      }
    667    }
    668  }
    669 
    670  incrementShowLessFrequentlyCount() {
    671    if (this.canShowLessFrequently) {
    672      lazy.UrlbarPrefs.set(
    673        this.showLessFrequentlyCountPref,
    674        this.showLessFrequentlyCount + 1
    675      );
    676    }
    677  }
    678 
    679  get showLessFrequentlyCount() {
    680    const pref = this.showLessFrequentlyCountPref;
    681    const count = lazy.UrlbarPrefs.get(pref) || 0;
    682    return Math.max(count, 0);
    683  }
    684 
    685  get canShowLessFrequently() {
    686    const cap =
    687      lazy.UrlbarPrefs.get("realtimeShowLessFrequentlyCap") ||
    688      lazy.QuickSuggest.config.showLessFrequentlyCap ||
    689      0;
    690    return !cap || this.showLessFrequentlyCount < cap;
    691  }
    692 
    693  get #minKeywordLength() {
    694    let hasUserValue = Services.prefs.prefHasUserValue(
    695      "browser.urlbar." + this.minKeywordLengthPref
    696    );
    697    let nimbusValue = lazy.UrlbarPrefs.get("realtimeMinKeywordLength");
    698    let minLength =
    699      hasUserValue || nimbusValue === null
    700        ? lazy.UrlbarPrefs.get(this.minKeywordLengthPref)
    701        : nimbusValue;
    702    return Math.max(minLength, 0);
    703  }
    704 }