diff --git a/__tests__/components/brain/BrainMobile.test.tsx b/__tests__/components/brain/BrainMobile.test.tsx index 6c1402f5dc..8a2c50d25a 100644 --- a/__tests__/components/brain/BrainMobile.test.tsx +++ b/__tests__/components/brain/BrainMobile.test.tsx @@ -135,18 +135,18 @@ jest.mock( () => ({ __esModule: true, default: ({ + leftThisRoundCount, onOpenQuickVote, - unratedCount, }: { + readonly leftThisRoundCount: number; readonly onOpenQuickVote: () => void; - readonly unratedCount: number; }) => ( ), }) @@ -254,9 +254,11 @@ describe("BrainMobile", () => { isDm: incomingWave?.chat?.scope?.group?.is_direct_message ?? false, })); mockUseMemesWaveFooterStats.mockReturnValue({ + isAvailable: true, isReady: true, + leftThisRoundCount: 3, uncastPower: 5000, - unratedCount: 3, + unratedCount: 9, votingLabel: "TDH", }); }); diff --git a/__tests__/components/brain/left-sidebar/waves/MemesWaveFooter.test.tsx b/__tests__/components/brain/left-sidebar/waves/MemesWaveFooter.test.tsx index 27a7cefecc..50d052c901 100644 --- a/__tests__/components/brain/left-sidebar/waves/MemesWaveFooter.test.tsx +++ b/__tests__/components/brain/left-sidebar/waves/MemesWaveFooter.test.tsx @@ -19,6 +19,8 @@ describe("MemesWaveFooter", () => { beforeEach(() => { jest.clearAllMocks(); useMemesWaveFooterStatsMock.mockReturnValue({ + isAvailable: false, + leftThisRoundCount: 0, uncastPower: null, unratedCount: 0, votingLabel: null, @@ -34,8 +36,10 @@ describe("MemesWaveFooter", () => { it("renders the expanded footer card", () => { useMemesWaveFooterStatsMock.mockReturnValue({ + isAvailable: true, + leftThisRoundCount: 3, uncastPower: 5000, - unratedCount: 3, + unratedCount: 12, votingLabel: "TDH", isReady: true, }); @@ -44,13 +48,16 @@ describe("MemesWaveFooter", () => { expect(screen.getByText("Uncast Power")).toBeInTheDocument(); expect(screen.getByText("5,000 TDH")).toBeInTheDocument(); - expect(screen.getByText("3 left")).toBeInTheDocument(); + expect(screen.getByText("3 left this round")).toBeInTheDocument(); + expect(screen.getByText("12 unrated")).toBeInTheDocument(); }); it("calls onOpenQuickVote from the expanded card", () => { useMemesWaveFooterStatsMock.mockReturnValue({ + isAvailable: true, + leftThisRoundCount: 3, uncastPower: 5000, - unratedCount: 3, + unratedCount: 12, votingLabel: "TDH", isReady: true, }); @@ -64,7 +71,7 @@ describe("MemesWaveFooter", () => { fireEvent.click( screen.getByRole("button", { - name: "Uncast Power, 5,000 TDH, 3 left", + name: "Uncast Power, 5,000 TDH left, 3 left this round, 12 unrated", }) ); @@ -73,8 +80,10 @@ describe("MemesWaveFooter", () => { it("prefetches quick vote from the expanded card on hover and focus", () => { useMemesWaveFooterStatsMock.mockReturnValue({ + isAvailable: true, + leftThisRoundCount: 3, uncastPower: 5000, - unratedCount: 3, + unratedCount: 12, votingLabel: "TDH", isReady: true, }); @@ -87,7 +96,7 @@ describe("MemesWaveFooter", () => { ); const button = screen.getByRole("button", { - name: "Uncast Power, 5,000 TDH, 3 left", + name: "Uncast Power, 5,000 TDH left, 3 left this round, 12 unrated", }); fireEvent.mouseEnter(button); @@ -98,8 +107,10 @@ describe("MemesWaveFooter", () => { it("renders the compact collapsed pill", () => { useMemesWaveFooterStatsMock.mockReturnValue({ + isAvailable: true, + leftThisRoundCount: 4, uncastPower: 5000, - unratedCount: 4, + unratedCount: 9, votingLabel: "TDH", isReady: true, }); @@ -115,15 +126,17 @@ describe("MemesWaveFooter", () => { expect(screen.queryByText("Uncast Power")).not.toBeInTheDocument(); expect( screen.getByRole("button", { - name: "4 submissions left unrated in memes wave", + name: "4 left this round, 9 unrated in the memes wave", }) ).toBeInTheDocument(); }); it("calls onOpenQuickVote from the collapsed pill", () => { useMemesWaveFooterStatsMock.mockReturnValue({ + isAvailable: true, + leftThisRoundCount: 4, uncastPower: 5000, - unratedCount: 4, + unratedCount: 9, votingLabel: "TDH", isReady: true, }); @@ -132,7 +145,7 @@ describe("MemesWaveFooter", () => { fireEvent.click( screen.getByRole("button", { - name: "4 submissions left unrated in memes wave", + name: "4 left this round, 9 unrated in the memes wave", }) ); @@ -141,8 +154,10 @@ describe("MemesWaveFooter", () => { it("prefetches quick vote from the collapsed pill on hover and focus", () => { useMemesWaveFooterStatsMock.mockReturnValue({ + isAvailable: true, + leftThisRoundCount: 4, uncastPower: 5000, - unratedCount: 4, + unratedCount: 9, votingLabel: "TDH", isReady: true, }); @@ -156,7 +171,7 @@ describe("MemesWaveFooter", () => { ); const button = screen.getByRole("button", { - name: "4 submissions left unrated in memes wave", + name: "4 left this round, 9 unrated in the memes wave", }); fireEvent.mouseEnter(button); @@ -167,6 +182,8 @@ describe("MemesWaveFooter", () => { it("ignores expanded-card clicks when no submissions remain", () => { useMemesWaveFooterStatsMock.mockReturnValue({ + isAvailable: false, + leftThisRoundCount: 0, uncastPower: 5000, unratedCount: 0, votingLabel: "TDH", @@ -180,14 +197,6 @@ describe("MemesWaveFooter", () => { /> ); - const button = screen.getByRole("button", { - name: "Uncast Power, 5,000 TDH, 0 left", - }); - - fireEvent.mouseEnter(button); - fireEvent.click(button); - - expect(onOpenQuickVote).not.toHaveBeenCalled(); - expect(onPrefetchQuickVote).not.toHaveBeenCalled(); + expect(screen.queryByRole("button")).toBeNull(); }); }); diff --git a/__tests__/components/brain/mobile/FloatingMemesQuickVoteTrigger.test.tsx b/__tests__/components/brain/mobile/FloatingMemesQuickVoteTrigger.test.tsx index 6f012c5a31..22647af9c0 100644 --- a/__tests__/components/brain/mobile/FloatingMemesQuickVoteTrigger.test.tsx +++ b/__tests__/components/brain/mobile/FloatingMemesQuickVoteTrigger.test.tsx @@ -11,10 +11,12 @@ jest.mock( () => ({ __esModule: true, default: ({ + leftThisRoundCount, onOpenQuickVote, onPrefetchQuickVote, - unratedCount, + unratedCount: _unratedCount, }: { + readonly leftThisRoundCount: number; readonly onOpenQuickVote: () => void; readonly onPrefetchQuickVote?: (() => void) | undefined; readonly unratedCount: number; @@ -26,7 +28,7 @@ jest.mock( onFocus={onPrefetchQuickVote} onMouseEnter={onPrefetchQuickVote} > - {unratedCount} + {leftThisRoundCount} ), }) @@ -44,7 +46,9 @@ describe("FloatingMemesQuickVoteTrigger", () => { beforeEach(() => { jest.clearAllMocks(); useMemesWaveFooterStatsMock.mockReturnValue({ + isAvailable: false, isReady: false, + leftThisRoundCount: 0, uncastPower: null, unratedCount: 0, votingLabel: null, @@ -64,9 +68,11 @@ describe("FloatingMemesQuickVoteTrigger", () => { it("passes hover and focus prefetch intent through to the floating trigger", () => { useMemesWaveFooterStatsMock.mockReturnValue({ + isAvailable: true, isReady: true, + leftThisRoundCount: 3, uncastPower: 5000, - unratedCount: 3, + unratedCount: 9, votingLabel: "TDH", }); diff --git a/__tests__/hooks/useMemesWaveFooterStats.test.tsx b/__tests__/hooks/useMemesWaveFooterStats.test.tsx index da39189354..9ddb6668d0 100644 --- a/__tests__/hooks/useMemesWaveFooterStats.test.tsx +++ b/__tests__/hooks/useMemesWaveFooterStats.test.tsx @@ -145,25 +145,14 @@ describe("useMemesWaveFooterStats", () => { it("fetches the first unvoted leaderboard item and derives footer stats from it", async () => { commonApiFetchMock.mockResolvedValue({ - count: 2, - page: 1, - next: true, - wave: { - id: "memes-wave", - name: "The Memes", - voting_credit_type: ApiWaveCreditType.Tdh, - authenticated_user_eligible_to_vote: true, - voting_period_start: null, - voting_period_end: null, - }, - drops: [ - createDrop({ - id: "drop-40", - serialNo: 40, - rating: 0, - maxRating: 5_000, - }), - ], + drop: createDrop({ + id: "drop-40", + serialNo: 40, + rating: 0, + maxRating: 5_000, + }), + left_to_vote_in_current_round: 2, + total_count: 7, } as any); const { result } = renderHook(() => useMemesWaveFooterStats(), { @@ -172,45 +161,30 @@ describe("useMemesWaveFooterStats", () => { await waitFor(() => expect(result.current.isReady).toBe(true)); + expect(result.current.leftThisRoundCount).toBe(2); expect(result.current.uncastPower).toBe(5_000); - expect(result.current.unratedCount).toBe(2); + expect(result.current.unratedCount).toBe(7); expect(result.current.votingLabel).toBe("TDH"); expect(commonApiFetchMock).toHaveBeenCalledTimes(1); expect(commonApiFetchMock).toHaveBeenCalledWith({ - endpoint: "waves/memes-wave/leaderboard", - params: { - page: "1", - page_size: "1", - sort: "CREATED_AT", - sort_direction: "DESC", - unvoted_by_me: "true", - }, + endpoint: "waves/memes-wave/undiscovered-drop", + params: undefined, + signal: expect.any(AbortSignal), }); }); it("stays hidden when the summary response does not include usable vote context", async () => { commonApiFetchMock.mockResolvedValue({ - count: 1, - page: 1, - next: false, - wave: { - id: "memes-wave", - name: "The Memes", - voting_credit_type: ApiWaveCreditType.Tdh, - authenticated_user_eligible_to_vote: true, - voting_period_start: null, - voting_period_end: null, + drop: { + ...createDrop({ + id: "drop-40", + serialNo: 40, + rating: 0, + }), + context_profile_context: null, }, - drops: [ - { - ...createDrop({ - id: "drop-40", - serialNo: 40, - rating: 0, - }), - context_profile_context: null, - }, - ], + left_to_vote_in_current_round: 1, + total_count: 1, } as any); const { result } = renderHook(() => useMemesWaveFooterStats(), { @@ -220,6 +194,7 @@ describe("useMemesWaveFooterStats", () => { await waitFor(() => expect(commonApiFetchMock).toHaveBeenCalledTimes(1)); expect(result.current.isReady).toBe(false); + expect(result.current.leftThisRoundCount).toBe(0); expect(result.current.uncastPower).toBeNull(); expect(result.current.unratedCount).toBe(0); expect(result.current.votingLabel).toBeNull(); diff --git a/components/brain/left-sidebar/waves/MemesWaveFooter.tsx b/components/brain/left-sidebar/waves/MemesWaveFooter.tsx index 885758ec10..cdb0f079d3 100644 --- a/components/brain/left-sidebar/waves/MemesWaveFooter.tsx +++ b/components/brain/left-sidebar/waves/MemesWaveFooter.tsx @@ -4,6 +4,10 @@ import MemesWaveQuickVoteTrigger from "@/components/brain/left-sidebar/waves/Mem import MemesWaveZapIcon from "@/components/brain/left-sidebar/waves/MemesWaveZapIcon"; import { useMemesWaveFooterStats } from "@/hooks/useMemesWaveFooterStats"; import { formatNumberWithCommas } from "@/helpers/Helpers"; +import { + formatMemesQuickVoteLeftThisRoundText, + formatMemesQuickVoteUnratedText, +} from "@/hooks/memesQuickVote.helpers"; import { AnimatePresence, motion } from "framer-motion"; import React from "react"; @@ -23,13 +27,21 @@ const MemesWaveFooter: React.FC = ({ onOpenQuickVote, onPrefetchQuickVote, }) => { - const { isAvailable, isReady, uncastPower, unratedCount, votingLabel } = - useMemesWaveFooterStats(); + const { + isAvailable, + isReady, + leftThisRoundCount, + uncastPower, + unratedCount, + votingLabel, + } = useMemesWaveFooterStats(); const buttonAriaLabel = isReady && typeof uncastPower === "number" ? `Uncast Power, ${formatNumberWithCommas(uncastPower)} ${ votingLabel ?? "Votes" - } left, ${formatNumberWithCommas(unratedCount)} unrated` + } left, ${formatMemesQuickVoteLeftThisRoundText( + leftThisRoundCount + )}, ${formatMemesQuickVoteUnratedText(unratedCount)}` : "Quick vote"; const buttonTitle = isReady ? "Uncast votes" : "Quick vote"; const votingPowerLabel = votingLabel ? ` ${votingLabel}` : " votes"; @@ -71,6 +83,7 @@ const MemesWaveFooter: React.FC = ({ {collapsed ? ( = ({ {isReady && ( - - {formatNumberWithCommas(unratedCount)} unrated - +
+ + {formatMemesQuickVoteLeftThisRoundText( + leftThisRoundCount + )} + + + {formatMemesQuickVoteUnratedText(unratedCount)} + +
)} diff --git a/components/brain/left-sidebar/waves/MemesWaveQuickVoteTrigger.tsx b/components/brain/left-sidebar/waves/MemesWaveQuickVoteTrigger.tsx index 6a4cc42fcf..a67598f4c9 100644 --- a/components/brain/left-sidebar/waves/MemesWaveQuickVoteTrigger.tsx +++ b/components/brain/left-sidebar/waves/MemesWaveQuickVoteTrigger.tsx @@ -1,12 +1,17 @@ "use client"; import MemesWaveZapIcon from "@/components/brain/left-sidebar/waves/MemesWaveZapIcon"; +import { + formatMemesQuickVoteLeftThisRoundText, + formatMemesQuickVoteUnratedText, +} from "@/hooks/memesQuickVote.helpers"; import { formatNumberWithCommas } from "@/helpers/Helpers"; import React from "react"; interface MemesWaveQuickVoteTriggerProps { readonly isAvailable?: boolean | undefined; readonly className?: string | undefined; + readonly leftThisRoundCount: number; readonly onOpenQuickVote: () => void; readonly onPrefetchQuickVote?: (() => void) | undefined; readonly unratedCount: number; @@ -15,6 +20,7 @@ interface MemesWaveQuickVoteTriggerProps { const MemesWaveQuickVoteTrigger: React.FC = ({ isAvailable = true, className, + leftThisRoundCount, onOpenQuickVote, onPrefetchQuickVote, unratedCount, @@ -24,10 +30,17 @@ const MemesWaveQuickVoteTrigger: React.FC = ({ } const label = - unratedCount > 0 - ? `${unratedCount} unrated submissions in the memes wave` + leftThisRoundCount > 0 + ? `${formatMemesQuickVoteLeftThisRoundText( + leftThisRoundCount + )}, ${formatMemesQuickVoteUnratedText(unratedCount)} in the memes wave` + : "Quick vote"; + const title = + leftThisRoundCount > 0 + ? `${formatMemesQuickVoteLeftThisRoundText( + leftThisRoundCount + )}, ${formatMemesQuickVoteUnratedText(unratedCount)}` : "Quick vote"; - const title = unratedCount > 0 ? `${unratedCount} unrated` : "Quick vote"; return ( diff --git a/components/brain/left-sidebar/waves/memes-quick-vote/MemesQuickVoteControls.tsx b/components/brain/left-sidebar/waves/memes-quick-vote/MemesQuickVoteControls.tsx index 456ffb7558..744a92281e 100644 --- a/components/brain/left-sidebar/waves/memes-quick-vote/MemesQuickVoteControls.tsx +++ b/components/brain/left-sidebar/waves/memes-quick-vote/MemesQuickVoteControls.tsx @@ -1,7 +1,10 @@ "use client"; -import { formatNumberWithCommas } from "@/helpers/Helpers"; import type { ExtendedDrop } from "@/helpers/waves/drop.helpers"; +import { + formatMemesQuickVoteLeftThisRoundText, + formatMemesQuickVoteUnratedText, +} from "@/hooks/memesQuickVote.helpers"; import MemesQuickVoteActionBar from "./MemesQuickVoteActionBar"; import MemesQuickVoteDescription from "./MemesQuickVoteDescription"; import MemesQuickVoteDropHeader from "./MemesQuickVoteDropHeader"; @@ -16,10 +19,11 @@ interface MemesQuickVoteControlsProps { readonly isCustomOpen: boolean; readonly isSubmitting: boolean; readonly isVoteFeedbackActive: boolean; + readonly leftThisRoundCount: number; readonly latestUsedAmount: number | null; - readonly remainingCount: number; readonly quickAmounts: readonly number[]; readonly uncastPower: number | null; + readonly unratedCount: number; readonly votingLabel: string | null; readonly onCustomChange: (value: string) => void; readonly onCustomSubmit: () => void; @@ -28,6 +32,26 @@ interface MemesQuickVoteControlsProps { readonly onVoteAmount: (amount: number) => void; } +function MemesQuickVoteStatPill({ + children, + tone = "secondary", +}: { + readonly children: string; + readonly tone?: "primary" | "secondary"; +}) { + return ( + + {children} + + ); +} + export default function MemesQuickVoteControls({ customValue, drop, @@ -36,10 +60,11 @@ export default function MemesQuickVoteControls({ isCustomOpen, isSubmitting, isVoteFeedbackActive, + leftThisRoundCount, latestUsedAmount, - remainingCount, quickAmounts, uncastPower, + unratedCount, votingLabel, onCustomChange, onCustomSubmit, @@ -60,9 +85,12 @@ export default function MemesQuickVoteControls({ className="tw-flex tw-shrink-0 tw-flex-col tw-gap-0 md:tw-h-full md:tw-min-h-0 md:tw-overflow-hidden md:tw-bg-[#0a0a0a]/30" >
- - {formatNumberWithCommas(remainingCount)} unrated - + + {formatMemesQuickVoteLeftThisRoundText(leftThisRoundCount)} + + + {formatMemesQuickVoteUnratedText(unratedCount)} +
diff --git a/components/brain/left-sidebar/waves/memes-quick-vote/MemesQuickVoteDialog.tsx b/components/brain/left-sidebar/waves/memes-quick-vote/MemesQuickVoteDialog.tsx index 702e1e2188..eafadb332d 100644 --- a/components/brain/left-sidebar/waves/memes-quick-vote/MemesQuickVoteDialog.tsx +++ b/components/brain/left-sidebar/waves/memes-quick-vote/MemesQuickVoteDialog.tsx @@ -1,14 +1,19 @@ "use client"; -import { formatNumberWithCommas } from "@/helpers/Helpers"; import { + formatMemesQuickVoteLeftThisRoundText, + formatMemesQuickVoteUnratedText, getDefaultQuickVoteAmount, normalizeQuickVoteAmount, } from "@/hooks/memesQuickVote.helpers"; import type { MemesQuickVoteDialogState } from "@/hooks/useMemesQuickVoteDialogController"; import { useMediaQuery } from "@/hooks/useMediaQuery"; +import { + MemesQuickVoteDialogDoneState, + MemesQuickVoteDialogErrorState, + MemesQuickVoteDialogRestartState, +} from "./MemesQuickVoteDialogStates"; import { XMarkIcon } from "@heroicons/react/24/outline"; -import { CheckCircleIcon } from "@heroicons/react/24/solid"; import { type ReactNode, useCallback, @@ -32,18 +37,58 @@ type MemesQuickVoteDialogProps = MemesQuickVoteDialogState; interface MemesQuickVoteDialogContentProps { readonly activeDrop: NonNullable; readonly isMobile: boolean; + readonly leftThisRoundCount: number; readonly latestUsedAmount: number | null; readonly nextDrop: MemesQuickVoteDialogProps["nextDrop"]; readonly onClose: () => void; - readonly remainingCount: number; readonly recentAmounts: number[]; readonly sessionId: number; readonly submitVote: MemesQuickVoteDialogProps["submitVote"]; readonly skipDrop: MemesQuickVoteDialogProps["skipDrop"]; readonly uncastPower: number | null; + readonly unratedCount: number; + readonly votingLabel: string | null; +} + +interface MemesQuickVotePreviewPaneProps { + readonly activeDrop: NonNullable; + readonly className: string; + readonly isBusy: boolean; + readonly isMobile: boolean; + readonly leftThisRoundCount: number; + readonly nextDrop: MemesQuickVoteDialogProps["nextDrop"]; + readonly onAdvanceStart: () => void; + readonly onSkip: () => void; + readonly onVoteWithSwipe: () => void; + readonly sessionId: number; + readonly swipeVoteAmount: number | null; + readonly uncastPower: number | null; + readonly unratedCount: number; readonly votingLabel: string | null; } +interface MemesQuickVoteControlsPaneProps { + readonly className: string; + readonly customValue: string; + readonly drop: NonNullable; + readonly feedbackAmount: number | null; + readonly feedbackSource: VoteFeedbackSource | null; + readonly isCustomOpen: boolean; + readonly isSubmitting: boolean; + readonly isVoteFeedbackActive: boolean; + readonly leftThisRoundCount: number; + readonly latestUsedAmount: number | null; + readonly quickAmounts: readonly number[]; + readonly uncastPower: number | null; + readonly unratedCount: number; + readonly votingLabel: string | null; + readonly onCustomChange: (value: string) => void; + readonly onCustomSubmit: () => void; + readonly onOpenCustom: () => void; + readonly onSkip: () => void; + readonly onVoteAmount: (amount: number) => void; +} + function getQuickVotePreloadedNextDrop( isOpen: boolean, nextDrop: MemesQuickVoteDialogProps["nextDrop"] @@ -58,8 +103,8 @@ function getQuickVotePreloadedNextDrop( const isGlbMedia = mediaMimeType === "model/gltf-binary" || mediaMimeType === "model/gltf+json" || - mediaUrl?.endsWith(".glb") || - mediaUrl?.endsWith(".gltf"); + (mediaUrl?.endsWith(".glb") ?? false) || + (mediaUrl?.endsWith(".gltf") ?? false); return isGlbMedia ? null : nextDrop; } @@ -68,27 +113,29 @@ function MemesQuickVotePreviewStack({ activeDrop, isBusy, isMobile, + leftThisRoundCount, nextDrop, onAdvanceStart, onSkip, onVoteWithSwipe, - remainingCount, sessionId, swipeVoteAmount, uncastPower, + unratedCount, votingLabel, }: { readonly activeDrop: NonNullable; readonly isBusy: boolean; readonly isMobile: boolean; + readonly leftThisRoundCount: number; readonly nextDrop: MemesQuickVoteDialogProps["nextDrop"]; readonly onAdvanceStart: () => void; readonly onSkip: () => void; readonly onVoteWithSwipe: () => void; - readonly remainingCount: number; readonly sessionId: number; readonly swipeVoteAmount: number | null; readonly uncastPower: number | null; + readonly unratedCount: number; readonly votingLabel: string | null; }) { const preloadedCardRef = useRef(null); @@ -121,10 +168,11 @@ function MemesQuickVotePreviewStack({ drop={nextDrop} isBusy={false} isMobile={isMobile} - remainingCount={remainingCount} + leftThisRoundCount={leftThisRoundCount} renderMode="preloaded" swipeVoteAmount={swipeVoteAmount} uncastPower={uncastPower} + unratedCount={unratedCount} votingLabel={votingLabel} onAdvanceStart={() => undefined} onSkip={() => undefined} @@ -139,10 +187,11 @@ function MemesQuickVotePreviewStack({ drop={activeDrop} isBusy={isBusy} isMobile={isMobile} - remainingCount={remainingCount} + leftThisRoundCount={leftThisRoundCount} renderMode="active" swipeVoteAmount={swipeVoteAmount} uncastPower={uncastPower} + unratedCount={unratedCount} votingLabel={votingLabel} onAdvanceStart={onAdvanceStart} onSkip={onSkip} @@ -153,18 +202,103 @@ function MemesQuickVotePreviewStack({ ); } +function MemesQuickVotePreviewPane({ + activeDrop, + className, + isBusy, + isMobile, + leftThisRoundCount, + nextDrop, + onAdvanceStart, + onSkip, + onVoteWithSwipe, + sessionId, + swipeVoteAmount, + uncastPower, + unratedCount, + votingLabel, +}: MemesQuickVotePreviewPaneProps) { + return ( +
+ +
+ ); +} + +function MemesQuickVoteControlsPane({ + className, + customValue, + drop, + feedbackAmount, + feedbackSource, + isCustomOpen, + isSubmitting, + isVoteFeedbackActive, + leftThisRoundCount, + latestUsedAmount, + quickAmounts, + uncastPower, + unratedCount, + votingLabel, + onCustomChange, + onCustomSubmit, + onOpenCustom, + onSkip, + onVoteAmount, +}: MemesQuickVoteControlsPaneProps) { + return ( +
+ +
+ ); +} + function MemesQuickVoteDialogContent({ activeDrop, isMobile, + leftThisRoundCount, latestUsedAmount, nextDrop, onClose, - remainingCount, recentAmounts, sessionId, submitVote, skipDrop, uncastPower, + unratedCount, votingLabel, }: MemesQuickVoteDialogContentProps) { const maxRating = activeDrop.context_profile_context?.max_rating ?? 0; @@ -288,6 +422,10 @@ function MemesQuickVoteDialogContent({ }); }, [activeDrop, skipDrop]); + const handleAdvanceStart = useCallback(() => { + setIsAdvancing(true); + }, []); + const handleSkip = () => { if (isControlsSubmitting) { return; @@ -297,6 +435,75 @@ function MemesQuickVoteDialogContent({ queueSkip(); }; + const handleVoteWithSwipe = useCallback(() => { + if (swipeVoteAmount === null) { + setIsAdvancing(false); + return; + } + + setIsAdvancing(true); + queueVoteAmount(swipeVoteAmount); + }, [queueVoteAmount, swipeVoteAmount]); + + const handleCustomChange = useCallback((value: string) => { + if (value === "") { + setCustomValue(""); + return; + } + + setCustomValue(value.replace(/[^\d]/g, "")); + }, []); + + const handleCustomSubmit = useCallback(() => { + handleBarVoteAmount(customValue, "custom-submit"); + }, [customValue, handleBarVoteAmount]); + + const handleToggleCustom = useCallback(() => { + setIsCustomOpen((current) => !current); + }, []); + + const handleQuickAmountVote = useCallback( + (amount: number) => { + handleBarVoteAmount(amount, "quick-amount"); + }, + [handleBarVoteAmount] + ); + const previewPaneProps = { + activeDrop, + isBusy: isControlsSubmitting, + isMobile, + leftThisRoundCount, + nextDrop, + onAdvanceStart: handleAdvanceStart, + onSkip: queueSkip, + onVoteWithSwipe: handleVoteWithSwipe, + sessionId, + swipeVoteAmount, + uncastPower, + unratedCount, + votingLabel, + } satisfies Omit; + const controlsPaneProps = { + customValue, + drop: activeDrop, + feedbackAmount: voteFeedback?.amount ?? null, + feedbackSource: voteFeedback?.source ?? null, + isCustomOpen, + isSubmitting: isControlsSubmitting, + isVoteFeedbackActive, + leftThisRoundCount, + latestUsedAmount: normalizedLatestUsedAmount, + quickAmounts: visibleQuickAmounts, + uncastPower, + unratedCount, + votingLabel, + onCustomChange: handleCustomChange, + onCustomSubmit: handleCustomSubmit, + onOpenCustom: handleToggleCustom, + onSkip: handleSkip, + onVoteAmount: handleQuickAmountVote, + } satisfies Omit; + return (
{isMobile && ( @@ -313,7 +520,10 @@ function MemesQuickVoteDialogContent({
- {formatNumberWithCommas(remainingCount)} unrated + {formatMemesQuickVoteLeftThisRoundText(leftThisRoundCount)} + + + {formatMemesQuickVoteUnratedText(unratedCount)}
@@ -323,202 +533,33 @@ function MemesQuickVoteDialogContent({ {isMobile ? (
-
- { - setIsAdvancing(true); - }} - onSkip={queueSkip} - onVoteWithSwipe={() => { - if (swipeVoteAmount === null) { - setIsAdvancing(false); - return; - } - - setIsAdvancing(true); - queueVoteAmount(swipeVoteAmount); - }} - /> -
+ -
- { - if (value === "") { - setCustomValue(""); - return; - } - - setCustomValue(value.replace(/[^\d]/g, "")); - }} - onCustomSubmit={() => { - handleBarVoteAmount(customValue, "custom-submit"); - }} - onOpenCustom={() => { - setIsCustomOpen((current) => !current); - }} - onSkip={handleSkip} - onVoteAmount={(amount) => { - handleBarVoteAmount(amount, "quick-amount"); - }} - /> -
+
) : ( <> -
- { - setIsAdvancing(true); - }} - onSkip={queueSkip} - onVoteWithSwipe={() => { - if (swipeVoteAmount === null) { - setIsAdvancing(false); - return; - } - - setIsAdvancing(true); - queueVoteAmount(swipeVoteAmount); - }} - /> -
+ -
- { - if (value === "") { - setCustomValue(""); - return; - } - - setCustomValue(value.replace(/[^\d]/g, "")); - }} - onCustomSubmit={() => { - handleBarVoteAmount(customValue, "custom-submit"); - }} - onOpenCustom={() => { - setIsCustomOpen((current) => !current); - }} - onSkip={handleSkip} - onVoteAmount={(amount) => { - handleBarVoteAmount(amount, "quick-amount"); - }} - /> -
+ )}
); } -function MemesQuickVoteDialogDoneState({ - onClose, -}: { - readonly onClose: () => void; -}) { - return ( -
-
-
-
- -
-
-
-
-
- -
-
-

- You're all caught up -

-

- No unrated memes are left in quick vote right now. -

- - -
-
- ); -} - -function MemesQuickVoteDialogErrorState({ - onRetry, -}: { - readonly onRetry: () => void; -}) { - return ( -
-
-

- Couldn't load quick vote -

-

- Quick vote couldn't load the next meme. Try again. -

- -
-
- ); -} - export default function MemesQuickVoteDialog({ isOpen, sessionId, @@ -526,20 +567,23 @@ export default function MemesQuickVoteDialog({ activeDrop, hasDiscoveryError, isExhausted, + isRestartingRound, + leftThisRoundCount, latestUsedAmount, nextDrop, recentAmounts, - remainingCount, retryDiscovery, submitVote, skipDrop, uncastPower, + unratedCount, votingLabel, }: MemesQuickVoteDialogProps) { const dialogRef = useRef(null); const previouslyFocusedElementRef = useRef(null); const previousBodyOverflowRef = useRef(""); const isMobile = useMediaQuery(QUICK_VOTE_MOBILE_QUERY); + const showStandaloneStateShellClose = activeDrop === null && !isExhausted; useEffect(() => { const dialog = dialogRef.current; @@ -598,8 +642,16 @@ export default function MemesQuickVoteDialog({ let dialogBody: ReactNode; - if (isExhausted) { - dialogBody = ; + if (isRestartingRound) { + dialogBody = ; + } else if (isExhausted) { + dialogBody = ( + + ); } else if (!activeDrop && hasDiscoveryError) { dialogBody = ; } else if (!activeDrop) { @@ -613,15 +665,16 @@ export default function MemesQuickVoteDialog({ key={contentResetKey} activeDrop={activeDrop} isMobile={isMobile} + leftThisRoundCount={leftThisRoundCount} latestUsedAmount={latestUsedAmount} nextDrop={preloadedNextDrop} onClose={onClose} - remainingCount={remainingCount} recentAmounts={recentAmounts} sessionId={sessionId} submitVote={submitVote} skipDrop={skipDrop} uncastPower={uncastPower} + unratedCount={unratedCount} votingLabel={votingLabel} /> ); @@ -647,9 +700,15 @@ export default function MemesQuickVoteDialog({
+ } + description={description} + title={title} + visual={ +
+
+
+
+ +
+
+ } + /> + ); +} + +export function MemesQuickVoteDialogRestartState() { + return ( + +
+
+
+
+
+
+ } + action={ +
+ + Loading next memes... +
+ } + /> + ); +} + +export function MemesQuickVoteDialogErrorState({ + onRetry, +}: { + readonly onRetry: () => void; +}) { + return ( +
+
+

+ Couldn't load quick vote +

+

+ Quick vote couldn't load the next meme. Try again. +

+ +
+
+ ); +} diff --git a/components/brain/left-sidebar/waves/memes-quick-vote/MemesQuickVotePreview.tsx b/components/brain/left-sidebar/waves/memes-quick-vote/MemesQuickVotePreview.tsx index 9778543a23..12ad0591e0 100644 --- a/components/brain/left-sidebar/waves/memes-quick-vote/MemesQuickVotePreview.tsx +++ b/components/brain/left-sidebar/waves/memes-quick-vote/MemesQuickVotePreview.tsx @@ -8,6 +8,10 @@ import { type ExtendedDrop, } from "@/helpers/waves/drop.helpers"; import useDeviceInfo from "@/hooks/useDeviceInfo"; +import { + formatMemesQuickVoteLeftThisRoundText, + formatMemesQuickVoteUnratedText, +} from "@/hooks/memesQuickVote.helpers"; import clsx from "clsx"; import type { ReactNode, TouchEventHandler } from "react"; import { useMemo } from "react"; @@ -22,10 +26,11 @@ interface MemesQuickVotePreviewProps { readonly drop: ExtendedDrop; readonly isBusy: boolean; readonly isMobile: boolean; - readonly remainingCount: number; + readonly leftThisRoundCount: number; readonly renderMode: "active" | "preloaded"; readonly swipeVoteAmount: number | null; readonly uncastPower: number | null; + readonly unratedCount: number; readonly votingLabel: string | null; readonly onAdvanceStart: () => void; readonly onSkip: () => void; @@ -211,10 +216,11 @@ function MemesQuickVotePreviewContent({ drop, isBusy, isMobile, - remainingCount, + leftThisRoundCount, renderMode, swipeVoteAmount, uncastPower, + unratedCount, votingLabel, onAdvanceStart, onSkip, @@ -359,12 +365,14 @@ function MemesQuickVotePreviewContent({ )} {isInteractive && ( - + <> + + {formatMemesQuickVoteLeftThisRoundText(leftThisRoundCount)} + + + {formatMemesQuickVoteUnratedText(unratedCount)} + + )}
diff --git a/components/brain/mobile/FloatingMemesQuickVoteTrigger.tsx b/components/brain/mobile/FloatingMemesQuickVoteTrigger.tsx index 26542e52ab..4ea6cfa7b0 100644 --- a/components/brain/mobile/FloatingMemesQuickVoteTrigger.tsx +++ b/components/brain/mobile/FloatingMemesQuickVoteTrigger.tsx @@ -12,7 +12,8 @@ export default function FloatingMemesQuickVoteTrigger({ onOpenQuickVote, onPrefetchQuickVote, }: FloatingMemesQuickVoteTriggerProps) { - const { isAvailable, unratedCount } = useMemesWaveFooterStats(); + const { isAvailable, leftThisRoundCount, unratedCount } = + useMemesWaveFooterStats(); if (!isAvailable) { return null; @@ -22,6 +23,7 @@ export default function FloatingMemesQuickVoteTrigger({
[...amounts].sort((left, right) => left - right); +export const formatMemesQuickVoteLeftThisRoundText = (count: number): string => + `${formatNumberWithCommas(count)} left this round`; + +export const formatMemesQuickVoteUnratedText = (count: number): string => + `${formatNumberWithCommas(count)} unrated`; + export const getDefaultQuickVoteAmount = (maxRating: number): number => { const normalizedMaxRating = Math.max(1, Math.floor(maxRating)); return Math.max( diff --git a/hooks/memesQuickVote.queue.state.ts b/hooks/memesQuickVote.queue.state.ts index 37fd0e94f2..5802125268 100644 --- a/hooks/memesQuickVote.queue.state.ts +++ b/hooks/memesQuickVote.queue.state.ts @@ -11,9 +11,11 @@ export type MemesQuickVoteSessionState = { readonly hasDiscoveryError: boolean; readonly isExhausted: boolean; readonly isLoading: boolean; + readonly isRestartingRound: boolean; + readonly leftThisRoundCount: number; readonly lookaheadDrops: readonly ApiDrop[]; readonly recentlyHandledDropIds: readonly string[]; - readonly totalCount: number; + readonly unratedCount: number; }; export const createInitialOptimisticRemainingPowerState = ( @@ -37,9 +39,11 @@ export const createInitialSessionState = (): MemesQuickVoteSessionState => ({ hasDiscoveryError: false, isExhausted: false, isLoading: false, + isRestartingRound: false, + leftThisRoundCount: 0, lookaheadDrops: [], recentlyHandledDropIds: [], - totalCount: 0, + unratedCount: 0, }); export const getUniqueDrops = (drops: readonly ApiDrop[]): ApiDrop[] => { @@ -58,26 +62,28 @@ export const getUniqueDrops = (drops: readonly ApiDrop[]): ApiDrop[] => { return uniqueDrops; }; -const getDisplayRemainingCount = ({ +const getDisplayLeftThisRoundCount = ({ hiddenDropIds, - rawTotalCount, + rawLeftThisRoundCount, }: { readonly hiddenDropIds: ReadonlySet; - readonly rawTotalCount: number; -}): number => Math.max(0, rawTotalCount - hiddenDropIds.size); + readonly rawLeftThisRoundCount: number; +}): number => Math.max(0, rawLeftThisRoundCount - hiddenDropIds.size); export const applyFetchedWindowState = ({ current, fetchedDrops, pendingDropIds, primaryDrop, - rawTotalCount, + rawLeftThisRoundCount, + rawUnratedCount, }: { readonly current: MemesQuickVoteSessionState; readonly fetchedDrops: readonly ApiDrop[]; readonly pendingDropIds: readonly string[]; readonly primaryDrop: ApiDrop | null; - readonly rawTotalCount: number; + readonly rawLeftThisRoundCount: number; + readonly rawUnratedCount: number; }): MemesQuickVoteSessionState => { const returnedDropIds = new Set(fetchedDrops.map((drop) => drop.id)); const resurfacedHandledDropIds = current.recentlyHandledDropIds.filter( @@ -88,10 +94,7 @@ export const applyFetchedWindowState = ({ !pendingDropIds.includes(drop.id) && !resurfacedHandledDropIds.includes(drop.id) ); - const recentlyHandledDropIds = - rawTotalCount > 0 && safeFetchedDropsWithoutHandled.length === 0 - ? [] - : resurfacedHandledDropIds; + const recentlyHandledDropIds = resurfacedHandledDropIds; const hiddenDropIds = new Set([...recentlyHandledDropIds, ...pendingDropIds]); let currentDrop = current.currentDrop; @@ -117,25 +120,33 @@ export const applyFetchedWindowState = ({ const lookaheadDrops = safeFetchedDrops.filter( (drop) => drop.id !== currentDrop?.id ); - const totalCount = getDisplayRemainingCount({ + const leftThisRoundCount = getDisplayLeftThisRoundCount({ hiddenDropIds, - rawTotalCount, + rawLeftThisRoundCount, }); - const isExhausted = + const isRestartingRound = currentDrop === null && lookaheadDrops.length === 0 && - hiddenDropIds.size === 0 && - primaryDrop === null && - rawTotalCount === 0; + rawLeftThisRoundCount === 0 && + rawUnratedCount > 0 && + safeFetchedDropsWithoutHandled.length === 0; + const isExhausted = + currentDrop === null && + lookaheadDrops.length === 0 && + hiddenDropIds.size === 0 && + primaryDrop === null && + rawUnratedCount === 0; return { currentDrop, hasDiscoveryError: false, isExhausted, - isLoading: currentDrop === null && !isExhausted, + isLoading: currentDrop === null && !isExhausted && !isRestartingRound, + isRestartingRound, + leftThisRoundCount, lookaheadDrops, - recentlyHandledDropIds, - totalCount, + recentlyHandledDropIds: isRestartingRound ? [] : recentlyHandledDropIds, + unratedCount: rawUnratedCount, }; }; @@ -208,10 +219,12 @@ export const applyOptimisticAdvanceState = ({ currentDrop, hasDiscoveryError: false, isExhausted: false, + isRestartingRound: false, isLoading: currentDrop === null, + leftThisRoundCount: Math.max(0, current.leftThisRoundCount - 1), lookaheadDrops: nextLookaheadDrops.slice(1), recentlyHandledDropIds, - totalCount: Math.max(0, current.totalCount - 1), + unratedCount: current.unratedCount, }; }; @@ -232,9 +245,13 @@ export const applyFailedDropRestoreState = ({ currentDrop: current.currentDrop ?? failedDrop, hasDiscoveryError: false, isExhausted: false, + isRestartingRound: false, isLoading: false, + leftThisRoundCount: wasHandled + ? current.leftThisRoundCount + 1 + : current.leftThisRoundCount, recentlyHandledDropIds, - totalCount: wasHandled ? current.totalCount + 1 : current.totalCount, + unratedCount: current.unratedCount, }; }; diff --git a/hooks/useMemesQuickVoteDialogController.ts b/hooks/useMemesQuickVoteDialogController.ts index 74f0e1ec99..c146430885 100644 --- a/hooks/useMemesQuickVoteDialogController.ts +++ b/hooks/useMemesQuickVoteDialogController.ts @@ -11,14 +11,16 @@ export type MemesQuickVoteDialogState = Pick< | "activeDrop" | "hasDiscoveryError" | "isExhausted" + | "isRestartingRound" + | "leftThisRoundCount" | "latestUsedAmount" | "nextDrop" | "recentAmounts" - | "remainingCount" | "retryDiscovery" | "submitVote" | "skipDrop" | "uncastPower" + | "unratedCount" | "votingLabel" > & { readonly isOpen: boolean; @@ -95,17 +97,19 @@ export const useMemesQuickVoteDialogController = activeDrop: quickVoteQueue.activeDrop, hasDiscoveryError: quickVoteQueue.hasDiscoveryError, isExhausted: quickVoteQueue.isExhausted, + isRestartingRound: quickVoteQueue.isRestartingRound, isOpen: isQuickVoteOpen, + leftThisRoundCount: quickVoteQueue.leftThisRoundCount, latestUsedAmount: quickVoteQueue.latestUsedAmount, nextDrop: quickVoteQueue.nextDrop, onClose: closeQuickVote, recentAmounts: quickVoteQueue.recentAmounts, - remainingCount: quickVoteQueue.remainingCount, retryDiscovery: quickVoteQueue.retryDiscovery, sessionId: quickVoteSessionId, skipDrop: quickVoteQueue.skipDrop, submitVote: quickVoteQueue.submitVote, uncastPower: quickVoteQueue.uncastPower, + unratedCount: quickVoteQueue.unratedCount, votingLabel: quickVoteQueue.votingLabel, }, isQuickVoteOpen, diff --git a/hooks/useMemesQuickVoteQueue.ts b/hooks/useMemesQuickVoteQueue.ts index e6bc3ec39a..d7314b4fe1 100644 --- a/hooks/useMemesQuickVoteQueue.ts +++ b/hooks/useMemesQuickVoteQueue.ts @@ -55,11 +55,12 @@ export type UseMemesQuickVoteQueueResult = { readonly hasDiscoveryError: boolean; readonly isExhausted: boolean; readonly isLoading: boolean; + readonly isRestartingRound: boolean; readonly isReady: boolean; + readonly leftThisRoundCount: number; readonly latestUsedAmount: number | null; readonly nextDrop: ExtendedDrop | null; readonly recentAmounts: number[]; - readonly remainingCount: number; readonly retryDiscovery: () => void; readonly submitVote: ( drop: ExtendedDrop, @@ -67,6 +68,7 @@ export type UseMemesQuickVoteQueueResult = { ) => Promise; readonly skipDrop: (drop: ExtendedDrop) => Promise; readonly uncastPower: number | null; + readonly unratedCount: number; readonly votingLabel: string | null; }; @@ -114,6 +116,43 @@ const runBestEffortSync = (sync: () => Promise): void => { }); }; +const QUICK_VOTE_RESTART_TIMEOUT_MS = 3000; + +const shouldRerunQuickVoteSync = ({ + abortController, + rerunRequestedRef, +}: { + readonly abortController: AbortController; + readonly rerunRequestedRef: { current: boolean }; +}): boolean => !abortController.signal.aborted && rerunRequestedRef.current; + +const getQuickVoteSyncPendingState = ( + current: MemesQuickVoteSessionState +): MemesQuickVoteSessionState => ({ + ...current, + hasDiscoveryError: false, + isExhausted: false, + isRestartingRound: current.isRestartingRound, + isLoading: current.currentDrop === null, +}); + +const getQuickVoteSyncFailureState = ( + current: MemesQuickVoteSessionState +): MemesQuickVoteSessionState => + current.currentDrop + ? { + ...current, + isRestartingRound: false, + isLoading: false, + } + : { + ...current, + hasDiscoveryError: true, + isExhausted: false, + isRestartingRound: false, + isLoading: false, + }; + const getCurrentSessionState = ({ key, state, @@ -173,7 +212,9 @@ const useMemesQuickVoteWindowSync = ({ waveId, }: UseMemesQuickVoteWindowSyncOptions) => { const syncAbortControllerRef = useRef(null); + const syncInFlightRef = useRef(false); const syncRequestIdRef = useRef(0); + const syncRerunRequestedRef = useRef(false); const syncRetryTimeoutRef = useRef | null>(null); @@ -189,107 +230,121 @@ const useMemesQuickVoteWindowSync = ({ const stopActiveSync = useCallback(() => { syncRequestIdRef.current += 1; + syncInFlightRef.current = false; + syncRerunRequestedRef.current = false; syncAbortControllerRef.current?.abort(); syncAbortControllerRef.current = null; clearSyncRetryTimeout(); }, [clearSyncRetryTimeout]); - const syncUndiscoveredWindow = useCallback(async () => { - if (!enabled) { - return; - } - - if (isSettingsLoaded && !isQuickVoteEnabled) { - setCurrentSessionState({ - ...createInitialSessionState(), - isExhausted: true, - }); - return; - } - - if (!isQuickVoteEnabled || !contextProfile || waveId === null) { - return; - } - - const requestId = syncRequestIdRef.current + 1; - syncRequestIdRef.current = requestId; - syncAbortControllerRef.current?.abort(); - const abortController = new AbortController(); - syncAbortControllerRef.current = abortController; - - setCurrentSessionState((current) => ({ - ...current, - hasDiscoveryError: false, - isExhausted: false, - isLoading: current.currentDrop === null, - })); - - try { - const responses = await Promise.all( - Array.from({ length: MEMES_QUICK_VOTE_LOOKAHEAD_COUNT }, (_, skip) => - fetchMemesQuickVoteUndiscoveredDrop({ - signal: abortController.signal, - skip, - waveId, - }) - ) - ); + const syncUndiscoveredWindow = useCallback( + async function syncWindow() { + if (syncInFlightRef.current) { + syncRerunRequestedRef.current = true; + return; + } - if ( - abortController.signal.aborted || - syncRequestIdRef.current !== requestId - ) { + if (!enabled) { return; } - const primaryDrop = responses[0]?.drop ?? null; - const rawTotalCount = - responses.find((response) => typeof response.total_count === "number") - ?.total_count ?? 0; - const fetchedDrops = getUniqueDrops( - responses.flatMap((response) => (response.drop ? [response.drop] : [])) - ); + if (isSettingsLoaded && !isQuickVoteEnabled) { + setCurrentSessionState({ + ...createInitialSessionState(), + isExhausted: true, + }); + return; + } - setCurrentSessionState((current) => - applyFetchedWindowState({ - current, - fetchedDrops, - pendingDropIds: pendingDropIdsRef.current, - primaryDrop, - rawTotalCount, - }) - ); - } catch { - if ( - abortController.signal.aborted || - syncRequestIdRef.current !== requestId - ) { + if (!isQuickVoteEnabled || !contextProfile || waveId === null) { return; } - setCurrentSessionState((current) => - current.currentDrop - ? { - ...current, - isLoading: false, - } - : { - ...current, - hasDiscoveryError: true, - isExhausted: false, - isLoading: false, - } - ); - } - }, [ - contextProfile, - enabled, - isQuickVoteEnabled, - isSettingsLoaded, - pendingDropIdsRef, - setCurrentSessionState, - waveId, - ]); + syncInFlightRef.current = true; + syncRerunRequestedRef.current = false; + const requestId = syncRequestIdRef.current + 1; + syncRequestIdRef.current = requestId; + const abortController = new AbortController(); + syncAbortControllerRef.current = abortController; + + setCurrentSessionState(getQuickVoteSyncPendingState); + + try { + const responses = await Promise.all( + Array.from({ length: MEMES_QUICK_VOTE_LOOKAHEAD_COUNT }, (_, skip) => + fetchMemesQuickVoteUndiscoveredDrop({ + signal: abortController.signal, + skip, + waveId, + }) + ) + ); + + if ( + abortController.signal.aborted || + syncRequestIdRef.current !== requestId + ) { + return; + } + + const primaryResponse = responses[0] ?? null; + const primaryDrop = primaryResponse?.drop ?? null; + const rawLeftThisRoundCount = + primaryResponse?.left_to_vote_in_current_round ?? 0; + const rawUnratedCount = primaryResponse?.total_count ?? 0; + const fetchedDrops = getUniqueDrops( + responses.flatMap((response) => + response.drop ? [response.drop] : [] + ) + ); + + setCurrentSessionState((current) => + applyFetchedWindowState({ + current, + fetchedDrops, + pendingDropIds: pendingDropIdsRef.current, + primaryDrop, + rawLeftThisRoundCount, + rawUnratedCount, + }) + ); + } catch { + if ( + abortController.signal.aborted || + syncRequestIdRef.current !== requestId + ) { + return; + } + + setCurrentSessionState(getQuickVoteSyncFailureState); + } finally { + if (syncAbortControllerRef.current === abortController) { + syncAbortControllerRef.current = null; + } + + syncInFlightRef.current = false; + + if ( + shouldRerunQuickVoteSync({ + abortController, + rerunRequestedRef: syncRerunRequestedRef, + }) + ) { + syncRerunRequestedRef.current = false; + runBestEffortSync(syncWindow); + } + } + }, + [ + contextProfile, + enabled, + isQuickVoteEnabled, + isSettingsLoaded, + pendingDropIdsRef, + setCurrentSessionState, + waveId, + ] + ); return { clearSyncRetryTimeout, @@ -304,6 +359,7 @@ const useMemesQuickVoteSessionEffects = ({ enabled, isQuickVoteEnabled, pendingDropIdsRef, + setCurrentSessionState, stateKey, stopActiveSync, syncUndiscoveredWindow, @@ -314,6 +370,7 @@ const useMemesQuickVoteSessionEffects = ({ readonly enabled: boolean; readonly isQuickVoteEnabled: boolean; readonly pendingDropIdsRef: { current: readonly string[] }; + readonly setCurrentSessionState: UseKeyedMemesQuickVoteSessionStoreResult["setCurrentSessionState"]; readonly stateKey: string; readonly stopActiveSync: () => void; readonly syncUndiscoveredWindow: () => Promise; @@ -347,7 +404,6 @@ const useMemesQuickVoteSessionEffects = ({ useEffect(() => { if ( !enabled || - currentSessionState.isLoading || currentSessionState.hasDiscoveryError || currentSessionState.isExhausted ) { @@ -355,6 +411,7 @@ const useMemesQuickVoteSessionEffects = ({ } const shouldContinueSync = + currentSessionState.isRestartingRound || currentSessionState.recentlyHandledDropIds.length > 0 || (pendingDropIdsRef.current.length > 0 && currentSessionState.currentDrop === null); @@ -375,12 +432,44 @@ const useMemesQuickVoteSessionEffects = ({ currentSessionState.hasDiscoveryError, currentSessionState.isExhausted, currentSessionState.isLoading, + currentSessionState.isRestartingRound, currentSessionState.recentlyHandledDropIds.length, enabled, pendingDropIdsRef, syncUndiscoveredWindow, ]); + useEffect(() => { + if (!enabled || !currentSessionState.isRestartingRound) { + return; + } + + const timeoutId = globalThis.setTimeout(() => { + stopActiveSync(); + setCurrentSessionState((current) => { + if (!current.isRestartingRound) { + return current; + } + + return { + ...current, + hasDiscoveryError: true, + isLoading: false, + isRestartingRound: false, + }; + }); + }, QUICK_VOTE_RESTART_TIMEOUT_MS); + + return () => { + globalThis.clearTimeout(timeoutId); + }; + }, [ + currentSessionState.isRestartingRound, + enabled, + setCurrentSessionState, + stopActiveSync, + ]); + useEffect( () => () => { stopActiveSync(); @@ -417,6 +506,7 @@ const useMemesQuickVoteSessionState = ({ enabled, isQuickVoteEnabled, pendingDropIdsRef, + setCurrentSessionState, stateKey, stopActiveSync, syncUndiscoveredWindow, @@ -681,17 +771,19 @@ export const useMemesQuickVoteQueue = ({ hasDiscoveryError: session.sessionState.hasDiscoveryError, isExhausted: session.sessionState.isExhausted, isLoading: session.sessionState.isLoading && activeDrop === null, + isRestartingRound: session.sessionState.isRestartingRound, isReady: activeDrop !== null, + leftThisRoundCount: session.sessionState.leftThisRoundCount, latestUsedAmount, nextDrop, recentAmounts, - remainingCount: session.sessionState.totalCount, retryDiscovery: () => { runBestEffortSync(session.syncUndiscoveredWindow); }, skipDrop: submitSkip, submitVote, uncastPower: activeDrop?.context_profile_context?.max_rating ?? null, + unratedCount: session.sessionState.unratedCount, votingLabel: activeDrop ? WAVE_VOTING_LABELS[activeDrop.wave.voting_credit_type] : null, diff --git a/hooks/useMemesWaveFooterStats.ts b/hooks/useMemesWaveFooterStats.ts index 5589a4b357..92057a4187 100644 --- a/hooks/useMemesWaveFooterStats.ts +++ b/hooks/useMemesWaveFooterStats.ts @@ -21,6 +21,7 @@ type MemesWaveFooterStats = MemesQuickVoteStats & { const EMPTY_STATS: MemesWaveFooterStats = { isAvailable: false, isReady: false, + leftThisRoundCount: 0, uncastPower: null, unratedCount: 0, votingLabel: null, @@ -74,6 +75,7 @@ export const useMemesWaveFooterStats = (): MemesWaveFooterStats => { return { isAvailable, + leftThisRoundCount: query.data.left_to_vote_in_current_round, uncastPower, unratedCount: query.data.total_count, votingLabel: WAVE_VOTING_LABELS[activeDrop.wave.voting_credit_type], diff --git a/openapi.yaml b/openapi.yaml index 0360cf4ea9..fe673bd9b0 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -10956,12 +10956,16 @@ components: type: object required: - drop + - left_to_vote_in_current_round - total_count properties: drop: anyOf: - type: "null" - $ref: "#/components/schemas/ApiDrop" + left_to_vote_in_current_round: + type: integer + format: int64 total_count: type: integer format: int64 @@ -11104,10 +11108,6 @@ components: visibility: $ref: "#/components/schemas/ApiCreateNewWaveVisibilityConfig" participation: - description: >- - Wave participation configuration. `submission_strategy` is immutable - after creation and, if provided on update, must match the existing - value exactly. $ref: "#/components/schemas/ApiUpdateWaveParticipationConfig" chat: $ref: "#/components/schemas/ApiCreateNewWaveChatConfig"