/* 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
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
// 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, useSizeSubmenu } from "../../../lib/utils";
import { SportsMatchRow } from "./SportsMatchRow";
import { LivePagination } from "./LivePagination";
import { MoveSubmenu } from "../MoveSubmenu";
import { WatchLiveModal } from "./WatchLiveModal";
import { WIDGET_REGISTRY, resolveWidgetSize } from "common/WidgetsRegistry.mjs";
import { useLocalizedTeamNames } from "./useLocalizedTeamNames.jsx";
import {
getMatchSectionL10nId,
groupMatchesBySection,
} from "./stageLabels.mjs";
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",
};
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",
};
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";
// 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);
}
// 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);
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"];
// /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;
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 { matchesTab } = sportsWidgetData;
const hasUserSelectedTab = useRef(false);
const activeTab =
hasLiveGames && !hasUserSelectedTab.current ? MATCHES_TABS.NOW : matchesTab;
// 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]);
// 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.
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: resultsFollowedOnly
? sortFollowedFirst(previous, selectedTeamsSet)
: previous,
sortedCurrent: sortFollowedFirst(rawLive ?? [], selectedTeamsSet),
sortedNext: upcomingFollowedOnly
? sortFollowedFirst(next, selectedTeamsSet)
: next,
};
}, [
rawMatches,
rawLive,
selectedTeamsSet,
resultsFollowedOnly,
upcomingFollowedOnly,
]);
// 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);
// 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);
const playIntroVideo = useMemo(() => {
const prefersReducedMotion =
globalThis.matchMedia?.("(prefers-reduced-motion: reduce)").matches ??
false;
return () => {
if (prefersReducedMotion) {
return;
}
const video = introVideoRef.current;
if (!video || !video.paused) {
return;
}
video.currentTime = 0;
video.play().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);
// 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();
}
function handleSportsWidgetHide() {
batch(() => {
dispatch(
ac.OnlyToMain({
type: at.SET_PREF,
data: { name: "widgets.sportsWidget.enabled", value: false },
})
);
dispatch(
ac.OnlyToMain({
type: at.WIDGETS_ENABLED,
data: {
widget_name: "sports",
widget_source: "context_menu",
enabled: false,
widget_size: widgetSize,
},
})
);
});
}
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]
);
const sizeSubmenuRef = useSizeSubmenu(handleChangeSize);
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() {
batch(() => {
dispatch(
ac.OnlyToMain({
type: at.OPEN_LINK,
data: {
url: "https://support.mozilla.org/kb/firefox-new-tab-widgets",
},
})
);
const telemetryData = {
widget_name: "sports",
widget_source: "context_menu",
user_action: USER_ACTION_TYPES.LEARN_MORE,
widget_size: widgetSize,
};
dispatch(
ac.OnlyToMain({
type: at.WIDGETS_USER_EVENT,
data: telemetryData,
})
);
});
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,
})
);
});
},
[dispatch, widgetSize, activeTab]
);
// @nova-cleanup(remove-gate): Remove this guard and PREF_NOVA_ENABLED after Nova ships
if (!prefs[PREF_NOVA_ENABLED]) {
return null;
}
return (