Source code
Revision control
Copy as Markdown
Other Tools
/* 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
import { RealtimeSuggestProvider } from "moz-src:///browser/components/urlbar/private/RealtimeSuggestProvider.sys.mjs";
/**
* A feature that manages sports realtime suggestions.
*/
export class SportsSuggestions extends RealtimeSuggestProvider {
get realtimeType() {
return "sports";
}
get isSponsored() {
return false;
}
get merinoProvider() {
return "sports";
}
getViewTemplateForImage(item, index) {
if (itemIcon(item)) {
return super.getViewTemplateForImage(item, index);
}
return [
{
name: `scheduled-date-chiclet-day-${index}`,
tag: "span",
classList: ["urlbarView-sports-scheduled-date-chiclet-day"],
},
{
name: `scheduled-date-chiclet-month-${index}`,
tag: "span",
classList: ["urlbarView-sports-scheduled-date-chiclet-month"],
},
];
}
getViewTemplateForDescriptionTop(item, index) {
return stringifiedScore(item.home_team.score) &&
stringifiedScore(item.away_team.score)
? this.#viewTemplateTopWithScores(index)
: this.#viewTemplateTopWithoutScores(index);
}
#viewTemplateTopWithScores(index) {
return [
{
name: `home-team-name-${index}`,
tag: "span",
},
{
name: `home-team-score-${index}`,
tag: "span",
classList: ["urlbarView-sports-score"],
},
{
tag: "span",
classList: ["urlbarView-realtime-description-separator-dot"],
},
{
name: `away-team-name-${index}`,
tag: "span",
},
{
name: `away-team-score-${index}`,
tag: "span",
classList: ["urlbarView-sports-score"],
},
];
}
#viewTemplateTopWithoutScores(index) {
return [
{
name: `team-names-${index}`,
tag: "span",
classList: ["urlbarView-sports-team-names"],
},
];
}
getViewTemplateForDescriptionBottom(item, index) {
return [
{
name: `sport-name-${index}`,
tag: "span",
},
{
tag: "span",
classList: ["urlbarView-realtime-description-separator-dot"],
},
{
name: `date-${index}`,
tag: "span",
},
{
tag: "span",
classList: ["urlbarView-realtime-description-separator-dot"],
},
{
name: `status-${index}`,
tag: "span",
classList: ["urlbarView-sports-status"],
},
];
}
getViewUpdateForPayloadItem(item, index) {
let topUpdate =
stringifiedScore(item.home_team.score) &&
stringifiedScore(item.away_team.score)
? this.#viewUpdateTopWithScores(item, index)
: this.#viewUpdateTopWithoutScores(item, index);
return {
...topUpdate,
...this.#viewUpdateImageAndBottom(item, index),
[`item_${index}`]: {
attributes: {
sport: item.sport,
status: item.status_type,
},
},
};
}
#viewUpdateTopWithScores(item, i) {
return {
[`home-team-name-${i}`]: {
textContent: item.home_team.name,
},
[`home-team-score-${i}`]: {
textContent: stringifiedScore(item.home_team.score),
},
[`away-team-name-${i}`]: {
textContent: item.away_team.name,
},
[`away-team-score-${i}`]: {
textContent: stringifiedScore(item.away_team.score),
},
};
}
#viewUpdateTopWithoutScores(item, i) {
return {
[`team-names-${i}`]: {
l10n: {
id: "urlbar-result-sports-team-names",
args: {
homeTeam: item.home_team.name,
awayTeam: item.away_team.name,
},
},
},
};
}
#viewUpdateImageAndBottom(item, i) {
let date = new Date(item.date);
let { zonedNow, zonedDate, daysUntil, isFuture } =
SportsSuggestions._parseDate(date);
let icon = itemIcon(item);
let isScheduled = item.status_type == "scheduled";
// Create the image update.
let imageUpdate;
if (icon) {
// The image container will contain the icon.
imageUpdate = {
[`image_container_${i}`]: {
attributes: {
// Remove the fallback attribute by setting it to null.
"is-fallback": null,
},
},
[`image_${i}`]: {
attributes: {
src: icon,
},
},
};
} else {
// Instead of an icon, the image container will be a date chiclet
// containing the item's date as text, with the day above the month.
let partsArray = new Intl.DateTimeFormat(undefined, {
month: "short",
day: "numeric",
timeZone: zonedNow.timeZoneId,
}).formatToParts(date);
let partsMap = Object.fromEntries(
partsArray.map(({ type, value }) => [type, value])
);
if (partsMap.day && partsMap.month) {
imageUpdate = {
[`image_container_${i}`]: {
attributes: {
"is-fallback": "",
},
},
[`scheduled-date-chiclet-day-${i}`]: {
textContent: partsMap.day,
},
[`scheduled-date-chiclet-month-${i}`]: {
textContent: partsMap.month,
},
};
} else {
// This shouldn't happen.
imageUpdate = {};
}
}
// Create the date update. First, format the date.
let formattedDate;
if (Math.abs(daysUntil) <= 1) {
// Relative date: "Today", "Tomorrow", "Yesterday"
formattedDate = capitalizeString(
new Intl.RelativeTimeFormat(undefined, {
numeric: "auto",
}).format(daysUntil, "day")
);
} else {
// Formatted date with some combination of year, month, day, and weekday
let opts = {
timeZone: zonedNow.timeZoneId,
};
if (!isScheduled || icon || !isFuture) {
opts.month = "short";
opts.day = "numeric";
if (zonedDate.year != zonedNow.year) {
opts.year = "numeric";
}
}
if (isScheduled && isFuture) {
opts.weekday = "short";
}
formattedDate = new Intl.DateTimeFormat(undefined, opts).format(date);
}
// Now format the time.
let formattedTime;
if (isScheduled && daysUntil >= 0) {
formattedTime = new Intl.DateTimeFormat(undefined, {
hour: "numeric",
minute: "numeric",
timeZoneName: "short",
timeZone: zonedNow.timeZoneId,
}).format(date);
}
// Finally, create the date update.
let dateUpdate;
if (formattedTime) {
dateUpdate = {
[`date-${i}`]: {
l10n: {
id: "urlbar-result-sports-game-date-with-time",
args: {
date: formattedDate,
time: formattedTime,
},
},
},
};
} else {
dateUpdate = {
[`date-${i}`]: {
textContent: formattedDate,
},
};
}
// Create the status update. Show the status if the game is live or if it
// happened earlier today. Otherwise clear the status.
let statusUpdate;
if (item.status_type == "live") {
statusUpdate = {
[`status-${i}`]: {
l10n: {
id: "urlbar-result-sports-status-live",
},
},
};
} else if (daysUntil == 0 && item.status_type == "past") {
statusUpdate = {
[`status-${i}`]: {
l10n: {
id: "urlbar-result-sports-status-final",
},
},
};
} else {
statusUpdate = {
[`status-${i}`]: {
// Clear the status by setting its text to an empty string.
textContent: "",
},
};
}
return {
...imageUpdate,
...dateUpdate,
...statusUpdate,
[`sport-name-${i}`]: {
textContent: item.sport,
},
};
}
/**
* Parses a date and returns some info about it.
*
* This is a static method rather than a helper function internal to this file
* so that tests can easily test it.
*
* @param {Date} date
* A `Date` object.
* @returns {DateParseResult}
* The result.
*
* @typedef {object} DateParseResult
* @property {typeof Temporal.ZonedDateTime} zonedNow
* Now as a `ZonedDateTime`.
* @property {typeof Temporal.ZonedDateTime} zonedDate
* The passed-in date as a `ZonedDateTime`.
* @property {boolean} isFuture
* Whether the date is in the future.
* @property {number} daysUntil
* The number of calendar days from today to the date:
* If the date is after tomorrow: `Infinity`
* If the date is tomorrow: `1`
* If the date is today: `0`
* If the date is yesterday: `-1`
* If the date is before yesterday: `-Infinity`
*/
static _parseDate(date) {
// Find how many days there are from today to the date.
let zonedNow = SportsSuggestions._zonedDateTimeISO();
let zonedDate = date.toTemporalInstant().toZonedDateTimeISO(zonedNow);
let today = zonedNow.startOfDay();
let yesterday = today.subtract({ days: 1 });
let tomorrow = today.add({ days: 1 });
let dayAfterTomorrow = today.add({ days: 2 });
let daysUntil;
if (Temporal.ZonedDateTime.compare(dayAfterTomorrow, zonedDate) <= 0) {
// date is after tomorrow
daysUntil = Infinity;
} else if (Temporal.ZonedDateTime.compare(tomorrow, zonedDate) <= 0) {
// date is tomorrow
daysUntil = 1;
} else if (Temporal.ZonedDateTime.compare(today, zonedDate) <= 0) {
// date is today
daysUntil = 0;
} else if (Temporal.ZonedDateTime.compare(yesterday, zonedDate) <= 0) {
// date is yesterday
daysUntil = -1;
} else {
// date is before yesterday
daysUntil = -Infinity;
}
let isFuture = Temporal.ZonedDateTime.compare(zonedNow, zonedDate) < 0;
return {
zonedNow,
zonedDate,
isFuture,
daysUntil,
};
}
// Thin wrapper around `zonedDateTimeISO` so that tests can easily set a mock
// "now" date and time.
static _zonedDateTimeISO() {
return Temporal.Now.zonedDateTimeISO();
}
}
function itemIcon(item) {
return item.icon || item.home_team?.icon || item.away_team?.icon;
}
function stringifiedScore(scoreValue) {
let s = scoreValue;
if (typeof s == "number") {
s = String(s);
}
return typeof s == "string" ? s : "";
}
function capitalizeString(str) {
return str[0].toLocaleUpperCase() + str.substring(1);
}