tor-browser

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

AmpSuggestions.sys.mjs (13003B)


      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  AmpMatchingStrategy:
     11    "moz-src:///toolkit/components/uniffi-bindgen-gecko-js/components/generated/RustSuggest.sys.mjs",
     12  CONTEXTUAL_SERVICES_PING_TYPES:
     13    "resource:///modules/PartnerLinkAttribution.sys.mjs",
     14  ContextId: "moz-src:///browser/modules/ContextId.sys.mjs",
     15  QuickSuggest: "moz-src:///browser/components/urlbar/QuickSuggest.sys.mjs",
     16  rawSuggestionUrlMatches:
     17    "moz-src:///toolkit/components/uniffi-bindgen-gecko-js/components/generated/RustSuggest.sys.mjs",
     18  Region: "resource://gre/modules/Region.sys.mjs",
     19  UrlbarPrefs: "moz-src:///browser/components/urlbar/UrlbarPrefs.sys.mjs",
     20  UrlbarResult: "moz-src:///browser/components/urlbar/UrlbarResult.sys.mjs",
     21  UrlbarUtils: "moz-src:///browser/components/urlbar/UrlbarUtils.sys.mjs",
     22 });
     23 
     24 const TIMESTAMP_TEMPLATE = "%YYYYMMDDHH%";
     25 const TIMESTAMP_LENGTH = 10;
     26 const TIMESTAMP_REGEXP = /^\d{10}$/;
     27 
     28 /**
     29 * A feature that manages AMP suggestions.
     30 */
     31 export class AmpSuggestions extends SuggestProvider {
     32  get enablingPreferences() {
     33    return [
     34      "ampFeatureGate",
     35      "suggest.amp",
     36      "suggest.quicksuggest.all",
     37      "suggest.quicksuggest.sponsored",
     38    ];
     39  }
     40 
     41  get primaryUserControlledPreferences() {
     42    return ["suggest.amp"];
     43  }
     44 
     45  get merinoProvider() {
     46    return "adm";
     47  }
     48 
     49  get rustSuggestionType() {
     50    return "Amp";
     51  }
     52 
     53  get rustProviderConstraints() {
     54    let intValue = lazy.UrlbarPrefs.get("ampMatchingStrategy");
     55    if (!intValue) {
     56      // If the value is zero or otherwise falsey, use the usual default
     57      // exact-keyword strategy by returning null here.
     58      return null;
     59    }
     60    if (!Object.values(lazy.AmpMatchingStrategy).includes(intValue)) {
     61      this.logger.error(
     62        "Unknown AmpMatchingStrategy value, using default strategy",
     63        { intValue }
     64      );
     65      return null;
     66    }
     67    return {
     68      ampAlternativeMatching: intValue,
     69    };
     70  }
     71 
     72  isSuggestionSponsored() {
     73    return true;
     74  }
     75 
     76  getSuggestionTelemetryType() {
     77    return "adm_sponsored";
     78  }
     79 
     80  enable(enabled) {
     81    if (enabled) {
     82      GleanPings.quickSuggest.setEnabled(true);
     83      GleanPings.quickSuggestDeletionRequest.setEnabled(true);
     84    } else {
     85      // Submit the `deletion-request` ping. Both it and the `quick-suggest`
     86      // ping must remain enabled in order for it to be successfully submitted
     87      // and uploaded. That's fine: It's harmless for both pings to remain
     88      // enabled until shutdown, and they won't be submitted again since AMP
     89      // suggestions are now disabled. On restart they won't be enabled again.
     90      this.#submitQuickSuggestDeletionRequestPing();
     91    }
     92  }
     93 
     94  makeResult(queryContext, suggestion) {
     95    let normalized = Object.assign({}, suggestion);
     96    if (suggestion.source == "merino") {
     97      // Normalize the Merino suggestion so it has the same properties as Rust
     98      // AMP suggestions: camelCased properties plus a `rawUrl` property whose
     99      // value is `url` without replacing the timestamp template.
    100      normalized.rawUrl = suggestion.url;
    101      normalized.fullKeyword = suggestion.full_keyword;
    102      normalized.impressionUrl = suggestion.impression_url;
    103      normalized.clickUrl = suggestion.click_url;
    104      normalized.blockId = suggestion.block_id;
    105      normalized.iabCategory = suggestion.iab_category;
    106      normalized.requestId = suggestion.request_id;
    107 
    108      // Replace URL timestamp templates inline. This isn't necessary for Rust
    109      // AMP suggestions because the Rust component handles it.
    110      this.#replaceSuggestionTemplates(normalized);
    111    }
    112 
    113    let isTopPick =
    114      lazy.UrlbarPrefs.get("quickSuggestAmpTopPickCharThreshold") &&
    115      lazy.UrlbarPrefs.get("quickSuggestAmpTopPickCharThreshold") <=
    116        queryContext.trimmedLowerCaseSearchString.length;
    117 
    118    let { value: title, highlights: titleHighlights } =
    119      lazy.QuickSuggest.getFullKeywordTitleAndHighlights({
    120        tokens: queryContext.tokens,
    121        highlightType: isTopPick
    122          ? lazy.UrlbarUtils.HIGHLIGHT.TYPED
    123          : lazy.UrlbarUtils.HIGHLIGHT.SUGGESTED,
    124        fullKeyword: normalized.fullKeyword,
    125        title: normalized.title,
    126      });
    127 
    128    let payload = {
    129      url: normalized.url,
    130      originalUrl: normalized.rawUrl,
    131      title,
    132      requestId: normalized.requestId,
    133      urlTimestampIndex: normalized.urlTimestampIndex,
    134      sponsoredImpressionUrl: normalized.impressionUrl,
    135      sponsoredClickUrl: normalized.clickUrl,
    136      sponsoredBlockId: normalized.blockId,
    137      sponsoredAdvertiser: normalized.advertiser,
    138      sponsoredIabCategory: normalized.iabCategory,
    139      isBlockable: true,
    140      isManageable: true,
    141    };
    142 
    143    let resultParams = {};
    144    if (isTopPick) {
    145      resultParams.isBestMatch = true;
    146      resultParams.suggestedIndex = 1;
    147    } else {
    148      if (lazy.UrlbarPrefs.get("quickSuggestSponsoredPriority")) {
    149        resultParams.isBestMatch = true;
    150        resultParams.suggestedIndex = 1;
    151      } else {
    152        resultParams.richSuggestionIconSize = 16;
    153      }
    154      payload.descriptionL10n = {
    155        id: "urlbar-result-action-sponsored",
    156      };
    157    }
    158 
    159    return new lazy.UrlbarResult({
    160      type: lazy.UrlbarUtils.RESULT_TYPE.URL,
    161      source: lazy.UrlbarUtils.RESULT_SOURCE.SEARCH,
    162      isRichSuggestion: true,
    163      ...resultParams,
    164      payload,
    165      highlights: {
    166        title: titleHighlights,
    167      },
    168    });
    169  }
    170 
    171  onImpression(state, queryContext, controller, featureResults, details) {
    172    // For the purpose of the `quick-suggest` impression ping, "impression"
    173    // means that one of these suggestions was visible at the time of an
    174    // engagement regardless of the engagement type or engagement result, so
    175    // submit the ping if `state` is "engagement".
    176    if (state == "engagement") {
    177      for (let result of featureResults) {
    178        this.#submitQuickSuggestImpressionPing({
    179          result,
    180          queryContext,
    181          details,
    182        });
    183      }
    184    }
    185  }
    186 
    187  onEngagement(queryContext, controller, details, _searchString) {
    188    let { result } = details;
    189 
    190    // Handle commands. These suggestions support the Dismissal and Manage
    191    // commands. Dismissal is the only one we need to handle here. `UrlbarInput`
    192    // handles Manage.
    193    if (details.selType == "dismiss") {
    194      lazy.QuickSuggest.dismissResult(result);
    195      controller.removeResult(result);
    196    }
    197 
    198    // A `quick-suggest` impression ping must always be submitted on engagement
    199    // regardless of engagement type. Normally we do that in `onImpression()`,
    200    // but that's not called when the session remains ongoing, so in that case,
    201    // submit the impression ping now.
    202    if (details.isSessionOngoing) {
    203      this.#submitQuickSuggestImpressionPing({ queryContext, result, details });
    204    }
    205 
    206    // Submit the `quick-suggest` engagement ping.
    207    let pingData;
    208    switch (details.selType) {
    209      case "quicksuggest":
    210        pingData = {
    211          pingType: lazy.CONTEXTUAL_SERVICES_PING_TYPES.QS_SELECTION,
    212          reportingUrl: result.payload.sponsoredClickUrl,
    213        };
    214        break;
    215      case "dismiss":
    216        pingData = {
    217          pingType: lazy.CONTEXTUAL_SERVICES_PING_TYPES.QS_BLOCK,
    218          iabCategory: result.payload.sponsoredIabCategory,
    219        };
    220        break;
    221    }
    222    if (pingData) {
    223      this.#submitQuickSuggestPing({ queryContext, result, ...pingData });
    224    }
    225  }
    226 
    227  isUrlEquivalentToResultUrl(url, result) {
    228    // If the URLs aren't the same length, they can't be equivalent.
    229    let resultURL = result.payload.url;
    230    if (resultURL.length != url.length) {
    231      return false;
    232    }
    233 
    234    if (result.payload.source == "rust") {
    235      // Rust has its own equivalence function.
    236      return lazy.rawSuggestionUrlMatches(result.payload.originalUrl, url);
    237    }
    238 
    239    // If the result URL doesn't have a timestamp, then do a straight string
    240    // comparison.
    241    let { urlTimestampIndex } = result.payload;
    242    if (typeof urlTimestampIndex != "number" || urlTimestampIndex < 0) {
    243      return resultURL == url;
    244    }
    245 
    246    // Compare the first parts of the strings before the timestamps.
    247    if (
    248      resultURL.substring(0, urlTimestampIndex) !=
    249      url.substring(0, urlTimestampIndex)
    250    ) {
    251      return false;
    252    }
    253 
    254    // Compare the second parts of the strings after the timestamps.
    255    let remainderIndex = urlTimestampIndex + TIMESTAMP_LENGTH;
    256    if (resultURL.substring(remainderIndex) != url.substring(remainderIndex)) {
    257      return false;
    258    }
    259 
    260    // Test the timestamp against the regexp.
    261    let maybeTimestamp = url.substring(
    262      urlTimestampIndex,
    263      urlTimestampIndex + TIMESTAMP_LENGTH
    264    );
    265    return TIMESTAMP_REGEXP.test(maybeTimestamp);
    266  }
    267 
    268  async #submitQuickSuggestPing({
    269    queryContext,
    270    result,
    271    pingType,
    272    ...pingData
    273  }) {
    274    if (queryContext.isPrivate) {
    275      return;
    276    }
    277 
    278    let allPingData = {
    279      pingType,
    280      // Suggest initialization awaits `Region.init()`, so safe to assume it's
    281      // already been initialized here.
    282      country: lazy.Region.home,
    283      ...pingData,
    284      matchType: result.isBestMatch ? "best-match" : "firefox-suggest",
    285      // Always use lowercase to make the reporting consistent.
    286      advertiser: result.payload.sponsoredAdvertiser.toLocaleLowerCase(),
    287      blockId: result.payload.sponsoredBlockId,
    288      improveSuggestExperience:
    289        lazy.UrlbarPrefs.get("quickSuggestOnlineAvailable") &&
    290        lazy.UrlbarPrefs.get("quicksuggest.online.enabled"),
    291      // `position` is 1-based, unlike `rowIndex`, which is zero-based.
    292      position: result.rowIndex + 1,
    293      suggestedIndex: result.suggestedIndex.toString(),
    294      suggestedIndexRelativeToGroup: !!result.isSuggestedIndexRelativeToGroup,
    295      requestId: result.payload.requestId,
    296      source: result.payload.source,
    297      contextId: await lazy.ContextId.request(),
    298    };
    299 
    300    for (let [gleanKey, value] of Object.entries(allPingData)) {
    301      let glean = Glean.quickSuggest[gleanKey];
    302      if (value !== undefined && value !== "") {
    303        glean.set(value);
    304      }
    305    }
    306    GleanPings.quickSuggest.submit();
    307  }
    308 
    309  #submitQuickSuggestImpressionPing({ queryContext, result, details }) {
    310    this.#submitQuickSuggestPing({
    311      result,
    312      queryContext,
    313      pingType: lazy.CONTEXTUAL_SERVICES_PING_TYPES.QS_IMPRESSION,
    314      isClicked:
    315        // `selType` == "quicksuggest" if the result itself was clicked. It will
    316        // be a command name if a command was clicked, e.g., "dismiss".
    317        result == details.result && details.selType == "quicksuggest",
    318      reportingUrl: result.payload.sponsoredImpressionUrl,
    319    });
    320  }
    321 
    322  async #submitQuickSuggestDeletionRequestPing() {
    323    if (lazy.ContextId.rotationEnabled) {
    324      // The ContextId module will take care of sending the appropriate
    325      // deletion requests if rotation is enabled.
    326      lazy.ContextId.forceRotation();
    327    } else {
    328      Glean.quickSuggest.contextId.set(await lazy.ContextId.request());
    329      GleanPings.quickSuggestDeletionRequest.submit();
    330    }
    331  }
    332 
    333  /**
    334   * Some AMP suggestion URL properties include timestamp templates that must be
    335   * replaced with timestamps at query time. This method replaces them in place.
    336   *
    337   * Example URL with template:
    338   *
    339   *   http://example.com/foo?bar=%YYYYMMDDHH%
    340   *
    341   * It will be replaced with a timestamp like this:
    342   *
    343   *   http://example.com/foo?bar=2021111610
    344   *
    345   * @param {object} suggestion
    346   *   An AMP suggestion.
    347   */
    348  #replaceSuggestionTemplates(suggestion) {
    349    let now = new Date();
    350    let timestampParts = [
    351      now.getFullYear(),
    352      now.getMonth() + 1,
    353      now.getDate(),
    354      now.getHours(),
    355    ];
    356    let timestamp = timestampParts
    357      .map(n => n.toString().padStart(2, "0"))
    358      .join("");
    359    for (let key of ["url", "clickUrl"]) {
    360      let value = suggestion[key];
    361      if (!value) {
    362        continue;
    363      }
    364 
    365      let timestampIndex = value.indexOf(TIMESTAMP_TEMPLATE);
    366      if (timestampIndex >= 0) {
    367        if (key == "url") {
    368          suggestion.urlTimestampIndex = timestampIndex;
    369        }
    370        // We could use replace() here but we need the timestamp index for
    371        // `suggestion.urlTimestampIndex`, and since we already have that, avoid
    372        // another O(n) substring search and manually replace the template with
    373        // the timestamp.
    374        suggestion[key] =
    375          value.substring(0, timestampIndex) +
    376          timestamp +
    377          value.substring(timestampIndex + TIMESTAMP_TEMPLATE.length);
    378      }
    379    }
    380  }
    381 
    382  static get TIMESTAMP_TEMPLATE() {
    383    return TIMESTAMP_TEMPLATE;
    384  }
    385 
    386  static get TIMESTAMP_LENGTH() {
    387    return TIMESTAMP_LENGTH;
    388  }
    389 }