- : 8 %
- : 7 %
- : 7 %
- : 7 %
- : 7 %
- : 7 %
- : 6 %
- : 6 %
- : 6 %
- : 4 %
- : 4 %
- : 4 %
- : 4 %
- : 4 %
- : 4 %
- : 4 %
- : 4 %
- : 30 %
- : 31 %
- : 31 %
- : 31 %
- : 32 %
- : 32 %
- : 33 %
- : 33 %
- : 33 %
- : 36 %
- : 33 %
- : 33 %
- : 34 %
- : 34 %
- : 33 %
- : 33 %
- : 33 %
- : 33 %
- : 33 %
- : 33 %
- : 36 %
- : 36 %
- : 36 %
- : 36 %
- : 36 %
- : 36 %
- : 36 %
- : 36 %
- : 36 %
- : 36 %
- : 36 %
- : 36 %
- : 36 %
- : 36 %
- : 36 %
- : 36 %
- : 36 %
- : 36 %
- : 36 %
- : 36 %
- : 36 %
- : 36 %
- : 36 %
- : 36 %
- : 36 %
- : 36 %
- : 36 %
- : 36 %
- : 36 %
- : 36 %
- : 36 %
- : 36 %
- : 36 %
- : 36 %
- : 36 %
- : 36 %
- : 4 %
- : 4 %
- : 4 %
- : 4 %
- : 4 %
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
// eslint-disable-next-line no-unused-vars
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useSelector, batch } from "react-redux";
import { actionCreators as ac, actionTypes as at } from "common/Actions.mjs";
import { useIntersectionObserver } from "../../../lib/utils";
import { SportsMatchRow, UpcomingMatchPlaceholder } from "./SportsMatchRow";
import { LivePagination } from "./LivePagination";
import { SizeSubmenu } from "../SizeSubmenu";
import { WidgetMenuFooter } from "../WidgetMenuFooter";
import { WatchLiveModal } from "./WatchLiveModal";
import { WIDGET_REGISTRY, resolveWidgetSize } from "common/WidgetsRegistry.mjs";
import {
useLocalizedTeamNames,
useTbdTeamName,
} from "./useLocalizedTeamNames.jsx";
import {
getMatchSectionL10nId,
groupMatchesBySection,
} from "./stageLabels.mjs";
import { WidgetCelebration } from "../WidgetCelebration";
import { useWidgetCelebration } from "../useWidgetCelebration";
import {
getMatchWinnerKey,
getTournamentPlacements,
getFinishedTournamentMatches,
isFinalStage,
isBronzeFinalStage,
} from "./matchResult.mjs";
import {
SportsResultCard,
SportsPodium,
SportsResultMascot,
} from "./SportsResultCelebration.jsx";
const WIDGET_STATES = {
INTRO: "sports-intro",
FOLLOW_TEAMS: "sports-follow-state",
MATCHES: "sports-matches",
KEY_DATES: "sports-key-dates",
};
const MATCHES_TABS = {
RESULTS: "results",
NOW: "now",
UPCOMING: "upcoming",
};
const SPORTS_CELEBRATION_ILLUSTRATION =
const SPORTS_RESULT_CONFETTI_COLORS = [
"var(--color-orange-30)",
"var(--color-pink-30)",
"var(--color-purple-30)",
"var(--color-yellow-30)",
"var(--color-green-30)",
"var(--color-cyan-30)",
];
function getVisibleMatchesTabs(hasLiveGames, hasPreviousResults) {
return (
Object.values(MATCHES_TABS)
// Only show the Now tab when there are live games.
.filter(id => id !== MATCHES_TABS.NOW || hasLiveGames)
.map(id => ({
id,
// Disable the Results tab until previous match data is available.
disabled: id === MATCHES_TABS.RESULTS && !hasPreviousResults,
}))
);
}
const USER_ACTION_TYPES = {
FOLLOW_TEAMS: "follow_teams",
SAVE_TEAMS: "save_teams",
VIEW_UPCOMING: "view_upcoming",
VIEW_RESULTS: "view_results",
VIEW_MATCHES: "view_matches",
VIEW_KEY_DATES: "view_key_dates",
CHANGE_SIZE: "change_size",
CHANGE_TAB: "change_tab",
LEARN_MORE: "learn_more",
TOGGLE_FOLLOWED_ONLY: "toggle_followed_only",
REFRESH_LIVE: "refresh_live",
};
// UI-side cooldown between successive clicks of the live refresh button. Must
// match (or exceed) the MIN_MANUAL_REFRESH_MS floor enforced by SportsFeed —
// the feed silently drops faster requests, so a shorter button cooldown would
// surface as a no-op click.
const LIVE_REFRESH_COOLDOWN_MS = 15000;
// Minimum time the refresh icon spins after a click, so even an instant /live
// response still reads as "something happened" rather than a flicker.
const LIVE_REFRESH_MIN_SPIN_MS = 2000;
const PREF_NOVA_ENABLED = "nova.enabled";
const PREF_SPORTS_WIDGET_SIZE = "widgets.sportsWidget.size";
const PREF_SPORTS_WIDGET_LIVE_ENABLED = "widgets.sportsWidget.live.enabled";
const PREF_FORCE_LIVE_DATA_TRUSTABLE = "widgets.sports.forceLiveDataTrustable";
const PREF_SPORTS_CELEBRATIONS_ENABLED =
"widgets.sportsWidget.celebrations.enabled";
const PREF_SPORTS_CELEBRATIONS_WINDOW_MS =
"widgets.sportsWidget.celebrations.windowMs";
const DEFAULT_CELEBRATION_WINDOW_MS = 86400000; // 24 hours
// World Cup 2026 kickoff: June 11, 2026 at 19:00 UTC. Used as a temporary
// guard to ignore /live data while the endpoint still serves mock matches
// pre-kickoff. Remove this once the backend returns empty pre-kickoff.
const WORLD_CUP_KICKOFF_MS = Date.UTC(2026, 5, 11, 19, 0, 0);
const SPORTS_WIDGET_REGISTRY_ENTRY = WIDGET_REGISTRY.find(
widget => widget.id === "sportsWidget"
);
// Stable sort that bubbles matches involving a followed team to the front
// while preserving the original chronological order otherwise.
function sortFollowedFirst(matches, selectedTeamsSet) {
if (!selectedTeamsSet.size) {
return matches;
}
const involvesFollowed = match =>
selectedTeamsSet.has(match.home_team?.key) ||
selectedTeamsSet.has(match.away_team?.key);
return [...matches]
.map((match, index) => ({ match, index }))
.sort((a, b) => {
const aFollowed = involvesFollowed(a.match) ? 1 : 0;
const bFollowed = involvesFollowed(b.match) ? 1 : 0;
if (aFollowed !== bFollowed) {
return bFollowed - aFollowed;
}
return a.index - b.index;
})
.map(entry => entry.match);
}
// The match that most recently ended and is still eligible to celebrate:
// within the window and not yet celebrated. Keyed off the feed's `endedAt`
// stamp rather than the display order, so the celebration targets the match
// that actually ended even when it isn't the top result. Searches finished
// (previous) and current matches, since a just-ended match can briefly remain
// in `current` before the backend moves it to `previous`.
function findCelebrationMatch(matches, celebrations, windowMs) {
const endedAt = celebrations?.endedAt;
if (!endedAt) {
return null;
}
const celebrated = new Set(celebrations?.celebrated ?? []);
const now = Date.now();
let best = null;
for (const match of matches) {
const id = match?.global_event_id;
const ts = id === null || id === undefined ? undefined : endedAt[id];
if (!ts || now - ts >= windowMs || celebrated.has(id)) {
continue;
}
if (!best || ts > endedAt[best.global_event_id]) {
best = match;
}
}
return best;
}
// Live matches keep priority because the result view hides the tab bar.
export function shouldShowResultView({
celebrationsEnabled,
resultViewReady,
hasLiveGames,
isMatchesState,
isResultsTab,
showResultsList,
}) {
return (
celebrationsEnabled &&
resultViewReady &&
!hasLiveGames &&
isMatchesState &&
isResultsTab &&
!showResultsList
);
}
// Moves the match with `id` to the front of `matches` (used to surface the
// just-ended match as the Results highlight). No-op when it isn't present.
function bubbleMatchToFront(matches, id) {
if (id === null || id === undefined) {
return matches;
}
const index = matches.findIndex(match => match.global_event_id === id);
if (index <= 0) {
return matches;
}
const next = [...matches];
const [match] = next.splice(index, 1);
next.unshift(match);
return next;
}
// Returns the match shown in the highlight view for the active tab, or null
// when the user has expanded a list view (no highlight is visible then).
function getHighlightMatch({
widgetState,
activeTab,
showResultsList,
showUpcomingList,
sortedPrevious,
sortedCurrent,
sortedNext,
liveIndex,
}) {
if (widgetState !== WIDGET_STATES.MATCHES) {
return null;
}
if (activeTab === MATCHES_TABS.RESULTS && !showResultsList) {
return sortedPrevious[0] || null;
}
if (activeTab === MATCHES_TABS.NOW) {
return sortedCurrent[liveIndex] || sortedCurrent[0] || null;
}
if (activeTab === MATCHES_TABS.UPCOMING && !showUpcomingList) {
return sortedNext[0] || null;
}
return null;
}
// Builds a CSS gradient string from the followed team's `colors` palette in
// the highlight state. The gradient doesn't show when both teams in the match
// are followed or when neither team is followed.
function getFollowedGradient(match, selectedTeamsSet, teamColorsByKey) {
if (!match) {
return null;
}
const homeFollowed = selectedTeamsSet.has(match.home_team?.key);
const awayFollowed = selectedTeamsSet.has(match.away_team?.key);
if (homeFollowed === awayFollowed) {
return null;
}
const followedKey = homeFollowed
? match.home_team?.key
: match.away_team?.key;
const colors = teamColorsByKey.get(followedKey);
if (!colors || colors.length < 2) {
return null;
}
return `linear-gradient(to right, ${colors.join(", ")})`;
}
// When the Now tab has 2+ live games, the widget root is labelled by the
// visible "Now" tab so screen readers can name the live-matches region.
function getCarouselArticleAttrs(active) {
return active ? { "aria-labelledby": "sports-now-tab" } : null;
}
// eslint-disable-next-line max-statements, complexity
function SportsWidget({ dispatch, handleUserInteraction, widgetEnabledMap }) {
const prefs = useSelector(state => state.Prefs.values);
const sportsWidgetData = useSelector(state => state.SportsWidget);
// Resolved once here and passed down to every match row so a list of matches
// makes a single Fluent lookup for the undecided-team aria-label name.
const tbdTeamName = useTbdTeamName();
const widgetSize = resolveWidgetSize(SPORTS_WIDGET_REGISTRY_ENTRY, prefs);
// Mirror SportsFeed.liveEnabled — raw pref OR the trainhop override. The
// canonical key is trainhopConfig.widgets.sportsWidgetLiveEnabled (the flat
// sportsWidget-prefixed convention shared by every widget); the legacy
// trainhopConfig.sports.liveEnabled is still honored for in-flight rollouts.
// Reading the raw pref alone would leave a Nimbus-only rollout in a
// permanently-paused state: the feed would start polling, but tick()
// bails on empty visibleTabs and we'd never attach the observer to dispatch
// WIDGETS_SPORTS_LIVE_VISIBLE.
const liveEnabled =
prefs[PREF_SPORTS_WIDGET_LIVE_ENABLED] ||
prefs.trainhopConfig?.widgets?.sportsWidgetLiveEnabled ||
prefs.trainhopConfig?.sports?.liveEnabled;
const widgetsMayBeMaximized = prefs["widgets.system.maximized"];
const widgetsMaximized = prefs["widgets.maximized"];
// /live currently serves mock data pre-kickoff, so ignore its contents
// until the kickoff timestamp. Drop this guard once the backend returns
// empty pre-kickoff.
const liveDataTrustable =
Date.now() >= WORLD_CUP_KICKOFF_MS || prefs[PREF_FORCE_LIVE_DATA_TRUSTABLE];
const hasLiveGames =
liveDataTrustable && sportsWidgetData?.data?.live?.length > 0;
// The watch-links endpoint only lists broadcasters for supported countries.
// The backend hoists the user's own country into `your_region`, so a
// non-empty `your_region` means the user's region is supported and the
// "Watch live" entry point should be shown; an empty one (e.g. Turkey) hides
// it.
const canWatchLive =
sportsWidgetData?.watchLive?.data?.your_region?.length > 0;
const hasPreviousResults =
sportsWidgetData?.data?.matches?.previous?.length > 0;
// Upcoming matches alone don't mean the tournament has started — the backend
// surfaces them within a +/-21 day window around kickoff, so they appear
// pre-kickoff. Only live games or previous results are deterministic signals
// that the tournament is underway.
const tournamentStarted = hasLiveGames || hasPreviousResults;
const savedWidgetState = sportsWidgetData.widgetState || WIDGET_STATES.INTRO;
// Once the backend has any match data (live or completed), skip
// the intro and open on the match schedule.
const widgetState =
tournamentStarted && savedWidgetState === WIDGET_STATES.INTRO
? WIDGET_STATES.MATCHES
: savedWidgetState;
const rawSelectedTeams = sportsWidgetData.selectedTeams;
const rawTeams = sportsWidgetData?.data?.teams;
const rawMatches = sportsWidgetData?.data?.matches;
const rawLive = liveDataTrustable ? sportsWidgetData?.data?.live : null;
const selectedTeams = useMemo(
() => rawSelectedTeams || [],
[rawSelectedTeams]
);
const teams = useMemo(() => rawTeams ?? [], [rawTeams]);
const localizedNames = useLocalizedTeamNames(teams);
const { matchesTab } = sportsWidgetData;
const hasUserSelectedTab = useRef(false);
// When the Now tab disappears (live games ended), the persisted `matchesTab`
// may still be "now". That would hide every panel and leave the widget
// blank with no tab visibly selected. Fall back to "Upcoming" so the next
// matches show by default.
const resolvedMatchesTab =
matchesTab === MATCHES_TABS.NOW && !hasLiveGames
? MATCHES_TABS.UPCOMING
: matchesTab;
const activeTab =
hasLiveGames && !hasUserSelectedTab.current
? MATCHES_TABS.NOW
: resolvedMatchesTab;
// Defensive clamp on the persisted live-pager index. The feed re-clamps
// after every fetch, but the restored cached index may briefly exceed the
// current live list (e.g. mid-flight between a fetch and the matching
// SET_LIVE_INDEX broadcast). When the live list is empty, the inner
// `Math.max((length ?? 0) - 1, 0)` collapses to 0, pinning liveIndex to 0.
const liveIndex = Math.min(
Math.max(sportsWidgetData.liveIndex ?? 0, 0),
Math.max((rawLive?.length ?? 0) - 1, 0)
);
// Set of followed team keys that are still in the tournament. Eliminated
// teams drop out so the rest of the UI (toggle, bubble-to-front sort,
// gradient border, per-row check/bold) behaves as if the user weren't
// following them anymore. The raw `selectedTeams` array is kept intact for
// the Follow Teams editor so users still see their original selection when
// re-opening it.
const selectedTeamsSet = useMemo(() => {
const eliminated = new Set();
for (const team of teams) {
if (team.eliminated) {
eliminated.add(team.key);
}
}
return new Set(selectedTeams.filter(key => !eliminated.has(key)));
}, [selectedTeams, teams]);
// Map of team key -> colors[] for looking up the gradient palette of a
// followed team in the currently-highlighted match.
const teamColorsByKey = useMemo(() => {
const map = new Map();
for (const team of teams) {
if (Array.isArray(team.colors) && team.colors.length) {
map.set(team.key, team.colors);
}
}
return map;
}, [teams]);
// Celebration window (trainhop > pref > default) and the match that just
// ended, keyed off the feed's `endedAt` stamp rather than the display order.
// It's surfaced as the Results highlight (below) and consumed by the
// celebration trigger, so the celebration targets the match that actually
// ended even when it isn't the top result.
const { celebrations } = sportsWidgetData;
const celebrationWindowMs =
prefs.trainhopConfig?.sportsCelebrations?.windowMs ??
prefs.trainhopConfig?.widgets?.sportsWidgetCelebrationsWindowMs ??
prefs.trainhopConfig?.sports?.celebrationsWindowMs ??
prefs[PREF_SPORTS_CELEBRATIONS_WINDOW_MS] ??
DEFAULT_CELEBRATION_WINDOW_MS;
const celebrationMatch = useMemo(
() =>
findCelebrationMatch(
[...(rawMatches?.previous ?? []), ...(rawMatches?.current ?? [])],
celebrations,
celebrationWindowMs
),
[rawMatches, celebrations, celebrationWindowMs]
);
const placements = useMemo(
() => getTournamentPlacements(getFinishedTournamentMatches(rawMatches)),
[rawMatches]
);
const tournamentDecided = !!placements.champion;
const finalMatch = useMemo(() => {
const all = [
...(rawMatches?.next ?? []),
...(rawMatches?.current ?? []),
...(rawMatches?.previous ?? []),
];
return all.find(match => isFinalStage(match?.stage)) ?? null;
}, [rawMatches]);
// Bubble followed teams to the front for the highlight view and list view
// when the followed-only toggle is on; with it off, matches stay chronological.
// The just-ended celebration match always bubbles to the very front so the
// celebration plays over its result.
const resultsFollowedOnly = sportsWidgetData.followedOnly?.results ?? true;
const upcomingFollowedOnly = sportsWidgetData.followedOnly?.upcoming ?? true;
const { sortedPrevious, sortedCurrent, sortedNext } = useMemo(() => {
const previous = rawMatches?.previous ?? [];
const next = rawMatches?.next ?? [];
return {
sortedPrevious: bubbleMatchToFront(
resultsFollowedOnly
? sortFollowedFirst(previous, selectedTeamsSet)
: previous,
celebrationMatch?.global_event_id
),
sortedCurrent: sortFollowedFirst(rawLive ?? [], selectedTeamsSet),
sortedNext: upcomingFollowedOnly
? sortFollowedFirst(next, selectedTeamsSet)
: next,
};
}, [
rawMatches,
rawLive,
selectedTeamsSet,
resultsFollowedOnly,
upcomingFollowedOnly,
celebrationMatch,
]);
// List-view toggle states for the Results and Upcoming tabs are lifted up
// here so we can tell whether a highlight match is currently visible (for
// applying the followed-team gradient on the article wrapper) and so we
// can force the widget into the large size while the list view is open.
const [showResultsList, setShowResultsList] = useState(false);
const [showUpcomingList, setShowUpcomingList] = useState(false);
// Close any open "View All" when the user minimizes the widgets section,
// so the Sports widget size also changes from Large to Medium. The Follow
// teams flow stays open — closing it would discard in-progress selections.
useEffect(() => {
if (!widgetsMaximized) {
setShowResultsList(false);
setShowUpcomingList(false);
}
}, [widgetsMaximized]);
// Expand the widget to the large size when the user opens the match list
// view ("View all") on either the Results or Upcoming tab, and restore the
// user's chosen size when they collapse back to the highlight view. The
// size pref itself is left untouched — this is purely a visual override.
const isMatchesListView =
widgetState === WIDGET_STATES.MATCHES &&
((activeTab === MATCHES_TABS.RESULTS && showResultsList) ||
(activeTab === MATCHES_TABS.UPCOMING && showUpcomingList));
const displaySize =
widgetState === WIDGET_STATES.FOLLOW_TEAMS || isMatchesListView
? "large"
: widgetSize;
const highlightMatch = getHighlightMatch({
widgetState,
activeTab,
showResultsList,
showUpcomingList,
sortedPrevious,
sortedCurrent,
sortedNext,
liveIndex,
});
const followedGradient = getFollowedGradient(
highlightMatch,
selectedTeamsSet,
teamColorsByKey
);
const fetchError = sportsWidgetData?.data?.fetchError ?? null;
const impressionFired = useRef(false);
const errorFired = useRef(false);
const introVideoRef = useRef(null);
// Caps the intro animation to two plays per widget mount.
// Toggling the widget off and back on remounts the component and resets this counter.
// You can also refresh the new tab page or open a new tab to reset the counter.
const introVideoPlayCount = useRef(0);
const playIntroVideo = useMemo(() => {
const prefersReducedMotion =
globalThis.matchMedia?.("(prefers-reduced-motion: reduce)").matches ??
false;
const maxIntroVideoPlays = 2;
return () => {
if (prefersReducedMotion) {
return;
}
if (introVideoPlayCount.current >= maxIntroVideoPlays) {
return;
}
const video = introVideoRef.current;
if (!video || !video.paused) {
return;
}
video.currentTime = 0;
video
.play()
.then(() => {
introVideoPlayCount.current += 1;
})
.catch(() => {});
};
}, []);
const [watchLiveOpen, setWatchLiveOpen] = useState(false);
const handleIntersection = useCallback(() => {
if (impressionFired.current) {
return;
}
impressionFired.current = true;
dispatch(
ac.AlsoToMain({
type: at.WIDGETS_IMPRESSION,
data: {
widget_name: "sports",
widget_size: widgetSize,
},
})
);
}, [dispatch, widgetSize]);
const widgetRef = useIntersectionObserver(handleIntersection);
// Track the article element via state so the live-visibility effect below
// re-runs whenever React mounts a new node (e.g. after an early-return
// gate flips and the article appears for the first time). widgetRef is a
// stable useRef and can't drive re-runs on its own.
const [liveEl, setLiveEl] = useState(null);
// End-of-match celebration.
const celebrationRef = useRef(null);
const {
celebrationFrame,
celebrationId,
completeCelebration,
isCelebrating,
triggerCelebration,
} = useWidgetCelebration(celebrationRef);
const [celebrationColors, setCelebrationColors] = useState(null);
const {
celebrationFrame: resultFrame,
celebrationId: resultCelebrationId,
completeCelebration: completeResultCelebration,
isCelebrating: isResultCelebrating,
triggerCelebration: triggerResultCelebration,
} = useWidgetCelebration(celebrationRef);
// Seam consumed by the detection layer (Patch 2): a followed-team win passes
// that team's colors; any other ended match passes none (generic). Celebrations
// are off by default and opt-in via the pref OR trainhopConfig, so they ship
// dark and can be enabled remotely without risking the rest of the widget.
// Canonical trainhop key is the dedicated trainhopConfig.sportsCelebrations
// namespace; the widgets/sports reads remain as fallbacks.
/**
* @backward-compat { version 153 }
* The trainhopConfig namespace migrated from the nested sports.* keys to the
* flat widgets.sportsWidget* keys (D303931). This celebration ships via the
* newtab XPI (train-hop), so it can run on a Firefox serving either
* namespace — read both. Remove the legacy
* trainhopConfig.sports.celebrationsEnabled read once 153 reaches Release.
*/
const celebrationsEnabled =
prefs[PREF_SPORTS_CELEBRATIONS_ENABLED] ||
prefs.trainhopConfig?.sportsCelebrations?.enabled ||
prefs.trainhopConfig?.widgets?.sportsWidgetCelebrationsEnabled ||
prefs.trainhopConfig?.sports?.celebrationsEnabled;
const celebrate = useCallback(
(kind, colors = null) => {
if (!celebrationsEnabled) {
return;
}
setCelebrationColors(kind === "followed" ? colors : null);
triggerCelebration();
},
[triggerCelebration, celebrationsEnabled]
);
// Celebration trigger: fire once for the match that just ended (the freshest
// endedAt within the window, surfaced as the Results highlight above) when
// the user is viewing the Results tab with the widget on-screen. Followed
// team won/tied -> team colors; no followed team -> generic; followed loss ->
// nothing. celebratedRef guards against re-firing within this session;
// `celebrations.celebrated` (persisted by the feed) guards across reloads.
const celebratedRef = useRef(new Set());
const [isPageVisible, setIsPageVisible] = useState(
typeof document === "undefined" || document.visibilityState === "visible"
);
useEffect(() => {
const onVisibility = () =>
setIsPageVisible(document.visibilityState === "visible");
document.addEventListener("visibilitychange", onVisibility);
return () => document.removeEventListener("visibilitychange", onVisibility);
}, []);
// Whether the widget itself is scrolled into view. Gating consumption on this
// (in addition to isPageVisible) prevents an off-screen widget from spending
// the one-shot celebration before the user can see it. Starts false so a
// never-observed widget can't fire; the observer reports the real state on
// attach. (isPageVisible is still needed: a backgrounded tab keeps reporting
// the element as intersecting.)
const [isWidgetVisible, setIsWidgetVisible] = useState(false);
useEffect(() => {
if (!celebrationsEnabled || !liveEl) {
return undefined;
}
const observer = new IntersectionObserver(
([entry]) => setIsWidgetVisible(entry.isIntersecting),
{ threshold: 0.3 }
);
observer.observe(liveEl);
return () => observer.disconnect();
}, [celebrationsEnabled, liveEl]);
useEffect(() => {
if (
!celebrationsEnabled ||
!isPageVisible ||
!isWidgetVisible ||
widgetState !== WIDGET_STATES.MATCHES ||
activeTab !== MATCHES_TABS.RESULTS ||
showResultsList
) {
return;
}
const match = celebrationMatch;
if (!match || celebratedRef.current.has(match.global_event_id)) {
return;
}
// The result view handles Final and Bronze Final celebrations.
if (isFinalStage(match.stage) || isBronzeFinalStage(match.stage)) {
return;
}
const id = match.global_event_id;
const winnerKey = getMatchWinnerKey(match);
const homeKey = match.home_team.key;
const awayKey = match.away_team.key;
// Ownership uses the raw saved selections, not selectedTeamsSet (which
// drops eliminated teams). A followed team's knockout loss eliminates it,
// so selectedTeamsSet would make it look unfollowed and fire the generic
// celebration instead of suppressing it.
const homeFollowed = selectedTeams.includes(homeKey);
const awayFollowed = selectedTeams.includes(awayKey);
let followedKey = null;
if (homeFollowed && awayFollowed) {
// Both followed: celebrate the winner (home on a draw).
followedKey = winnerKey || homeKey;
} else if (homeFollowed) {
followedKey = homeKey;
} else if (awayFollowed) {
followedKey = awayKey;
}
// Consume the event up front so it never re-fires (and a suppressed
// followed loss can't replay as a generic celebration after an unfollow).
celebratedRef.current.add(id);
dispatch(
ac.AlsoToMain({ type: at.WIDGETS_SPORTS_MARK_CELEBRATED, data: id })
);
// A followed team that lost gets no animation (ties count as a win).
if (followedKey && winnerKey && winnerKey !== followedKey) {
return;
}
if (followedKey) {
celebrate("followed", teamColorsByKey.get(followedKey));
} else {
celebrate("generic");
}
}, [
celebrationsEnabled,
isPageVisible,
isWidgetVisible,
widgetState,
activeTab,
showResultsList,
celebrationMatch,
selectedTeams,
teamColorsByKey,
celebrate,
dispatch,
]);
const resultViewReady = tournamentDecided || !!placements.third;
let resultTriggerId = null;
if (tournamentDecided) {
const { match, team } = placements.champion;
resultTriggerId = `final:${match.global_event_id}:${team.key}`;
} else if (placements.third) {
const { match, team } = placements.third;
resultTriggerId = `third:${match.global_event_id}:${team.key}`;
}
const showResultView = shouldShowResultView({
celebrationsEnabled,
resultViewReady,
hasLiveGames,
isMatchesState: widgetState === WIDGET_STATES.MATCHES,
isResultsTab: activeTab === MATCHES_TABS.RESULTS,
showResultsList,
});
// The result mascot is an animated WebP that can't be paused, so don't render
// it for reduced-motion users (the confetti/fireworks overlay is suppressed
// the same way in useWidgetCelebration). The static result card still shows.
const prefersReducedMotion =
globalThis.matchMedia?.("(prefers-reduced-motion: reduce)").matches ??
false;
// Visibility-gated so an off-screen widget can't spend the one-shot animation.
const resultCelebratedRef = useRef(null);
useEffect(() => {
if (
!showResultView ||
!resultTriggerId ||
!isPageVisible ||
!isWidgetVisible ||
resultCelebratedRef.current === resultTriggerId
) {
return;
}
resultCelebratedRef.current = resultTriggerId;
triggerResultCelebration();
}, [
showResultView,
resultTriggerId,
isPageVisible,
isWidgetVisible,
triggerResultCelebration,
]);
// Live polling visibility gate. Separate from the one-shot impression
// observer above (which unobserves after the first intersect) — this one
// fires on every enter/leave so the feed can pause polling when no tab
// has the widget on-screen. Also listens for tab visibility changes:
// IntersectionObserver only reports viewport intersection, so a
// backgrounded tab would otherwise keep reporting VISIBLE forever.
useEffect(() => {
if (!liveEnabled || !liveEl) {
return undefined;
}
let isIntersecting = false;
const dispatchState = visible => {
dispatch(
ac.OnlyToMain({
type: visible
? at.WIDGETS_SPORTS_LIVE_VISIBLE
: at.WIDGETS_SPORTS_LIVE_HIDDEN,
})
);
};
const observer = new IntersectionObserver(
([entry]) => {
isIntersecting = entry.isIntersecting;
dispatchState(isIntersecting && !document.hidden);
},
// Match the impression observer's threshold so "visible enough to
// count" means the same thing for both.
{ threshold: 0.3 }
);
observer.observe(liveEl);
const onVisibilityChange = () =>
dispatchState(isIntersecting && !document.hidden);
document.addEventListener("visibilitychange", onVisibilityChange);
return () => {
observer.disconnect();
document.removeEventListener("visibilitychange", onVisibilityChange);
};
}, [liveEnabled, dispatch, liveEl]);
const handleErrorIntersection = useCallback(() => {
if (!fetchError || errorFired.current) {
return;
}
errorFired.current = true;
// Fire from the content side so telemetry can tie the event to a tab
// session. Events dispatched from the main process lack that link and get dropped.
dispatch(
ac.AlsoToMain({
type: at.WIDGETS_ERROR,
data: {
widget_name: "sports",
widget_size: widgetSize,
error_type: fetchError.error_type,
},
})
);
}, [dispatch, fetchError, widgetSize]);
const errorRef = useIntersectionObserver(handleErrorIntersection);
const handleInteraction = useCallback(
() => handleUserInteraction("sportsWidget"),
[handleUserInteraction]
);
function handleFollowTeams(widgetSource) {
dispatch(
ac.OnlyToMain({
type: at.WIDGETS_USER_EVENT,
data: {
widget_name: "sports",
widget_source: widgetSource,
user_action: USER_ACTION_TYPES.FOLLOW_TEAMS,
widget_size: widgetSize,
},
})
);
// Tell the backend the widget state changed — it will save it and update the UI.
dispatch(
ac.AlsoToMain({
type: at.WIDGETS_SPORTS_CHANGE_WIDGET_STATE,
data: WIDGET_STATES.FOLLOW_TEAMS,
})
);
handleInteraction();
}
function handleViewUpcoming() {
// Mark this as an explicit tab choice so the live-games auto-override
// doesn't pin activeTab back to NOW.
hasUserSelectedTab.current = true;
batch(() => {
dispatch(
ac.OnlyToMain({
type: at.WIDGETS_USER_EVENT,
data: {
widget_name: "sports",
widget_source: "context_menu",
user_action: USER_ACTION_TYPES.VIEW_UPCOMING,
widget_size: widgetSize,
},
})
);
dispatch(
ac.AlsoToMain({
type: at.WIDGETS_SPORTS_CHANGE_WIDGET_STATE,
data: WIDGET_STATES.MATCHES,
})
);
dispatch(
ac.AlsoToMain({
type: at.WIDGETS_SPORTS_CHANGE_MATCHES_TAB,
data: MATCHES_TABS.UPCOMING,
})
);
});
handleInteraction();
}
function handleViewResults() {
// Mark this as an explicit tab choice so the live-games auto-override
// doesn't pin activeTab back to NOW.
hasUserSelectedTab.current = true;
batch(() => {
dispatch(
ac.OnlyToMain({
type: at.WIDGETS_USER_EVENT,
data: {
widget_name: "sports",
widget_source: "context_menu",
user_action: USER_ACTION_TYPES.VIEW_RESULTS,
widget_size: widgetSize,
},
})
);
dispatch(
ac.AlsoToMain({
type: at.WIDGETS_SPORTS_CHANGE_WIDGET_STATE,
data: WIDGET_STATES.MATCHES,
})
);
dispatch(
ac.AlsoToMain({
type: at.WIDGETS_SPORTS_CHANGE_MATCHES_TAB,
data: MATCHES_TABS.RESULTS,
})
);
});
handleInteraction();
}
function handleViewKeyDates(widgetSource) {
batch(() => {
dispatch(
ac.OnlyToMain({
type: at.WIDGETS_USER_EVENT,
data: {
widget_name: "sports",
widget_source: widgetSource,
user_action: USER_ACTION_TYPES.VIEW_KEY_DATES,
widget_size: widgetSize,
},
})
);
dispatch(
ac.AlsoToMain({
type: at.WIDGETS_SPORTS_CHANGE_WIDGET_STATE,
data: WIDGET_STATES.KEY_DATES,
})
);
});
handleInteraction();
}
const handleChangeSize = useCallback(
size => {
batch(() => {
dispatch(
ac.OnlyToMain({
type: at.SET_PREF,
data: { name: PREF_SPORTS_WIDGET_SIZE, value: size },
})
);
dispatch(
ac.OnlyToMain({
type: at.WIDGETS_USER_EVENT,
data: {
widget_name: "sports",
widget_source: "context_menu",
user_action: USER_ACTION_TYPES.CHANGE_SIZE,
action_value: size,
widget_size: size,
},
})
);
});
},
[dispatch]
);
function handleViewMatches(widgetSource) {
batch(() => {
dispatch(
ac.OnlyToMain({
type: at.WIDGETS_USER_EVENT,
data: {
widget_name: "sports",
widget_source: widgetSource,
user_action: USER_ACTION_TYPES.VIEW_MATCHES,
widget_size: widgetSize,
},
})
);
dispatch(
ac.AlsoToMain({
type: at.WIDGETS_SPORTS_CHANGE_WIDGET_STATE,
data: WIDGET_STATES.MATCHES,
})
);
});
handleInteraction();
}
function handleLearnMore() {
dispatch(
ac.OnlyToMain({
type: at.WIDGETS_USER_EVENT,
data: {
widget_name: "sports",
widget_source: "context_menu",
user_action: USER_ACTION_TYPES.LEARN_MORE,
widget_size: widgetSize,
},
})
);
handleInteraction();
}
// Discard any team changes and go back to the intro state.
const handleCancelSelection = useCallback(
() =>
dispatch(
ac.AlsoToMain({
type: at.WIDGETS_SPORTS_CHANGE_WIDGET_STATE,
data: WIDGET_STATES.INTRO,
})
),
[dispatch]
);
const handleSaveSelection = useCallback(
newSelectedTeams => {
if (newSelectedTeams.length) {
dispatch(
ac.OnlyToMain({
type: at.WIDGETS_USER_EVENT,
data: {
widget_name: "sports",
widget_source: "widget",
user_action: USER_ACTION_TYPES.SAVE_TEAMS,
action_value: newSelectedTeams.length,
widget_size: widgetSize,
},
})
);
}
dispatch(
ac.AlsoToMain({
type: at.WIDGETS_SPORTS_CHANGE_SELECTED_TEAMS,
data: newSelectedTeams,
})
);
handleCancelSelection();
},
[dispatch, widgetSize, handleCancelSelection]
);
const handleViewIntro = useCallback(
() =>
dispatch(
ac.AlsoToMain({
type: at.WIDGETS_SPORTS_CHANGE_WIDGET_STATE,
data: WIDGET_STATES.INTRO,
})
),
[dispatch]
);
const handleMatchesTabChange = useCallback(
tab => {
if (tab === activeTab) {
return;
}
hasUserSelectedTab.current = true;
batch(() => {
dispatch(
ac.OnlyToMain({
type: at.WIDGETS_USER_EVENT,
data: {
widget_name: "sports",
widget_source: "widget",
user_action: USER_ACTION_TYPES.CHANGE_TAB,
action_value: tab,
widget_size: widgetSize,
},
})
);
dispatch(
ac.AlsoToMain({
type: at.WIDGETS_SPORTS_CHANGE_MATCHES_TAB,
data: tab,
})
);
});
handleInteraction();
},
[dispatch, widgetSize, activeTab, handleInteraction]
);
// @nova-cleanup(remove-gate): Remove this guard and PREF_NOVA_ENABLED after Nova ships
if (!prefs[PREF_NOVA_ENABLED]) {
return null;
}
// A followed-team celebration (team colors passed) gets a 2px linear-gradient
// border in the followed team's colors instead of the generic animated
// stroke. The gradient feeds --sports-celebration-border-gradient.
const isFollowedCelebration = isCelebrating && !!celebrationColors?.length;
// `to right` keeps the gradient's midpoint centered (green left -> white
// center -> red right), matching the followed-highlight border.
const celebrationBorderGradient = celebrationColors?.length
? `linear-gradient(to right, ${celebrationColors.join(", ")})`
: null;
const widgetStyle = {
...(followedGradient && { "--sports-followed-gradient": followedGradient }),
...(celebrationBorderGradient && {
"--sports-celebration-border-gradient": celebrationBorderGradient,
}),
};
// Result-view selection:
// - Interim (3rd Place decided, Final not yet played): the third-place card,
// shown to everyone.
// - Final decided: a follower of the runner-up team gets the full podium
// (their team's moment); everyone else gets the champion card. The podium
// only fits the large widget, so it falls back to the champion card at
// medium. `selectedTeams` (not the eliminated-filtered set) is used so the
// runner-up — eliminated by losing the Final — still counts as followed.
const pickResultView = () => {
if (!placements.champion) {
return placements.third ? "third" : null;
}
const followsRunnerUp =
!!placements.runnerUp &&
selectedTeams.includes(placements.runnerUp.team.key);
if (followsRunnerUp && placements.third && displaySize === "large") {
return "podium";
}
return "champion";
};
const resultView = showResultView ? pickResultView() : null;
// Confetti/fireworks take the celebrated team's colors: the third-place team
// (interim), the runner-up (their podium), otherwise the champion.
let resultHeroTeam = placements.champion?.team;
if (resultView === "third") {
resultHeroTeam = placements.third?.team;
} else if (resultView === "podium") {
resultHeroTeam = placements.runnerUp?.team;
}
const resultConfettiColors =
(resultHeroTeam && teamColorsByKey.get(resultHeroTeam.key)) ||
SPORTS_RESULT_CONFETTI_COLORS;
let resultBody = null;
if (resultView === "podium") {
resultBody = (
<SportsPodium placements={placements} localizedNames={localizedNames} />
);
} else if (resultView === "champion" && placements.champion) {
resultBody = (
<SportsResultCard
team={placements.champion.team}
type="champion"
size={displaySize}
localizedNames={localizedNames}
/>
);
} else if (resultView === "third" && placements.third) {
resultBody = (
<SportsResultCard
team={placements.third.team}
type="third"
size={displaySize}
finalMatch={displaySize === "large" ? finalMatch : null}
finalMatchVariant="upcoming"
tbdTeamName={tbdTeamName}
localizedNames={localizedNames}
/>
);
}
return (
<article
className={`sports widget col-4 ${displaySize}-widget ${widgetState}${
followedGradient ? " is-followed-highlight" : ""
}${isCelebrating ? " is-celebrating" : ""}${
isFollowedCelebration ? " is-followed-celebration" : ""
}${resultBody ? " is-result-view" : ""}`}
style={widgetStyle}
ref={el => {
widgetRef.current = [el];
celebrationRef.current = el;
setLiveEl(el);
// Only attach the error observer when there's something to report —
// otherwise the first intersect with no fetchError adds the target to
// the hook's internal WeakSet and a fetchError arriving later never fires.
errorRef.current = fetchError ? [el] : [];
}}
onMouseEnter={playIntroVideo}
onFocus={e => {
if (!e.currentTarget.contains(e.relatedTarget)) {
playIntroVideo();
}
}}
{...getCarouselArticleAttrs(
activeTab === MATCHES_TABS.NOW && (rawLive?.length ?? 0) >= 2
)}
>
{isCelebrating && celebrationFrame ? (
<WidgetCelebration
classNamePrefix="sports-celebration"
celebrationFrame={celebrationFrame}
celebrationId={celebrationId}
confettiColors={celebrationColors ?? undefined}
confettiShape="soccer"
illustrationSrc={SPORTS_CELEBRATION_ILLUSTRATION}
onComplete={completeCelebration}
/>
) : null}
{isResultCelebrating && resultFrame ? (
<WidgetCelebration
classNamePrefix="sports-celebration"
celebrationFrame={resultFrame}
celebrationId={resultCelebrationId}
confettiColors={resultConfettiColors}
confettiShape="soccer"
confettiCount={84}
fireworkBursts={10}
onComplete={completeResultCelebration}
/>
) : null}
{resultBody && resultView && !prefersReducedMotion ? (
<SportsResultMascot
view={resultView}
animationId={resultCelebrationId}
/>
) : null}
{widgetState === WIDGET_STATES.INTRO && (
<video
ref={introVideoRef}
className="sports-intro-video"
muted={true}
playsInline={true}
preload="auto"
aria-hidden="true"
tabIndex={-1}
/>
)}
<div className="sports-title-wrapper">
{/* The empty self-closing div here is used to help center the title, since the context menu also takes up space. */}
{widgetState === WIDGET_STATES.INTRO && <div />}
{widgetState === WIDGET_STATES.FOLLOW_TEAMS && (
<span
className="sports-follow-teams-title"
data-l10n-id="newtab-sports-widget-follow-teams-title"
// If changing this number, also update isMaxSelected in SportsWidgetFollowTeams.
data-l10n-args={JSON.stringify({ number: 3 })}
/>
)}
{widgetState === WIDGET_STATES.MATCHES && (
<moz-button
className="sports-back-button"
type="icon ghost"
iconsrc="chrome://global/skin/icons/arrow-left.svg"
data-l10n-id="newtab-sports-widget-back-button"
onClick={handleViewIntro}
style={{ visibility: tournamentStarted ? "hidden" : "visible" }}
aria-hidden={tournamentStarted}
/>
)}
{widgetState === WIDGET_STATES.MATCHES && !resultBody && (
<div className="sports-matches-tabs" role="tablist">
{getVisibleMatchesTabs(hasLiveGames, hasPreviousResults).map(
({ id, disabled }) => (
<button
key={id}
id={`sports-${id}-tab`}
role="tab"
aria-selected={activeTab === id}
disabled={disabled}
className={`sports-matches-tab${activeTab === id ? " is-active" : ""}${disabled ? " is-disabled" : ""}`}
onClick={() => handleMatchesTabChange(id)}
data-l10n-id={`newtab-sports-widget-${id}`}
/>
)
)}
</div>
)}
{widgetState === WIDGET_STATES.KEY_DATES && (
<>
<moz-button
className="sports-back-button"
type="icon ghost"
iconsrc="chrome://global/skin/icons/arrow-left.svg"
data-l10n-id="newtab-sports-widget-back-button"
onClick={handleViewIntro}
/>
<h3 data-l10n-id="newtab-sports-widget-key-dates"></h3>
</>
)}
{widgetState === WIDGET_STATES.INTRO && (
<div className="sports-intro-wrapper">
<h2
className="sports-intro-title"
data-l10n-id="newtab-sports-widget-keep-tabs"
/>
{displaySize === "large" && (
<p
className="sports-intro-lede"
data-l10n-id="newtab-sports-widget-get-updates"
></p>
)}
</div>
)}
{widgetState === WIDGET_STATES.FOLLOW_TEAMS ? (
<button
className="sports-cancel-button"
data-l10n-id="newtab-sports-widget-cancel"
onClick={handleCancelSelection}
/>
) : (
<div className="sports-context-menu-wrapper">
<moz-button
className="sports-context-menu-button"
iconSrc="chrome://global/skin/icons/more.svg"
menuId="sports-context-menu"
type="ghost"
/>
<panel-list
className="panel-list-no-icons"
id="sports-context-menu"
>
<panel-item
data-l10n-id="newtab-sports-widget-menu-follow-teams"
onClick={() => handleFollowTeams("context_menu")}
/>
<panel-item
data-l10n-id="newtab-sports-widget-menu-view-schedule"
onClick={() => handleViewKeyDates("context_menu")}
/>
<panel-item
data-l10n-id="newtab-sports-widget-menu-view-upcoming"
onClick={handleViewUpcoming}
/>
<panel-item
data-l10n-id="newtab-sports-widget-menu-view-results"
onClick={handleViewResults}
disabled={!hasPreviousResults}
/>
<WidgetMenuFooter
dispatch={dispatch}
widgetId="sportsWidget"
widgetEnabledMap={widgetEnabledMap}
widgetName="sports"
enabledPref="widgets.sportsWidget.enabled"
widgetSize={widgetSize}
learnMoreL10nId="newtab-sports-widget-menu-learn-more"
onLearnMore={handleLearnMore}
sizeSubmenu={
widgetsMayBeMaximized ? (
<SizeSubmenu
submenuId="sports-size-submenu"
sizes={["medium", "large"]}
checkedSize={widgetSize}
onChangeSize={handleChangeSize}
/>
) : null
}
/>
</panel-list>
</div>
)}
</div>
<div className="sports-body">
{widgetState === WIDGET_STATES.FOLLOW_TEAMS && (
<SportsWidgetFollowTeams
teams={teams}
initialSelectedTeams={selectedTeams}
onSave={handleSaveSelection}
localizedNames={localizedNames}
/>
)}
{widgetState === WIDGET_STATES.MATCHES && resultBody}
{widgetState === WIDGET_STATES.MATCHES && !resultBody && (
<SportsMatchesView
dispatch={dispatch}
matchesTab={activeTab}
hasLiveGames={hasLiveGames}
hasLivePagination={
activeTab === MATCHES_TABS.NOW && (rawLive?.length ?? 0) >= 2
}
size={displaySize}
widgetSize={widgetSize}
previous={sortedPrevious}
current={sortedCurrent}
next={sortedNext}
liveIndex={liveIndex}
lastLiveUpdated={sportsWidgetData.lastLiveUpdated}
handleInteraction={handleInteraction}
selectedTeamsSet={selectedTeamsSet}
tbdTeamName={tbdTeamName}
localizedNames={localizedNames}
followedOnly={sportsWidgetData.followedOnly}
showResultsList={showResultsList}
setShowResultsList={setShowResultsList}
showUpcomingList={showUpcomingList}
setShowUpcomingList={setShowUpcomingList}
loadMore={sportsWidgetData.loadMore}
canWatchLive={canWatchLive}
onWatchClick={() => setWatchLiveOpen(true)}
/>
)}
{widgetState === WIDGET_STATES.KEY_DATES && (
<SportsWidgetKeyDates handleViewMatches={handleViewMatches} />
)}
{widgetState === WIDGET_STATES.INTRO && (
<>
<div className="sports-buttons-wrapper">
<moz-button
type="primary"
size={widgetSize === "medium" ? "small" : undefined}
data-l10n-id="newtab-sports-widget-view-matches"
className="sports-view-matches"
onClick={() => handleViewMatches("widget")}
/>
<moz-button
type="secondary"
size={widgetSize === "medium" ? "small" : undefined}
data-l10n-id="newtab-sports-widget-follow-teams"
className="sports-follow-teams-btn"
onClick={() => handleFollowTeams("widget")}
/>
</div>
{liveEnabled && sportsWidgetData?.initialized && (
<div className="sports-live-scores">
{/* Live scores content */}
</div>
)}
</>
)}
</div>
{watchLiveOpen && (
<WatchLiveModal
onClose={() => setWatchLiveOpen(false)}
dispatch={dispatch}
widgetSize={widgetSize}
/>
)}
</article>
);
}
function SportsWidgetFollowTeams({
teams,
initialSelectedTeams,
onSave,
localizedNames,
}) {
const [selectedTeams, setSelectedTeams] = useState(initialSelectedTeams);
const [searchQuery, setSearchQuery] = useState("");
// Eliminated teams stay in the list (shown disabled with an "(eliminated)"
// badge) but don't count toward the 3-team cap and aren't persisted on save
// — otherwise the user could be stuck following a team they can no longer
// toggle off, or blocked from picking a replacement.
const eliminatedKeys = new Set(
teams.filter(team => team.eliminated).map(team => team.key)
);
const activeSelectedTeams = selectedTeams.filter(
key => !eliminatedKeys.has(key)
);
const isMaxSelected = activeSelectedTeams.length >= 3;
function handleTeamToggle(teamKey, isChecked) {
setSelectedTeams(prev =>
isChecked ? [...prev, teamKey] : prev.filter(key => key !== teamKey)
);
}
const sortedTeams = localizedNames
? [...teams].sort((a, b) =>
localizedNames[a.key].localeCompare(localizedNames[b.key])
)
: [];
const filteredTeams = searchQuery
? sortedTeams.filter(team =>
localizedNames[team.key]
.toLocaleLowerCase()
.includes(searchQuery.toLocaleLowerCase())
)
: sortedTeams;
return (
<div className="sports-follow-teams">
<moz-input-search
data-l10n-id="newtab-sports-widget-search-country"
className="sports-country-search"
onInput={e => setSearchQuery(e.target.value)}
/>
<div className="sports-follow-teams-list">
{/* Wait until names are localized so users in other locales don't see a flicker of content in English. */}
{localizedNames &&
filteredTeams.map(team => {
const isSelected = selectedTeams.includes(team.key);
const isEliminated = eliminatedKeys.has(team.key);
const isRowDisabled =
isEliminated || (!isSelected && isMaxSelected);
const localizedName = localizedNames[team.key];
return (
<div
key={team.key}
className={`sports-follow-teams-row${isRowDisabled ? " is-disabled" : ""}`}
onClick={e => {
// The checkbox already handles its own toggle; skip here so we don't toggle twice.
if (e.target.localName === "moz-checkbox") {
return;
}
if (isRowDisabled) {
return;
}
handleTeamToggle(team.key, !isSelected);
}}
>
<moz-checkbox
checked={isSelected || undefined}
disabled={isRowDisabled ? true : undefined}
onChange={e => handleTeamToggle(team.key, e.target.checked)}
aria-label={localizedName}
/>
<img
className="sports-team-flag"
src={team.icon_url}
alt=""
title={localizedName}
/>
{isEliminated ? (
<span
className="sports-team-name"
data-l10n-id="newtab-sports-widget-team-name-eliminated"
data-l10n-args={JSON.stringify({
teamName: localizedName,
})}
/>
) : (
<span className="sports-team-name">{localizedName}</span>
)}
</div>
);
})}
</div>
<moz-button
className="sports-done-button"
data-l10n-id="newtab-sports-widget-done-button"
type="primary"
size="small"
onClick={() => onSave(activeSelectedTeams)}
/>
</div>
);
}
// Controlled: `isCoolingDown`, `isSpinning` and `onClick` are owned by
// SportsMatchesView so both the disabled state and the spin persist across the
// medium and large widget size changes.
function LiveRefreshButton({ isCoolingDown, isSpinning, onClick }) {
return (
<moz-button
className={`sports-live-refresh-button${
isSpinning ? " is-spinning" : ""
}`}
type="icon ghost"
size="small"
iconSrc="chrome://browser/skin/sync.svg"
data-l10n-id="newtab-custom-widget-live-refresh"
disabled={isCoolingDown || undefined}
onClick={onClick}
/>
);
}
function SportsSectionLabel({ match, withLiveBadge = false }) {
const l10nId = getMatchSectionL10nId(match);
const stageContent = l10nId ? (
<span data-l10n-id={l10nId} />
) : (
<span>{match.stage}</span>
);
if (!withLiveBadge) {
return <span className="sports-section-label">{stageContent}</span>;
}
return (
<span className="sports-section-label">
{stageContent}{" "}
<span className="sports-section-label-live">
<span aria-hidden="true">{"• "}</span>
<span data-l10n-id="newtab-sports-widget-live" />
</span>
</span>
);
}
// Mounts an IntersectionObserver on a bottom-of-list sentinel element.
// When the sentinel scrolls into the scrollable `.sports-body` ancestor
// (or within 200px of doing so), the hook dispatches
// WIDGETS_SPORTS_FETCH_MORE_MATCHES with the given `direction`. The
// observer is torn down when the list collapses, when load-more is
// exhausted, or when the sentinel element unmounts.
//
// - `active`: whether the list this sentinel belongs to is expanded
// - `loading`: current in-flight flag for this direction
// - `exhausted`: end-of-data flag for this direction
function useLoadMoreSentinel({
direction,
sentinelRef,
active,
loading,
exhausted,
dispatch,
}) {
useEffect(() => {
if (!active || exhausted || !sentinelRef.current) {
return undefined;
}
const sentinel = sentinelRef.current;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting && !loading && !exhausted) {
dispatch(
ac.OnlyToMain({
type: at.WIDGETS_SPORTS_FETCH_MORE_MATCHES,
data: { direction },
})
);
}
},
{
root: sentinel.closest(".sports-body"),
// Fire the fetch when the sentinel is within 200px of being
// visible, not only once it's actually on screen.
rootMargin: "200px 0px",
}
);
observer.observe(sentinel);
return () => observer.disconnect();
}, [dispatch, direction, sentinelRef, active, loading, exhausted]);
}
function SportsMatchesView({
dispatch,
matchesTab,
hasLiveGames,
hasLivePagination,
// `size` is the *effective* display size — it may be forced to "large"
// when the user has expanded the match list view, even if the user's
// chosen pref is "medium". Use it for layout decisions inside the view.
size,
// `widgetSize` is the user's chosen size pref, used for telemetry only so
// events keep reporting the user's actual chosen size regardless of any
// temporary list-view expansion.
widgetSize,
previous,
current,
next,
liveIndex,
lastLiveUpdated,
handleInteraction,
selectedTeamsSet,
tbdTeamName,
localizedNames,
followedOnly,
showResultsList,
setShowResultsList,
showUpcomingList,
setShowUpcomingList,
loadMore,
canWatchLive,
onWatchClick,
}) {
const resultsPanelRef = useRef(null);
const upcomingPanelRef = useRef(null);
// Refs to 1px-tall invisible divs rendered at the end of the upcoming /
// results lists. IntersectionObserver can only watch real DOM elements,
// so we need a concrete element at the bottom of each list to detect
// "user scrolled to the end". When a sentinel scrolls into view, the
// observer below dispatches WIDGETS_SPORTS_FETCH_MORE_MATCHES for the
// corresponding direction.
const upcomingSentinelRef = useRef(null);
const resultsSentinelRef = useRef(null);
const upcomingLoadMoreLoading = !!loadMore?.upcoming?.loading;
const upcomingLoadMoreExhausted = !!loadMore?.upcoming?.exhausted;
const resultsLoadMoreLoading = !!loadMore?.results?.loading;
const resultsLoadMoreExhausted = !!loadMore?.results?.exhausted;
const hasFollowedTeams = selectedTeamsSet.size > 0;
// Read the persisted per-tab toggle state from redux. Defaults to true so
// users with followed teams see the filtered list right away.
const resultsFollowedOnly = followedOnly?.results ?? true;
const upcomingFollowedOnly = followedOnly?.upcoming ?? true;
const setFollowedOnly = (tab, value) =>
batch(() => {
dispatch(
ac.OnlyToMain({
type: at.WIDGETS_USER_EVENT,
data: {
widget_name: "sports",
// `widget_source` carries the originating tab (results/upcoming)
// since the toggle is rendered per-tab. `action_value` carries
// the new pressed state.
widget_source: tab,
user_action: USER_ACTION_TYPES.TOGGLE_FOLLOWED_ONLY,
action_value: value,
widget_size: widgetSize,
},
})
);
dispatch(
ac.AlsoToMain({
type: at.WIDGETS_SPORTS_CHANGE_FOLLOWED_ONLY,
data: { [tab]: value },
})
);
});
const filterFollowed = matches =>
matches.filter(
match =>
selectedTeamsSet.has(match.home_team?.key) ||
selectedTeamsSet.has(match.away_team?.key)
);
// Filtering is only meaningful when the user has followed at least one
// team — otherwise we'd hide every match.
const displayedPrevious =
hasFollowedTeams && resultsFollowedOnly
? filterFollowed(previous)
: previous;
const displayedNext =
hasFollowedTeams && upcomingFollowedOnly ? filterFollowed(next) : next;
// When the user expands a tab into list mode, move keyboard focus to the
// first match row in the just-revealed list. Without this, focus stays on
// the "View all" button, which sits at the bottom of the widget — pressing
// Tab from there moves focus *out* of the widget instead of into the new
// content, creating a keyboard trap for screen reader / keyboard users.
// We don't move focus when collapsing back to highlight view: focus
// naturally remains on the "Show less" button the user just activated,
// which is the expected behavior.
useEffect(() => {
if (showResultsList) {
resultsPanelRef.current?.querySelector(".sports-match-row")?.focus();
}
}, [showResultsList]);
useEffect(() => {
if (showUpcomingList) {
upcomingPanelRef.current?.querySelector(".sports-match-row")?.focus();
}
}, [showUpcomingList]);
// Hook up the IntersectionObserver-driven load-more for each list. The
// hook dispatches WIDGETS_SPORTS_FETCH_MORE_MATCHES with the matching
// direction when its sentinel scrolls into view; it only runs while the
// list is expanded and not yet exhausted.
useLoadMoreSentinel({
direction: "upcoming",
sentinelRef: upcomingSentinelRef,
active: showUpcomingList,
loading: upcomingLoadMoreLoading,
exhausted: upcomingLoadMoreExhausted,
dispatch,
});
useLoadMoreSentinel({
direction: "results",
sentinelRef: resultsSentinelRef,
active: showResultsList,
loading: resultsLoadMoreLoading,
exhausted: resultsLoadMoreExhausted,
dispatch,
});
// Tracks whether the live-refresh button is in its post-click cooldown
// window.
// Flipped to true when clicked. While true, the button is disabled.
// Flips back to false when LIVE_REFRESH_COOLDOWN_MS finishes, and gets re-enabled again.
const [liveRefreshCoolingDown, setLiveRefreshCoolingDown] = useState(false);
// Spins the refresh icon while a manual fetch is in flight. Set on click,
// cleared when fresh /live data lands (`lastLiveUpdated` changes) — but never
// before LIVE_REFRESH_MIN_SPIN_MS — or when the cooldown ends as a safety cap
// (e.g. the feed dropped the click as too-soon).
const [liveRefreshSpinning, setLiveRefreshSpinning] = useState(false);
const liveRefreshTimerRef = useRef(null);
// Click timestamp, non-null only while a manual refresh's spin is in flight.
// Doubles as the guard that makes the stop-on-update effect ignore its mount
// run and any automatic-poll updates that happen while no refresh is pending.
const liveRefreshSpinStartRef = useRef(null);
const liveRefreshStopTimerRef = useRef(null);
const stopLiveRefreshSpin = useCallback(() => {
if (liveRefreshStopTimerRef.current) {
clearTimeout(liveRefreshStopTimerRef.current);
liveRefreshStopTimerRef.current = null;
}
liveRefreshSpinStartRef.current = null;
setLiveRefreshSpinning(false);
}, []);
useEffect(
() => () => {
if (liveRefreshTimerRef.current) {
clearTimeout(liveRefreshTimerRef.current);
}
if (liveRefreshStopTimerRef.current) {
clearTimeout(liveRefreshStopTimerRef.current);
}
},
[]
);
// Stop the spin once a new /live response arrives, but hold it for at least
// LIVE_REFRESH_MIN_SPIN_MS so a fast response still reads as an action. The
// start-ref guard skips the mount run and idle auto-poll updates.
useEffect(() => {
// Ignore the mount run / idle auto-poll updates, and don't reschedule once
// a floor-stop is already pending (the floor is anchored to the click).
if (
liveRefreshSpinStartRef.current === null ||
liveRefreshStopTimerRef.current
) {
return;
}
const remaining =
LIVE_REFRESH_MIN_SPIN_MS - (Date.now() - liveRefreshSpinStartRef.current);
if (remaining <= 0) {
stopLiveRefreshSpin();
} else {
liveRefreshStopTimerRef.current = setTimeout(
stopLiveRefreshSpin,
remaining
);
}
}, [lastLiveUpdated, stopLiveRefreshSpin]);
const handleLiveRefreshClick = useCallback(() => {
if (liveRefreshCoolingDown) {
return;
}
setLiveRefreshCoolingDown(true);
setLiveRefreshSpinning(true);
liveRefreshSpinStartRef.current = Date.now();
liveRefreshTimerRef.current = setTimeout(() => {
liveRefreshTimerRef.current = null;
setLiveRefreshCoolingDown(false);
stopLiveRefreshSpin();
}, LIVE_REFRESH_COOLDOWN_MS);
batch(() => {
dispatch(
ac.OnlyToMain({
type: at.WIDGETS_USER_EVENT,
data: {
widget_name: "sports",
widget_source: "now",
user_action: USER_ACTION_TYPES.REFRESH_LIVE,
widget_size: widgetSize,
},
})
);
dispatch(ac.OnlyToMain({ type: at.WIDGETS_SPORTS_LIVE_REFRESH }));
});
handleInteraction?.();
}, [
dispatch,
handleInteraction,
liveRefreshCoolingDown,
stopLiveRefreshSpin,
widgetSize,
]);
return (
<div className="sports-matches-view">
<div
className="sports-matches-tab-panel"
hidden={matchesTab !== MATCHES_TABS.RESULTS}
ref={resultsPanelRef}
>
{showResultsList ? (
<>
{hasFollowedTeams && (
<moz-toggle
className="sports-followed-only-toggle"
pressed={resultsFollowedOnly || null}
data-l10n-id="newtab-sports-widget-followed-only-toggle"
ontoggle={e => setFollowedOnly("results", !!e.target.pressed)}
></moz-toggle>
)}
<div className="sports-matches-list">
{groupMatchesBySection(displayedPrevious).map((section, idx) => (
<div
key={`${section.key}-${idx}`}
className="sports-matches-list-section"
>
<SportsSectionLabel match={section.matches[0]} />
<ul>
{section.matches.map(match => (
<li
key={`${match.home_team?.key}-${match.away_team?.key}-${match.date}`}
>
<SportsMatchRow
match={match}
variant="results"
size="list"
handleInteraction={handleInteraction}
followedTeams={selectedTeamsSet}
tbdTeamName={tbdTeamName}
localizedNames={localizedNames}
/>
</li>
))}
</ul>
</div>
))}
{resultsLoadMoreLoading && (
<div
className="sports-results-loading-more"
role="status"
aria-live="polite"
data-l10n-id="newtab-sports-widget-loading-more"
/>
)}
{!resultsLoadMoreExhausted && (
<div
ref={resultsSentinelRef}
className="sports-results-load-more-sentinel"
aria-hidden="true"
/>
)}
</div>
</>
) : (
previous[0] && (
<>
{size === "large" && <SportsSectionLabel match={previous[0]} />}
<div className="match-highlight-view">
<SportsMatchRow
match={previous[0]}
variant="results"
size={size}
handleInteraction={handleInteraction}
followedTeams={selectedTeamsSet}
tbdTeamName={tbdTeamName}
localizedNames={localizedNames}
/>
</div>
</>
)
)}
{!!previous.length && (
<moz-button
className="sports-view-all"
type="secondary"
size={size === "medium" ? "small" : undefined}
data-l10n-id={
showResultsList
? "newtab-sports-widget-show-less"
: "newtab-sports-widget-view-all"
}
onClick={() => setShowResultsList(v => !v)}
></moz-button>
)}
</div>
{hasLiveGames && (
<div
className="sports-matches-tab-panel"
hidden={matchesTab !== MATCHES_TABS.NOW}
>
{current[liveIndex] && (
<>
{size === "large" && (
<div className="sports-now-header">
<SportsSectionLabel
match={current[liveIndex]}
withLiveBadge={true}
/>
<LiveRefreshButton
isCoolingDown={liveRefreshCoolingDown}
isSpinning={liveRefreshSpinning}
onClick={handleLiveRefreshClick}
/>
</div>
)}
<div
className="match-highlight-view"
{...(hasLivePagination && {
"aria-live": "polite",
"aria-atomic": "false",
})}
>
<SportsMatchRow
match={current[liveIndex]}
variant="now"
size={size}
handleInteraction={handleInteraction}
followedTeams={selectedTeamsSet}
tbdTeamName={tbdTeamName}
localizedNames={localizedNames}
/>
</div>
{/* TODO: Replace play icon when finalized */}
{canWatchLive && (
<moz-button
className="sports-watch-live-button"
type={size === "medium" ? "icon" : "default"}
size={size === "medium" ? "small" : undefined}
iconSrc="chrome://browser/skin/device-tv.svg"
data-l10n-id={
size === "medium"
? "newtab-sports-widget-watch-icon"
: "newtab-sports-widget-watch"
}
onClick={onWatchClick}
></moz-button>
)}
{size === "medium" && (
<LiveRefreshButton
isCoolingDown={liveRefreshCoolingDown}
isSpinning={liveRefreshSpinning}
onClick={handleLiveRefreshClick}
/>
)}
{current.length >= 2 && (
<LivePagination
dispatch={dispatch}
liveIndex={liveIndex}
liveCount={current.length}
size={size}
widgetSize={widgetSize}
handleInteraction={handleInteraction}
/>
)}
</>
)}
</div>
)}
<div
className="sports-matches-tab-panel"
hidden={matchesTab !== MATCHES_TABS.UPCOMING}
ref={upcomingPanelRef}
>
{showUpcomingList ? (
<>
{hasFollowedTeams && (
<moz-toggle
className="sports-followed-only-toggle"
pressed={upcomingFollowedOnly || null}
data-l10n-id="newtab-sports-widget-followed-only-toggle"
ontoggle={e => setFollowedOnly("upcoming", !!e.target.pressed)}
></moz-toggle>
)}
<div className="sports-matches-list">
{groupMatchesBySection(displayedNext).map((section, idx) => (
<div
key={`${section.key}-${idx}`}
className="sports-matches-list-section"
>
<SportsSectionLabel match={section.matches[0]} />
<ul>
{section.matches.map(match => (
<li
// Fallback is for test fixtures, which omit global_event_id.
key={
match.global_event_id ??
`${match.home_team?.key}-${match.away_team?.key}-${match.date}`
}
>
<SportsMatchRow
match={match}
variant="upcoming"
size="list"
handleInteraction={handleInteraction}
followedTeams={selectedTeamsSet}
tbdTeamName={tbdTeamName}
localizedNames={localizedNames}
/>
</li>
))}
</ul>
</div>
))}
{upcomingLoadMoreLoading && (
<div
className="sports-upcoming-loading-more"
role="status"
aria-live="polite"
data-l10n-id="newtab-sports-widget-loading-more"
/>
)}
{!upcomingLoadMoreExhausted && (
<div
ref={upcomingSentinelRef}
className="sports-upcoming-load-more-sentinel"
aria-hidden="true"
/>
)}
</div>
</>
) : (
<>
{next[0] && (
<>
{size === "large" && <SportsSectionLabel match={next[0]} />}
<div className="match-highlight-view">
<SportsMatchRow
match={next[0]}
variant="upcoming"
size={size}
handleInteraction={handleInteraction}
followedTeams={selectedTeamsSet}
tbdTeamName={tbdTeamName}
localizedNames={localizedNames}
/>
</div>
</>
)}
{/* No upcoming matches from the backend — show the placeholder. */}
{!next[0] && (
<div className="match-highlight-view">
<UpcomingMatchPlaceholder size={size} />
</div>
)}
</>
)}
{!!next.length && (
<moz-button
className="sports-view-all"
type="secondary"
size={size === "medium" ? "small" : undefined}
data-l10n-id={
showUpcomingList
? "newtab-sports-widget-show-less"
: "newtab-sports-widget-view-all"
}
onClick={() => setShowUpcomingList(v => !v)}
></moz-button>
)}
</div>
</div>
);
}
// Full ISO timestamps with the host (ET) offset so DATETIME projects each
// kickoff onto the viewer's local calendar day. Bounds are the first and
// last match kickoffs of each stage, sourced from FIFA's 2026 fixtures.
const keyDatesList = [
{
stageL10nId: "newtab-sports-widget-group-stage",
start: "2026-06-11T15:00:00-04:00",
end: "2026-06-27T22:00:00-04:00",
},
{
stageL10nId: "newtab-sports-widget-round-32",
start: "2026-06-28T15:00:00-04:00",
end: "2026-07-03T21:30:00-04:00",
},
{
stageL10nId: "newtab-sports-widget-round-16",
start: "2026-07-04T13:00:00-04:00",
end: "2026-07-07T16:00:00-04:00",
},
{
stageL10nId: "newtab-sports-widget-quarter-finals",
start: "2026-07-09T16:00:00-04:00",
end: "2026-07-11T21:00:00-04:00",
},
{
stageL10nId: "newtab-sports-widget-semi-finals",
start: "2026-07-14T15:00:00-04:00",
end: "2026-07-15T15:00:00-04:00",
},
{
stageL10nId: "newtab-sports-widget-bronze-finals",
date: "2026-07-18T17:00:00-04:00",
},
{
stageL10nId: "newtab-sports-widget-final",
date: "2026-07-19T15:00:00-04:00",
},
];
function SportsWidgetKeyDates({ handleViewMatches }) {
return (
<div className="sports-key-dates">
<ul className="sports-key-dates-list">
{keyDatesList.map(({ stageL10nId, start, end, date }) => (
<li key={stageL10nId} className="sports-key-dates-item">
<span data-l10n-id={stageL10nId} />
<span
data-l10n-id={
date
? "newtab-sports-widget-key-date"
: "newtab-sports-widget-key-date-range"
}
data-l10n-args={JSON.stringify(
date
? { date: new Date(date).getTime() }
: {
start: new Date(start).getTime(),
end: new Date(end).getTime(),
}
)}
/>
</li>
))}
</ul>
<moz-button
type="secondary"
size="small"
data-l10n-id="newtab-sports-widget-view-matches"
onClick={() => handleViewMatches("key_dates_state")}
/>
</div>
);
}
export { SportsWidget };