tor-browser

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

SportsSuggestions.sys.mjs (10708B)


      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 { RealtimeSuggestProvider } from "moz-src:///browser/components/urlbar/private/RealtimeSuggestProvider.sys.mjs";
      6 
      7 /**
      8 * A feature that manages sports realtime suggestions.
      9 */
     10 export class SportsSuggestions extends RealtimeSuggestProvider {
     11  get realtimeType() {
     12    return "sports";
     13  }
     14 
     15  get isSponsored() {
     16    return false;
     17  }
     18 
     19  get merinoProvider() {
     20    return "sports";
     21  }
     22 
     23  getViewTemplateForImage(item, index) {
     24    if (itemIcon(item)) {
     25      return super.getViewTemplateForImage(item, index);
     26    }
     27 
     28    return [
     29      {
     30        name: `scheduled-date-chiclet-day-${index}`,
     31        tag: "span",
     32        classList: ["urlbarView-sports-scheduled-date-chiclet-day"],
     33      },
     34      {
     35        name: `scheduled-date-chiclet-month-${index}`,
     36        tag: "span",
     37        classList: ["urlbarView-sports-scheduled-date-chiclet-month"],
     38      },
     39    ];
     40  }
     41 
     42  getViewTemplateForDescriptionTop(item, index) {
     43    return stringifiedScore(item.home_team.score) &&
     44      stringifiedScore(item.away_team.score)
     45      ? this.#viewTemplateTopWithScores(index)
     46      : this.#viewTemplateTopWithoutScores(index);
     47  }
     48 
     49  #viewTemplateTopWithScores(index) {
     50    return [
     51      {
     52        name: `home-team-name-${index}`,
     53        tag: "span",
     54      },
     55      {
     56        name: `home-team-score-${index}`,
     57        tag: "span",
     58        classList: ["urlbarView-sports-score"],
     59      },
     60      {
     61        tag: "span",
     62        classList: ["urlbarView-realtime-description-separator-dot"],
     63      },
     64      {
     65        name: `away-team-name-${index}`,
     66        tag: "span",
     67      },
     68      {
     69        name: `away-team-score-${index}`,
     70        tag: "span",
     71        classList: ["urlbarView-sports-score"],
     72      },
     73    ];
     74  }
     75 
     76  #viewTemplateTopWithoutScores(index) {
     77    return [
     78      {
     79        name: `team-names-${index}`,
     80        tag: "span",
     81        classList: ["urlbarView-sports-team-names"],
     82      },
     83    ];
     84  }
     85 
     86  getViewTemplateForDescriptionBottom(item, index) {
     87    return [
     88      {
     89        name: `sport-name-${index}`,
     90        tag: "span",
     91      },
     92      {
     93        tag: "span",
     94        classList: ["urlbarView-realtime-description-separator-dot"],
     95      },
     96      {
     97        name: `date-${index}`,
     98        tag: "span",
     99      },
    100      {
    101        tag: "span",
    102        classList: ["urlbarView-realtime-description-separator-dot"],
    103      },
    104      {
    105        name: `status-${index}`,
    106        tag: "span",
    107        classList: ["urlbarView-sports-status"],
    108      },
    109    ];
    110  }
    111 
    112  getViewUpdateForPayloadItem(item, index) {
    113    let topUpdate =
    114      stringifiedScore(item.home_team.score) &&
    115      stringifiedScore(item.away_team.score)
    116        ? this.#viewUpdateTopWithScores(item, index)
    117        : this.#viewUpdateTopWithoutScores(item, index);
    118 
    119    return {
    120      ...topUpdate,
    121      ...this.#viewUpdateImageAndBottom(item, index),
    122      [`item_${index}`]: {
    123        attributes: {
    124          sport: item.sport,
    125          status: item.status_type,
    126        },
    127      },
    128    };
    129  }
    130 
    131  #viewUpdateTopWithScores(item, i) {
    132    return {
    133      [`home-team-name-${i}`]: {
    134        textContent: item.home_team.name,
    135      },
    136      [`home-team-score-${i}`]: {
    137        textContent: stringifiedScore(item.home_team.score),
    138      },
    139      [`away-team-name-${i}`]: {
    140        textContent: item.away_team.name,
    141      },
    142      [`away-team-score-${i}`]: {
    143        textContent: stringifiedScore(item.away_team.score),
    144      },
    145    };
    146  }
    147 
    148  #viewUpdateTopWithoutScores(item, i) {
    149    return {
    150      [`team-names-${i}`]: {
    151        l10n: {
    152          id: "urlbar-result-sports-team-names",
    153          args: {
    154            homeTeam: item.home_team.name,
    155            awayTeam: item.away_team.name,
    156          },
    157        },
    158      },
    159    };
    160  }
    161 
    162  #viewUpdateImageAndBottom(item, i) {
    163    let date = new Date(item.date);
    164    let { zonedNow, zonedDate, daysUntil, isFuture } =
    165      SportsSuggestions._parseDate(date);
    166 
    167    let icon = itemIcon(item);
    168    let isScheduled = item.status_type == "scheduled";
    169 
    170    // Create the image update.
    171    let imageUpdate;
    172    if (icon) {
    173      // The image container will contain the icon.
    174      imageUpdate = {
    175        [`image_container_${i}`]: {
    176          attributes: {
    177            // Remove the fallback attribute by setting it to null.
    178            "is-fallback": null,
    179          },
    180        },
    181        [`image_${i}`]: {
    182          attributes: {
    183            src: icon,
    184          },
    185        },
    186      };
    187    } else {
    188      // Instead of an icon, the image container will be a date chiclet
    189      // containing the item's date as text, with the day above the month.
    190      let partsArray = new Intl.DateTimeFormat(undefined, {
    191        month: "short",
    192        day: "numeric",
    193        timeZone: zonedNow.timeZoneId,
    194      }).formatToParts(date);
    195      let partsMap = Object.fromEntries(
    196        partsArray.map(({ type, value }) => [type, value])
    197      );
    198      if (partsMap.day && partsMap.month) {
    199        imageUpdate = {
    200          [`image_container_${i}`]: {
    201            attributes: {
    202              "is-fallback": "",
    203            },
    204          },
    205          [`scheduled-date-chiclet-day-${i}`]: {
    206            textContent: partsMap.day,
    207          },
    208          [`scheduled-date-chiclet-month-${i}`]: {
    209            textContent: partsMap.month,
    210          },
    211        };
    212      } else {
    213        // This shouldn't happen.
    214        imageUpdate = {};
    215      }
    216    }
    217 
    218    // Create the date update. First, format the date.
    219    let formattedDate;
    220    if (Math.abs(daysUntil) <= 1) {
    221      // Relative date: "Today", "Tomorrow", "Yesterday"
    222      formattedDate = capitalizeString(
    223        new Intl.RelativeTimeFormat(undefined, {
    224          numeric: "auto",
    225        }).format(daysUntil, "day")
    226      );
    227    } else {
    228      // Formatted date with some combination of year, month, day, and weekday
    229      let opts = {
    230        timeZone: zonedNow.timeZoneId,
    231      };
    232      if (!isScheduled || icon || !isFuture) {
    233        opts.month = "short";
    234        opts.day = "numeric";
    235        if (zonedDate.year != zonedNow.year) {
    236          opts.year = "numeric";
    237        }
    238      }
    239      if (isScheduled && isFuture) {
    240        opts.weekday = "short";
    241      }
    242      formattedDate = new Intl.DateTimeFormat(undefined, opts).format(date);
    243    }
    244 
    245    // Now format the time.
    246    let formattedTime;
    247    if (isScheduled && daysUntil >= 0) {
    248      formattedTime = new Intl.DateTimeFormat(undefined, {
    249        hour: "numeric",
    250        minute: "numeric",
    251        timeZoneName: "short",
    252        timeZone: zonedNow.timeZoneId,
    253      }).format(date);
    254    }
    255 
    256    // Finally, create the date update.
    257    let dateUpdate;
    258    if (formattedTime) {
    259      dateUpdate = {
    260        [`date-${i}`]: {
    261          l10n: {
    262            id: "urlbar-result-sports-game-date-with-time",
    263            args: {
    264              date: formattedDate,
    265              time: formattedTime,
    266            },
    267          },
    268        },
    269      };
    270    } else {
    271      dateUpdate = {
    272        [`date-${i}`]: {
    273          textContent: formattedDate,
    274        },
    275      };
    276    }
    277 
    278    // Create the status update. Show the status if the game is live or if it
    279    // happened earlier today. Otherwise clear the status.
    280    let statusUpdate;
    281    if (item.status_type == "live") {
    282      statusUpdate = {
    283        [`status-${i}`]: {
    284          l10n: {
    285            id: "urlbar-result-sports-status-live",
    286          },
    287        },
    288      };
    289    } else if (daysUntil == 0 && item.status_type == "past") {
    290      statusUpdate = {
    291        [`status-${i}`]: {
    292          l10n: {
    293            id: "urlbar-result-sports-status-final",
    294          },
    295        },
    296      };
    297    } else {
    298      statusUpdate = {
    299        [`status-${i}`]: {
    300          // Clear the status by setting its text to an empty string.
    301          textContent: "",
    302        },
    303      };
    304    }
    305 
    306    return {
    307      ...imageUpdate,
    308      ...dateUpdate,
    309      ...statusUpdate,
    310      [`sport-name-${i}`]: {
    311        textContent: item.sport,
    312      },
    313    };
    314  }
    315 
    316  /**
    317   * Parses a date and returns some info about it.
    318   *
    319   * This is a static method rather than a helper function internal to this file
    320   * so that tests can easily test it.
    321   *
    322   * @param {Date} date
    323   *   A `Date` object.
    324   * @returns {DateParseResult}
    325   *   The result.
    326   *
    327   * @typedef {object} DateParseResult
    328   * @property {typeof Temporal.ZonedDateTime} zonedNow
    329   *   Now as a `ZonedDateTime`.
    330   * @property {typeof Temporal.ZonedDateTime} zonedDate
    331   *   The passed-in date as a `ZonedDateTime`.
    332   * @property {boolean} isFuture
    333   *   Whether the date is in the future.
    334   * @property {number} daysUntil
    335   *   The number of calendar days from today to the date:
    336   *   If the date is after tomorrow: `Infinity`
    337   *   If the date is tomorrow: `1`
    338   *   If the date is today: `0`
    339   *   If the date is yesterday: `-1`
    340   *   If the date is before yesterday: `-Infinity`
    341   */
    342  static _parseDate(date) {
    343    // Find how many days there are from today to the date.
    344    let zonedNow = SportsSuggestions._zonedDateTimeISO();
    345    let zonedDate = date.toTemporalInstant().toZonedDateTimeISO(zonedNow);
    346 
    347    let today = zonedNow.startOfDay();
    348    let yesterday = today.subtract({ days: 1 });
    349    let tomorrow = today.add({ days: 1 });
    350    let dayAfterTomorrow = today.add({ days: 2 });
    351 
    352    let daysUntil;
    353    if (Temporal.ZonedDateTime.compare(dayAfterTomorrow, zonedDate) <= 0) {
    354      // date is after tomorrow
    355      daysUntil = Infinity;
    356    } else if (Temporal.ZonedDateTime.compare(tomorrow, zonedDate) <= 0) {
    357      // date is tomorrow
    358      daysUntil = 1;
    359    } else if (Temporal.ZonedDateTime.compare(today, zonedDate) <= 0) {
    360      // date is today
    361      daysUntil = 0;
    362    } else if (Temporal.ZonedDateTime.compare(yesterday, zonedDate) <= 0) {
    363      // date is yesterday
    364      daysUntil = -1;
    365    } else {
    366      // date is before yesterday
    367      daysUntil = -Infinity;
    368    }
    369 
    370    let isFuture = Temporal.ZonedDateTime.compare(zonedNow, zonedDate) < 0;
    371 
    372    return {
    373      zonedNow,
    374      zonedDate,
    375      isFuture,
    376      daysUntil,
    377    };
    378  }
    379 
    380  // Thin wrapper around `zonedDateTimeISO` so that tests can easily set a mock
    381  // "now" date and time.
    382  static _zonedDateTimeISO() {
    383    return Temporal.Now.zonedDateTimeISO();
    384  }
    385 }
    386 
    387 function itemIcon(item) {
    388  return item.icon || item.home_team?.icon || item.away_team?.icon;
    389 }
    390 
    391 function stringifiedScore(scoreValue) {
    392  let s = scoreValue;
    393  if (typeof s == "number") {
    394    s = String(s);
    395  }
    396  return typeof s == "string" ? s : "";
    397 }
    398 
    399 function capitalizeString(str) {
    400  return str[0].toLocaleUpperCase() + str.substring(1);
    401 }