diff --git a/components/brain/my-stream/MyStreamWaveDesktopTabs.tsx b/components/brain/my-stream/MyStreamWaveDesktopTabs.tsx index ab213fc122..81833ff9ed 100644 --- a/components/brain/my-stream/MyStreamWaveDesktopTabs.tsx +++ b/components/brain/my-stream/MyStreamWaveDesktopTabs.tsx @@ -305,62 +305,63 @@ const MyStreamWaveDesktopTabs: React.FC = ({ return (
-
-
-
- { - if (key.startsWith("curation:")) { - onSelectCuration(key.replace("curation:", "")); - return; - } - - onSelectCuration(null); - setActiveTab(key as MyStreamWaveTab); - }} - /> - {mobileOverflowItems.length > 0 && ( - } - aria-label="More curations" - items={mobileOverflowItems} - menuWidthClassName="tw-w-52" - /> - )} -
-
-
+
- { - if (key.startsWith("curation:")) { - onSelectCuration(key.replace("curation:", "")); - return; - } - - onSelectCuration(null); - setActiveTab(key as MyStreamWaveTab); - }} - /> +
+ { + if (key.startsWith("curation:")) { + onSelectCuration(key.replace("curation:", "")); + return; + } + + onSelectCuration(null); + setActiveTab(key as MyStreamWaveTab); + }} + /> + {mobileOverflowItems.length > 0 && ( + } + aria-label="More curations" + items={mobileOverflowItems} + menuWidthClassName="tw-w-52" + /> + )} +
- {showCreateCurationAction && ( +
+
+ { + if (key.startsWith("curation:")) { + onSelectCuration(key.replace("curation:", "")); + return; + } + + onSelectCuration(null); + setActiveTab(key as MyStreamWaveTab); + }} + /> +
+ {showCreateCurationAction && ( +
- )} +
+ )}
); }; diff --git a/components/brain/my-stream/tabs/MyStreamWaveCreateCurationAction.tsx b/components/brain/my-stream/tabs/MyStreamWaveCreateCurationAction.tsx index c9f98e62c7..e88867e2a2 100644 --- a/components/brain/my-stream/tabs/MyStreamWaveCreateCurationAction.tsx +++ b/components/brain/my-stream/tabs/MyStreamWaveCreateCurationAction.tsx @@ -1,7 +1,6 @@ "use client"; import { PlusIcon } from "@heroicons/react/24/outline"; -import clsx from "clsx"; import type { ApiWave } from "@/generated/models/ApiWave"; import { useWaveCurations } from "@/hooks/waves/useWaveCurations"; import MyStreamActionTooltip from "../MyStreamActionTooltip"; @@ -11,13 +10,11 @@ import { useState } from "react"; interface MyStreamWaveCreateCurationActionProps { readonly wave: ApiWave; readonly onCreated: (curationId: string) => void; - readonly className?: string | undefined; } export default function MyStreamWaveCreateCurationAction({ wave, onCreated, - className, }: MyStreamWaveCreateCurationActionProps) { const { data: curations = [] } = useWaveCurations({ waveId: wave.id, @@ -32,45 +29,41 @@ export default function MyStreamWaveCreateCurationAction({ const createCurationTooltipId = `my-stream-create-curation-${wave.id}`; const showCreateFirstCurationCallout = curations.length === 0; - const createButtonTooltipProps = showCreateFirstCurationCallout - ? {} - : { - "data-tooltip-id": createCurationTooltipId, - "data-tooltip-content": "Create curation", - }; + const handleOpenCreateCuration = () => setIsCreateCurationOpen(true); + const baseButtonClassName = + "tw-inline-flex tw-h-9 tw-w-9 tw-items-center tw-justify-center tw-rounded-xl tw-border tw-border-solid tw-border-iron-700 tw-bg-iron-900 tw-transition desktop-hover:hover:tw-border-iron-500 desktop-hover:hover:tw-bg-iron-800 desktop-hover:hover:tw-text-white"; return ( <> -
- + + ) : ( + + )}
- + {!showCreateFirstCurationCallout && ( + + )} {isCreateCurationOpen && ( = ({ return ; }; - const { - isMemesWave, - isRankWave, - pauses: { filterDecisionsDuringPauses }, - } = useWave(wave); - - const { allDecisions } = useDecisionPoints(wave); - - const filteredDecisions = React.useMemo(() => { - const decisionsAsApiFormat: { decision_time: number }[] = allDecisions.map( - (decision) => ({ decision_time: decision.timestamp }) - ); - const filtered = filterDecisionsDuringPauses(decisionsAsApiFormat); - return allDecisions.filter((decision) => - filtered.some((f) => f.decision_time === decision.timestamp) - ); - }, [allDecisions, filterDecisionsDuringPauses]); - - const nextDecisionTime = - filteredDecisions.find( - (decision) => decision.timestamp > Time.currentMillis() - )?.timestamp ?? null; - - const [timeLeft, setTimeLeft] = useState(EMPTY_TIME_LEFT); - - useEffect(() => { - if (typeof nextDecisionTime !== "number") return; - - const intervalId = setInterval(() => { - const newTimeLeft = calculateTimeLeft(nextDecisionTime); - setTimeLeft(newTimeLeft); - if ( - newTimeLeft.days === 0 && - newTimeLeft.hours === 0 && - newTimeLeft.minutes === 0 && - newTimeLeft.seconds === 0 - ) { - clearInterval(intervalId); - } - }, 1000); - return () => clearInterval(intervalId); - }, [nextDecisionTime]); - - const displayedTimeLeft = - typeof nextDecisionTime === "number" ? timeLeft : EMPTY_TIME_LEFT; - const handleMemesSubmit = () => { setIsMemesModalOpen(true); }; @@ -296,12 +237,6 @@ const MyStreamWaveTabsMeme: React.FC = ({ onCreated={onSelectCuration} /> )} - {(isMemesWave || isRankWave) && - typeof nextDecisionTime === "number" && ( -
- -
- )} = ({ wave, @@ -46,6 +54,7 @@ export const WaveLeaderboardTime: React.FC = ({ const [isDecisionDetailsOpen, setIsDecisionDetailsOpen] = useState(false); + const [timeLeft, setTimeLeft] = useState(EMPTY_TIME_LEFT); const autoExpandFutureAttemptsRef = useRef(0); const [timelineFocus, setTimelineFocus] = useState<"start" | "end" | null>( null @@ -75,6 +84,24 @@ export const WaveLeaderboardTime: React.FC = ({ (decision) => decision.timestamp > Time.currentMillis() )?.timestamp ?? null; + useEffect(() => { + if (typeof nextDecisionTime !== "number") { + setTimeLeft(EMPTY_TIME_LEFT); + return; + } + + const updateTimeLeft = () => { + setTimeLeft(calculateTimeLeft(nextDecisionTime)); + }; + + updateTimeLeft(); + + const intervalId = globalThis.setInterval(updateTimeLeft, 1000); + return () => { + clearInterval(intervalId); + }; + }, [nextDecisionTime]); + useEffect(() => { if (nextDecisionTime !== null) { autoExpandFutureAttemptsRef.current = 0; @@ -117,6 +144,9 @@ export const WaveLeaderboardTime: React.FC = ({ }; }, [nextDecisionTime, hasMoreFuture, loadMoreFuture]); + const displayedTimeLeft = + typeof nextDecisionTime === "number" ? timeLeft : EMPTY_TIME_LEFT; + const handleLoadMorePast = () => { if (hasMorePast) { setTimelineFocus("start"); @@ -154,6 +184,7 @@ export const WaveLeaderboardTime: React.FC = ({ isOpen={isDecisionDetailsOpen} setIsOpen={handleDecisionDetailsOpenChange} nextDecisionTime={nextDecisionTime} + timeLeft={displayedTimeLeft} isPaused={Boolean(currentPause)} currentPause={currentPause} /> diff --git a/components/waves/leaderboard/time/CompactTimeCountdown.tsx b/components/waves/leaderboard/time/CompactTimeCountdown.tsx index 6c585a7643..bb90db6776 100644 --- a/components/waves/leaderboard/time/CompactTimeCountdown.tsx +++ b/components/waves/leaderboard/time/CompactTimeCountdown.tsx @@ -4,23 +4,20 @@ import { TimeUnitDisplay } from "./TimeUnitDisplay"; interface CompactTimeCountdownProps { readonly timeLeft: TimeLeft; - readonly label?: string | undefined; } /** - * Displays a compact countdown with time units inline - * Used in tab headers and other space-constrained areas + * Displays a compact inline countdown for upcoming decisions. */ export const CompactTimeCountdown: React.FC = ({ timeLeft, }) => { return ( -
- +
+ Next winner:
- {/* Days - only show when > 0 */} {timeLeft.days > 0 && ( )} diff --git a/components/waves/leaderboard/time/TimeUnitDisplay.tsx b/components/waves/leaderboard/time/TimeUnitDisplay.tsx index 404c334de2..1f00c2b6d4 100644 --- a/components/waves/leaderboard/time/TimeUnitDisplay.tsx +++ b/components/waves/leaderboard/time/TimeUnitDisplay.tsx @@ -14,10 +14,12 @@ export const TimeUnitDisplay: React.FC = ({ }) => { return (
- + {value} - {label} + + {label} +
); }; diff --git a/components/waves/leaderboard/time/TimelineToggleHeader.tsx b/components/waves/leaderboard/time/TimelineToggleHeader.tsx index e77d6cd8c9..88bae05ca5 100644 --- a/components/waves/leaderboard/time/TimelineToggleHeader.tsx +++ b/components/waves/leaderboard/time/TimelineToggleHeader.tsx @@ -4,11 +4,14 @@ import type { FC } from "react"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faChevronDown } from "@fortawesome/free-solid-svg-icons"; import type { ApiWaveDecisionPause } from "@/generated/models/ApiWaveDecisionPause"; +import type { TimeLeft } from "@/helpers/waves/time.utils"; +import { CompactTimeCountdown } from "./CompactTimeCountdown"; interface TimelineToggleHeaderProps { readonly isOpen: boolean; readonly setIsOpen: (isOpen: boolean) => void; readonly nextDecisionTime: number | null; + readonly timeLeft: TimeLeft; readonly isPaused?: boolean | undefined; readonly currentPause?: ApiWaveDecisionPause | null | undefined; } @@ -20,10 +23,19 @@ export const TimelineToggleHeader: FC = ({ isOpen, setIsOpen, nextDecisionTime, + timeLeft, isPaused = false, currentPause, }) => { const hasNextDecision = typeof nextDecisionTime === "number"; + const formattedNextDecisionDate = hasNextDecision + ? new Date(nextDecisionTime).toLocaleDateString(undefined, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }) + : null; // Extract the status display logic const getStatusDisplay = () => { @@ -50,14 +62,12 @@ export const TimelineToggleHeader: FC = ({ if (hasNextDecision) { return ( - - {new Date(nextDecisionTime).toLocaleDateString(undefined, { - month: "short", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - })} - +
+ + + {formattedNextDecisionDate} + +
); } @@ -80,7 +90,7 @@ export const TimelineToggleHeader: FC = ({ {hasNextDecision ? "Decision Timeline" : "Announcement history"} -
+
{getStatusDisplay()}
diff --git a/hooks/useMediaQuery.ts b/hooks/useMediaQuery.ts index dce0513677..97a2a697fd 100644 --- a/hooks/useMediaQuery.ts +++ b/hooks/useMediaQuery.ts @@ -1,17 +1,56 @@ -import { useEffect, useState } from "react"; +import { useEffect, useEffectEvent, useState } from "react"; + +const getMediaQueryList = (query: string): MediaQueryList | null => { + if ( + globalThis.window === undefined || + typeof globalThis.window.matchMedia !== "function" + ) { + return null; + } + + return globalThis.window.matchMedia(query); +}; export function useMediaQuery(query: string): boolean { const [matches, setMatches] = useState(false); + const syncMatches = useEffectEvent((nextMatches: boolean) => { + setMatches((currentMatches) => + currentMatches === nextMatches ? currentMatches : nextMatches + ); + }); useEffect(() => { - const m = globalThis.window?.matchMedia?.(query); - if (!m) return; + const mediaQueryList = getMediaQueryList(query); + if (!mediaQueryList) { + return; + } + + syncMatches(mediaQueryList.matches); + + const handleChange = (event: MediaQueryListEvent) => { + syncMatches(event.matches); + }; + + if (typeof mediaQueryList.addEventListener === "function") { + mediaQueryList.addEventListener("change", handleChange); + return () => mediaQueryList.removeEventListener("change", handleChange); + } + + const previousOnChange = mediaQueryList.onchange; + const fallbackHandler: NonNullable = ( + event + ) => { + previousOnChange?.call(mediaQueryList, event); + syncMatches(mediaQueryList.matches); + }; - setMatches(m.matches); + mediaQueryList.onchange = fallbackHandler; - const handler = (e: MediaQueryListEvent) => setMatches(e.matches); - m.addEventListener("change", handler); - return () => m.removeEventListener("change", handler); + return () => { + if (mediaQueryList.onchange === fallbackHandler) { + mediaQueryList.onchange = previousOnChange; + } + }; }, [query]); return matches;