/* 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/. */ import React, { useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { batch, useSelector } from "react-redux"; import { actionCreators as ac, actionTypes as at } from "common/Actions.mjs"; import { PREF_CLOCKS_SIZE, PREF_WIDGETS_CLOCKS_ENABLED, } from "common/WidgetsRegistry.mjs"; import { useIntersectionObserver, useSizeSubmenu } from "../../../lib/utils"; import { AddClockForm } from "./AddClockForm"; import { ClocksRow } from "./ClocksRow"; import { EditClocksPanel } from "./EditClocksPanel"; import { MoveSubmenu } from "../MoveSubmenu"; import { backfillClockLabelColors, buildNextClockZones, MAX_CLOCK_COUNT, buildDefaultZones, getSupportedTimeZones, parseClockZonesPref, removeClockZoneAtIndex, shouldUse12HourTimeFormat, } from "./ClocksHelpers"; const USER_ACTION_TYPES = { ADD_CLOCK: "add_clock", ADD_NICKNAME: "add_nickname", CHANGE_HOUR_FORMAT: "change_hour_format", CHANGE_SIZE: "change_size", COLLAPSE: "collapse", EDIT_CLOCK: "edit_clock", EXPAND: "expand", LEARN_MORE: "learn_more", REMOVE_CLOCK: "remove_clock", }; const PREF_CLOCKS_HOUR_FORMAT = "widgets.clocks.hourFormat"; const PREF_CLOCKS_ZONES = "widgets.clocks.zones"; const CLOCKS_PANEL = { FORM: "form", EDIT: "edit", }; const CLOCK_WIDGET_SOURCE = { CONTEXT_MENU: "context_menu", MANAGE: "manage", ROW: "row", TOOLBAR: "toolbar", }; function getClockWidgetDisplayState({ activePanel, hourFormatPref, size }) { const currentSize = size || "medium"; const locale = typeof navigator !== "undefined" ? navigator.language : undefined; return { currentSize, locale, panelDisplaySize: activePanel ? "large" : currentSize, use12HourFormat: shouldUse12HourTimeFormat({ prefValue: hourFormatPref, locale, }), }; } /** * Nova-only World Clocks widget. Up to four clocks with a minute-aligned * tick, hover toolbar, and context menu. * * @param {object} props * @param {Function} props.dispatch * @param {"small"|"medium"|"large"} [props.size] Defaults to "medium". */ function Clocks({ dispatch, size, widgetEnabledMap }) { const clocksZonesPref = useSelector( state => state.Prefs.values[PREF_CLOCKS_ZONES] ); const hourFormatPref = useSelector( state => state.Prefs.values[PREF_CLOCKS_HOUR_FORMAT] ); const [now, setNow] = useState(null); const impressionFired = useRef(false); const contextMenuRef = useRef(null); const contextMenuButtonRef = useRef(null); // Suppress hover-reveal after a menu action; cleared on mouseleave. const [isDismissed, setIsDismissed] = useState(false); const [activePanel, setActivePanel] = useState(null); const [formSource, setFormSource] = useState(CLOCK_WIDGET_SOURCE.TOOLBAR); const [panelOpenSource, setPanelOpenSource] = useState(null); const [editingClockIndex, setEditingClockIndex] = useState(null); const addButtonRef = useRef(null); // Blur the trigger after hide() returns focus there; otherwise // :focus-within keeps the overlay open. const closeContextMenu = useCallback(() => { contextMenuRef.current?.hide?.(); setIsDismissed(true); // Defer a frame so we don't race hide()'s synchronous focus return. requestAnimationFrame(() => { if (document.activeElement instanceof HTMLElement) { document.activeElement.blur(); } }); }, []); const { currentSize, locale, panelDisplaySize, use12HourFormat } = getClockWidgetDisplayState({ activePanel, hourFormatPref, size, }); const currentSizeRef = useRef(currentSize); useEffect(() => { currentSizeRef.current = currentSize; }, [currentSize]); // Each tick realigns to the next minute, so paused tabs or device sleep // can't compound drift. `now` starts null so the first render stays // stable for prerender/hydration. useEffect(() => { let timeoutId; const tick = () => { setNow(new Date()); timeoutId = setTimeout(tick, 60_000 - (Date.now() % 60_000)); }; tick(); return () => clearTimeout(timeoutId); }, []); const handleIntersection = useCallback(() => { if (impressionFired.current) { return; } impressionFired.current = true; dispatch( ac.AlsoToMain({ type: at.WIDGETS_IMPRESSION, data: { widget_name: "clocks", widget_size: currentSizeRef.current, }, }) ); }, [dispatch]); const clocksRef = useIntersectionObserver(handleIntersection); const handleChangeSize = useCallback( newSize => { batch(() => { dispatch( ac.OnlyToMain({ type: at.SET_PREF, data: { name: PREF_CLOCKS_SIZE, value: newSize }, }) ); dispatch( ac.OnlyToMain({ type: at.WIDGETS_USER_EVENT, data: { widget_name: "clocks", widget_source: CLOCK_WIDGET_SOURCE.CONTEXT_MENU, user_action: USER_ACTION_TYPES.CHANGE_SIZE, action_value: newSize, widget_size: newSize, }, }) ); }); closeContextMenu(); }, [dispatch, closeContextMenu] ); const sizeSubmenuRef = useSizeSubmenu(handleChangeSize); const handleToggleHourFormat = useCallback(() => { const nextFormat = use12HourFormat ? "24" : "12"; batch(() => { dispatch( ac.OnlyToMain({ type: at.SET_PREF, data: { name: PREF_CLOCKS_HOUR_FORMAT, value: nextFormat }, }) ); dispatch( ac.OnlyToMain({ type: at.WIDGETS_USER_EVENT, data: { widget_name: "clocks", widget_source: CLOCK_WIDGET_SOURCE.CONTEXT_MENU, user_action: USER_ACTION_TYPES.CHANGE_HOUR_FORMAT, action_value: nextFormat, widget_size: currentSize, }, }) ); }); closeContextMenu(); }, [use12HourFormat, dispatch, currentSize, closeContextMenu]); const handleHide = useCallback(() => { batch(() => { dispatch( ac.OnlyToMain({ type: at.SET_PREF, data: { name: PREF_WIDGETS_CLOCKS_ENABLED, value: false }, }) ); dispatch( ac.OnlyToMain({ type: at.WIDGETS_ENABLED, data: { widget_name: "clocks", widget_source: CLOCK_WIDGET_SOURCE.CONTEXT_MENU, enabled: false, widget_size: currentSize, }, }) ); }); closeContextMenu(); }, [dispatch, currentSize, closeContextMenu]); const handleLearnMore = useCallback(() => { batch(() => { dispatch( ac.OnlyToMain({ type: at.OPEN_LINK, data: { url: "https://support.mozilla.org/kb/firefox-new-tab-widgets", }, }) ); dispatch( ac.OnlyToMain({ type: at.WIDGETS_USER_EVENT, data: { widget_name: "clocks", widget_source: CLOCK_WIDGET_SOURCE.CONTEXT_MENU, user_action: USER_ACTION_TYPES.LEARN_MORE, widget_size: currentSize, }, }) ); }); closeContextMenu(); }, [dispatch, currentSize, closeContextMenu]); const clockZones = useMemo( () => parseClockZonesPref(clocksZonesPref) || buildDefaultZones(), [clocksZonesPref] ); useEffect(() => { if (!clockZones.some(clock => clock.label && !clock.labelColor)) { return; } dispatch( ac.OnlyToMain({ type: at.SET_PREF, data: { name: PREF_CLOCKS_ZONES, value: JSON.stringify(backfillClockLabelColors(clockZones)), }, }) ); }, [clockZones, dispatch]); const canAddClock = clockZones.length < MAX_CLOCK_COUNT; const supportedTimeZones = useMemo(() => getSupportedTimeZones(), []); const resetAddClockForm = useCallback(() => { setEditingClockIndex(null); }, []); const handleShowAddClock = useCallback( (source = CLOCK_WIDGET_SOURCE.TOOLBAR) => { setActivePanel(CLOCKS_PANEL.FORM); setFormSource(source); setEditingClockIndex(null); setIsDismissed(false); }, [] ); const handleShowEditClocks = useCallback( source => { setActivePanel(CLOCKS_PANEL.EDIT); setPanelOpenSource(source); setIsDismissed(false); dispatch( ac.OnlyToMain({ type: at.WIDGETS_USER_EVENT, data: { widget_name: "clocks", widget_source: source, user_action: USER_ACTION_TYPES.EXPAND, widget_size: currentSize, }, }) ); }, [currentSize, dispatch] ); const handleCloseDisplayPanel = useCallback(() => { if (activePanel === CLOCKS_PANEL.EDIT) { dispatch( ac.OnlyToMain({ type: at.WIDGETS_USER_EVENT, data: { widget_name: "clocks", widget_source: panelOpenSource, user_action: USER_ACTION_TYPES.COLLAPSE, widget_size: currentSize, }, }) ); } setActivePanel(null); resetAddClockForm(); requestAnimationFrame(() => { (addButtonRef.current ?? contextMenuButtonRef.current)?.focus(); }); }, [activePanel, panelOpenSource, currentSize, dispatch, resetAddClockForm]); const handleCloseClockForm = useCallback(() => { if (formSource === CLOCK_WIDGET_SOURCE.MANAGE) { setActivePanel(CLOCKS_PANEL.EDIT); resetAddClockForm(); return; } handleCloseDisplayPanel(); }, [formSource, handleCloseDisplayPanel, resetAddClockForm]); const handleShowEditClockForm = useCallback( (index, source = CLOCK_WIDGET_SOURCE.ROW) => { setActivePanel(CLOCKS_PANEL.FORM); setFormSource(source); setEditingClockIndex(index); setIsDismissed(false); }, [] ); const handleSaveClock = useCallback( zone => { const existingClock = editingClockIndex !== null ? clockZones[editingClockIndex] : null; batch(() => { dispatch( ac.OnlyToMain({ type: at.SET_PREF, data: { name: PREF_CLOCKS_ZONES, value: JSON.stringify( buildNextClockZones(clockZones, editingClockIndex, zone) ), }, }) ); dispatch( ac.OnlyToMain({ type: at.WIDGETS_USER_EVENT, data: { widget_name: "clocks", widget_source: formSource, user_action: editingClockIndex !== null ? USER_ACTION_TYPES.EDIT_CLOCK : USER_ACTION_TYPES.ADD_CLOCK, widget_size: currentSize, }, }) ); if (zone.label && !existingClock?.label) { dispatch( ac.OnlyToMain({ type: at.WIDGETS_USER_EVENT, data: { widget_name: "clocks", widget_source: formSource, user_action: USER_ACTION_TYPES.ADD_NICKNAME, widget_size: currentSize, }, }) ); } }); if (formSource === CLOCK_WIDGET_SOURCE.MANAGE) { setActivePanel(CLOCKS_PANEL.EDIT); resetAddClockForm(); return; } handleCloseDisplayPanel(); }, [ clockZones, formSource, currentSize, editingClockIndex, handleCloseDisplayPanel, resetAddClockForm, dispatch, ] ); const handleRemoveClock = useCallback( (index, source = CLOCK_WIDGET_SOURCE.ROW) => { if (clockZones.length <= 1) { return; } batch(() => { dispatch( ac.OnlyToMain({ type: at.SET_PREF, data: { name: PREF_CLOCKS_ZONES, value: JSON.stringify(removeClockZoneAtIndex(clockZones, index)), }, }) ); dispatch( ac.OnlyToMain({ type: at.WIDGETS_USER_EVENT, data: { widget_name: "clocks", widget_source: source, user_action: USER_ACTION_TYPES.REMOVE_CLOCK, widget_size: currentSize, }, }) ); }); }, [clockZones, currentSize, dispatch] ); const isClockFormOpen = activePanel === CLOCKS_PANEL.FORM; const isEditingClocks = activePanel === CLOCKS_PANEL.EDIT; const hasAnyLabel = clockZones.some(c => !!c.label); return (
setIsDismissed(false)} ref={el => { // useIntersectionObserver expects ref.current to be an array of targets. clocksRef.current = [el]; }} >
{canAddClock && ( handleShowAddClock()} ref={addButtonRef} /> )} {["small", "medium", "large"].map(s => ( ))} { handleShowEditClocks(CLOCK_WIDGET_SOURCE.CONTEXT_MENU); closeContextMenu(); }} />
{isClockFormOpen && ( )} {isEditingClocks && ( handleShowAddClock(CLOCK_WIDGET_SOURCE.MANAGE)} onEditClock={index => handleShowEditClockForm(index, CLOCK_WIDGET_SOURCE.MANAGE) } onRemoveClock={index => handleRemoveClock(index, CLOCK_WIDGET_SOURCE.MANAGE) } onClose={handleCloseDisplayPanel} /> )}
    {clockZones.map((c, i) => { const showLabel = panelDisplaySize === "large" && !!c.label; // Medium columns too narrow at 3+ clocks; Small always abbreviates. const shouldAbbreviate = panelDisplaySize === "small" || (panelDisplaySize === "medium" && clockZones.length >= 3); const showInlineActions = !activePanel && currentSize !== "small"; const hideTimeOnInlineActions = showInlineActions && clockZones.length > 1; return ( handleShowEditClockForm(i) : null } onRemove={ showInlineActions && clockZones.length > 1 ? () => handleRemoveClock(i) : null } shouldAbbreviate={shouldAbbreviate} showLabel={showLabel} hideTimeOnInlineActions={hideTimeOnInlineActions} showInlineActions={showInlineActions} use12HourFormat={use12HourFormat} /> ); })}
); } export { Clocks };