From 7c730632d60001601da93a9007a05b1d6cbc1949 Mon Sep 17 00:00:00 2001 From: ragnep Date: Fri, 10 Apr 2026 21:43:35 +0300 Subject: [PATCH 1/4] memes wave ui fixes Signed-off-by: ragnep --- .../my-stream/MyStreamWaveDesktopTabs.tsx | 99 ++++++++++--------- .../tabs/MyStreamWaveCreateCurationAction.tsx | 63 ++++++------ .../my-stream/tabs/MyStreamWaveTabsMeme.tsx | 67 +------------ .../waves/leaderboard/WaveLeaderboardTime.tsx | 31 ++++++ .../leaderboard/time/CompactTimeCountdown.tsx | 8 +- .../leaderboard/time/TimeUnitDisplay.tsx | 6 +- .../leaderboard/time/TimelineToggleHeader.tsx | 41 ++++++-- hooks/useMediaQuery.ts | 47 ++++++--- 8 files changed, 185 insertions(+), 177 deletions(-) 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..3a2a5b1864 100644 --- a/components/waves/leaderboard/time/CompactTimeCountdown.tsx +++ b/components/waves/leaderboard/time/CompactTimeCountdown.tsx @@ -8,19 +8,17 @@ interface CompactTimeCountdownProps { } /** - * Displays a compact countdown with time units inline - * Used in tab headers and other space-constrained areas + * Legacy compact countdown markup kept for compatibility with existing tests. */ 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..49dae831d7 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 { TimeUnitDisplay } from "./TimeUnitDisplay"; 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,25 @@ export const TimelineToggleHeader: FC = ({ if (hasNextDecision) { return ( - - {new Date(nextDecisionTime).toLocaleDateString(undefined, { - month: "short", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - })} - +
+
+ + Next winner: + +
+ {timeLeft.days > 0 && ( + + )} + + + +
+
+ + + {formattedNextDecisionDate} + +
); } @@ -80,7 +103,7 @@ export const TimelineToggleHeader: FC = ({ {hasNextDecision ? "Decision Timeline" : "Announcement history"} -
+
{getStatusDisplay()}
diff --git a/hooks/useMediaQuery.ts b/hooks/useMediaQuery.ts index dce0513677..6feb0715dc 100644 --- a/hooks/useMediaQuery.ts +++ b/hooks/useMediaQuery.ts @@ -1,18 +1,43 @@ -import { useEffect, useState } from "react"; +import { useCallback, useSyncExternalStore } from "react"; + +const getMediaQueryList = (query: string): MediaQueryList | null => { + if ( + typeof window === "undefined" || + typeof window.matchMedia !== "function" + ) { + return null; + } + + return window.matchMedia(query); +}; export function useMediaQuery(query: string): boolean { - const [matches, setMatches] = useState(false); + const subscribe = useCallback( + (onStoreChange: () => void) => { + const mediaQueryList = getMediaQueryList(query); + if (!mediaQueryList) { + return () => {}; + } + + const handler = () => { + onStoreChange(); + }; - useEffect(() => { - const m = globalThis.window?.matchMedia?.(query); - if (!m) return; + if (typeof mediaQueryList.addEventListener === "function") { + mediaQueryList.addEventListener("change", handler); + return () => mediaQueryList.removeEventListener("change", handler); + } - setMatches(m.matches); + mediaQueryList.addListener(handler); + return () => mediaQueryList.removeListener(handler); + }, + [query] + ); - const handler = (e: MediaQueryListEvent) => setMatches(e.matches); - m.addEventListener("change", handler); - return () => m.removeEventListener("change", handler); - }, [query]); + const getSnapshot = useCallback( + () => getMediaQueryList(query)?.matches ?? false, + [query] + ); - return matches; + return useSyncExternalStore(subscribe, getSnapshot, () => false); } From 2c0ff27ab0713e987eeefe30297567089d7ec69c Mon Sep 17 00:00:00 2001 From: ragnep Date: Fri, 10 Apr 2026 21:50:32 +0300 Subject: [PATCH 2/4] wip Signed-off-by: ragnep --- .../leaderboard/time/CompactTimeCountdown.tsx | 3 +-- .../leaderboard/time/TimelineToggleHeader.tsx | 17 ++------------ hooks/useMediaQuery.ts | 23 +++++++++++++++---- 3 files changed, 21 insertions(+), 22 deletions(-) diff --git a/components/waves/leaderboard/time/CompactTimeCountdown.tsx b/components/waves/leaderboard/time/CompactTimeCountdown.tsx index 3a2a5b1864..bb90db6776 100644 --- a/components/waves/leaderboard/time/CompactTimeCountdown.tsx +++ b/components/waves/leaderboard/time/CompactTimeCountdown.tsx @@ -4,11 +4,10 @@ import { TimeUnitDisplay } from "./TimeUnitDisplay"; interface CompactTimeCountdownProps { readonly timeLeft: TimeLeft; - readonly label?: string | undefined; } /** - * Legacy compact countdown markup kept for compatibility with existing tests. + * Displays a compact inline countdown for upcoming decisions. */ export const CompactTimeCountdown: React.FC = ({ timeLeft, diff --git a/components/waves/leaderboard/time/TimelineToggleHeader.tsx b/components/waves/leaderboard/time/TimelineToggleHeader.tsx index 49dae831d7..88bae05ca5 100644 --- a/components/waves/leaderboard/time/TimelineToggleHeader.tsx +++ b/components/waves/leaderboard/time/TimelineToggleHeader.tsx @@ -5,7 +5,7 @@ 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 { TimeUnitDisplay } from "./TimeUnitDisplay"; +import { CompactTimeCountdown } from "./CompactTimeCountdown"; interface TimelineToggleHeaderProps { readonly isOpen: boolean; @@ -63,20 +63,7 @@ export const TimelineToggleHeader: FC = ({ if (hasNextDecision) { return (
-
- - Next winner: - -
- {timeLeft.days > 0 && ( - - )} - - - -
-
- + {formattedNextDecisionDate} diff --git a/hooks/useMediaQuery.ts b/hooks/useMediaQuery.ts index 6feb0715dc..df9f9bc607 100644 --- a/hooks/useMediaQuery.ts +++ b/hooks/useMediaQuery.ts @@ -2,13 +2,13 @@ import { useCallback, useSyncExternalStore } from "react"; const getMediaQueryList = (query: string): MediaQueryList | null => { if ( - typeof window === "undefined" || - typeof window.matchMedia !== "function" + typeof globalThis.window === "undefined" || + typeof globalThis.window.matchMedia !== "function" ) { return null; } - return window.matchMedia(query); + return globalThis.window.matchMedia(query); }; export function useMediaQuery(query: string): boolean { @@ -28,8 +28,21 @@ export function useMediaQuery(query: string): boolean { return () => mediaQueryList.removeEventListener("change", handler); } - mediaQueryList.addListener(handler); - return () => mediaQueryList.removeListener(handler); + const previousOnChange = mediaQueryList.onchange; + const fallbackHandler: NonNullable = ( + event + ) => { + previousOnChange?.call(mediaQueryList, event); + onStoreChange(); + }; + + mediaQueryList.onchange = fallbackHandler; + + return () => { + if (mediaQueryList.onchange === fallbackHandler) { + mediaQueryList.onchange = previousOnChange; + } + }; }, [query] ); From 1e647199c662bbcc2cb359cb1cbbf860c8d9cc5b Mon Sep 17 00:00:00 2001 From: ragnep Date: Fri, 10 Apr 2026 21:56:36 +0300 Subject: [PATCH 3/4] wip Signed-off-by: ragnep --- hooks/useMediaQuery.ts | 81 +++++++++++++++++++++--------------------- 1 file changed, 41 insertions(+), 40 deletions(-) diff --git a/hooks/useMediaQuery.ts b/hooks/useMediaQuery.ts index df9f9bc607..8302864e52 100644 --- a/hooks/useMediaQuery.ts +++ b/hooks/useMediaQuery.ts @@ -1,4 +1,4 @@ -import { useCallback, useSyncExternalStore } from "react"; +import { useEffect, useEffectEvent, useState } from "react"; const getMediaQueryList = (query: string): MediaQueryList | null => { if ( @@ -12,45 +12,46 @@ const getMediaQueryList = (query: string): MediaQueryList | null => { }; export function useMediaQuery(query: string): boolean { - const subscribe = useCallback( - (onStoreChange: () => void) => { - const mediaQueryList = getMediaQueryList(query); - if (!mediaQueryList) { - return () => {}; + const [matches, setMatches] = useState(false); + const syncMatches = useEffectEvent((nextMatches: boolean) => { + setMatches((currentMatches) => + currentMatches === nextMatches ? currentMatches : nextMatches + ); + }); + + useEffect(() => { + 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); + }; + + mediaQueryList.onchange = fallbackHandler; + + return () => { + if (mediaQueryList.onchange === fallbackHandler) { + mediaQueryList.onchange = previousOnChange; } + }; + }, [query]); - const handler = () => { - onStoreChange(); - }; - - if (typeof mediaQueryList.addEventListener === "function") { - mediaQueryList.addEventListener("change", handler); - return () => mediaQueryList.removeEventListener("change", handler); - } - - const previousOnChange = mediaQueryList.onchange; - const fallbackHandler: NonNullable = ( - event - ) => { - previousOnChange?.call(mediaQueryList, event); - onStoreChange(); - }; - - mediaQueryList.onchange = fallbackHandler; - - return () => { - if (mediaQueryList.onchange === fallbackHandler) { - mediaQueryList.onchange = previousOnChange; - } - }; - }, - [query] - ); - - const getSnapshot = useCallback( - () => getMediaQueryList(query)?.matches ?? false, - [query] - ); - - return useSyncExternalStore(subscribe, getSnapshot, () => false); + return matches; } From 831ec30645f0014b85563671ed96de78954d1483 Mon Sep 17 00:00:00 2001 From: ragnep Date: Fri, 10 Apr 2026 22:22:04 +0300 Subject: [PATCH 4/4] wip Signed-off-by: ragnep --- hooks/useMediaQuery.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hooks/useMediaQuery.ts b/hooks/useMediaQuery.ts index 8302864e52..97a2a697fd 100644 --- a/hooks/useMediaQuery.ts +++ b/hooks/useMediaQuery.ts @@ -2,7 +2,7 @@ import { useEffect, useEffectEvent, useState } from "react"; const getMediaQueryList = (query: string): MediaQueryList | null => { if ( - typeof globalThis.window === "undefined" || + globalThis.window === undefined || typeof globalThis.window.matchMedia !== "function" ) { return null;