diff --git a/__tests__/components/auth/Auth.test.tsx b/__tests__/components/auth/Auth.test.tsx index 0c20b5d203..f513fec74c 100644 --- a/__tests__/components/auth/Auth.test.tsx +++ b/__tests__/components/auth/Auth.test.tsx @@ -28,6 +28,21 @@ jest.mock("@/services/api/common-api", () => ({ jest.mock("@/services/auth/auth.utils", () => ({ canStoreAnotherWalletAccount: jest.fn(() => true), getWalletAddress: jest.fn(() => null), + isAuthAddressAuthorized: jest.fn( + ({ + address, + connectedAccounts, + }: { + readonly address: string | null | undefined; + readonly connectedAccounts: readonly { readonly address: string }[]; + }) => + Boolean( + address && + connectedAccounts.some( + (account) => account.address.toLowerCase() === address.toLowerCase() + ) + ) + ), removeAuthJwt: jest.fn(), setActiveWalletAccount: jest.fn(() => true), setAuthJwt: jest.fn(), @@ -119,6 +134,14 @@ mockTitleContextModule(); let walletAddress: string | null = "0x1"; let connectionState: string = "connected"; +let connectedAccountsOverride: + | readonly { + readonly address: string; + readonly role: string | null; + readonly isActive: boolean; + readonly isConnected: boolean; + }[] + | null = null; const mockSeizeDisconnectAndLogout = jest.fn(() => Promise.resolve()); const mockSeizeDisconnect = jest.fn(() => Promise.resolve()); @@ -126,16 +149,18 @@ const mockSeizeDisconnect = jest.fn(() => Promise.resolve()); jest.mock("@/components/auth/SeizeConnectContext", () => ({ useSeizeConnectContext: jest.fn(() => ({ address: walletAddress, - connectedAccounts: walletAddress - ? [ - { - address: walletAddress, - role: null, - isActive: true, - isConnected: !!walletAddress, - }, - ] - : [], + connectedAccounts: + connectedAccountsOverride ?? + (walletAddress + ? [ + { + address: walletAddress, + role: null, + isActive: true, + isConnected: !!walletAddress, + }, + ] + : []), isConnected: !!walletAddress, seizeDisconnect: mockSeizeDisconnect, seizeDisconnectAndLogout: mockSeizeDisconnectAndLogout, @@ -172,8 +197,27 @@ describe("Auth component", () => { beforeEach(() => { walletAddress = "0x1"; connectionState = "connected"; + connectedAccountsOverride = null; jest.clearAllMocks(); + const mockIsAuthAddressAuthorized = require("@/services/auth/auth.utils") + .isAuthAddressAuthorized as jest.MockedFunction; + mockIsAuthAddressAuthorized.mockImplementation( + ({ + address, + connectedAccounts, + }: { + readonly address: string | null | undefined; + readonly connectedAccounts: readonly { readonly address: string }[]; + }) => + Boolean( + address && + connectedAccounts.some( + (account) => account.address.toLowerCase() === address.toLowerCase() + ) + ) + ); + // Reset mock implementations mockSignMessage.mockResolvedValue({ signature: "0xsignature", @@ -846,6 +890,66 @@ describe("Auth component", () => { expect.objectContaining({ type: "error" }) ); }); + + it("treats dev-auth sessions as authorized even without stored connected accounts", async () => { + connectedAccountsOverride = []; + + const mockGetAuthJwt = require("@/services/auth/auth.utils") + .getAuthJwt as jest.MockedFunction; + const mockGetWalletAddress = require("@/services/auth/auth.utils") + .getWalletAddress as jest.MockedFunction; + const mockIsAuthAddressAuthorized = require("@/services/auth/auth.utils") + .isAuthAddressAuthorized as jest.MockedFunction; + + mockGetAuthJwt.mockReturnValue("dev-jwt"); + mockGetWalletAddress.mockReturnValue(walletAddress); + mockIsAuthAddressAuthorized.mockReturnValue(true); + + const Child = () => { + const { requestAuth } = React.useContext(AuthContext); + const [result, setResult] = React.useState("pending"); + + return ( + <> + + {result} + + ); + }; + + render( + + + + + + ); + + const user = userEvent.setup(); + await user.click(screen.getByTestId("test-dev-auth")); + + await waitFor(() => { + expect(screen.getByTestId("test-dev-auth-result")).toHaveTextContent( + "true" + ); + }); + + expect(mockCommonApiPost).not.toHaveBeenCalled(); + expect(mockSignMessage).not.toHaveBeenCalled(); + expect( + screen.queryByText("Sign Authentication Request") + ).not.toBeInTheDocument(); + }); }); describe("Toast Functionality", () => { diff --git a/__tests__/components/brain/BrainMobile.test.tsx b/__tests__/components/brain/BrainMobile.test.tsx index d322585d6d..6c1402f5dc 100644 --- a/__tests__/components/brain/BrainMobile.test.tsx +++ b/__tests__/components/brain/BrainMobile.test.tsx @@ -1,5 +1,12 @@ -import { act, render, screen, waitFor } from "@testing-library/react"; -import BrainMobile, { BrainView } from "@/components/brain/BrainMobile"; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import BrainMobile from "@/components/brain/BrainMobile"; +import { BrainView } from "@/components/brain/mobile/brainMobileViews"; jest.mock("next/image", () => ({ __esModule: true, @@ -21,9 +28,39 @@ jest.mock("@/hooks/useDeviceInfo", () => ({ __esModule: true, default: () => ({ isApp }), })); +jest.mock("@/hooks/useMemesQuickVoteDialogController", () => ({ + useMemesQuickVoteDialogController: () => { + const React = require("react"); + const [isQuickVoteOpen, setIsQuickVoteOpen] = React.useState(false); + const [quickVoteSessionId, setQuickVoteSessionId] = React.useState(0); + const nextSessionIdRef = React.useRef(1); + const reservedSessionIdRef = React.useRef(null as number | null); + + return { + closeQuickVote: () => setIsQuickVoteOpen(false), + isQuickVoteOpen, + openQuickVote: () => { + const sessionId = + reservedSessionIdRef.current ?? nextSessionIdRef.current; + reservedSessionIdRef.current = null; + nextSessionIdRef.current = sessionId + 1; + setQuickVoteSessionId(sessionId); + setIsQuickVoteOpen(true); + }, + prefetchQuickVote: () => { + if (reservedSessionIdRef.current === null) { + reservedSessionIdRef.current = nextSessionIdRef.current; + } + }, + quickVoteSessionId, + }; + }, +})); let dropData: any = null; let waveData: any = null; +let mockIsCompleted = false; +let mockFirstDecisionDone = true; jest.mock("@tanstack/react-query", () => ({ keepPreviousData: {}, @@ -39,10 +76,15 @@ jest.mock("@/hooks/useWave", () => ({ useWave: (...args: any[]) => mockUseWave(...args), })); +const mockUseMemesWaveFooterStats = jest.fn(); +jest.mock("@/hooks/useMemesWaveFooterStats", () => ({ + useMemesWaveFooterStats: () => mockUseMemesWaveFooterStats(), +})); + jest.mock("@/hooks/useWaveTimers", () => ({ useWaveTimers: () => ({ - voting: { isCompleted: false }, - decisions: { firstDecisionDone: true }, + voting: { isCompleted: mockIsCompleted }, + decisions: { firstDecisionDone: mockFirstDecisionDone }, }), })); @@ -69,9 +111,18 @@ jest.mock("@/components/brain/mobile/BrainMobileAbout", () => ({ default: () =>
, })); +const mockBrainMobileWaves = jest.fn( + ({ onOpenQuickVote }: { readonly onOpenQuickVote: () => void }) => ( +
+ +
+ ) +); jest.mock("@/components/brain/mobile/BrainMobileWaves", () => ({ __esModule: true, - default: () =>
, + default: (props: any) => mockBrainMobileWaves(props), })); jest.mock("@/components/brain/mobile/BrainMobileMessages", () => ({ @@ -79,7 +130,61 @@ jest.mock("@/components/brain/mobile/BrainMobileMessages", () => ({ default: () =>
, })); -jest.mock("@/components/brain/notifications", () => ({ +jest.mock( + "@/components/brain/left-sidebar/waves/MemesWaveQuickVoteTrigger", + () => ({ + __esModule: true, + default: ({ + onOpenQuickVote, + unratedCount, + }: { + readonly onOpenQuickVote: () => void; + readonly unratedCount: number; + }) => ( + + ), + }) +); + +let mockDialogMountCount = 0; +jest.mock( + "@/components/brain/left-sidebar/waves/memes-quick-vote/MemesQuickVoteDialog", + () => ({ + __esModule: true, + default: ({ + isOpen, + onClose, + sessionId, + }: { + readonly isOpen: boolean; + readonly sessionId: number; + readonly onClose: () => void; + }) => { + const React = require("react"); + + React.useEffect(() => { + mockDialogMountCount += 1; + }, []); + + return isOpen ? ( +
+
Session {sessionId}
+ +
+ ) : null; + }, + }) +); + +jest.mock("@/components/brain/notifications/NotificationsContainer", () => ({ __esModule: true, default: () =>
, })); @@ -118,6 +223,18 @@ jest.mock("@/components/brain/my-stream/MyStreamWaveFAQ", () => ({ // Tests describe("BrainMobile", () => { + const createWave = (isDirectMessage = false) => + ({ + id: "1", + chat: { + scope: { + group: { + is_direct_message: isDirectMessage, + }, + }, + }, + }) as any; + beforeEach(() => { jest.clearAllMocks(); mockSearchParams = new URLSearchParams(); @@ -126,11 +243,21 @@ describe("BrainMobile", () => { dropData = null; waveData = null; isApp = true; + mockIsCompleted = false; + mockFirstDecisionDone = true; latestTabsProps = null; - mockUseWave.mockReturnValue({ + mockDialogMountCount = 0; + mockUseWave.mockImplementation((incomingWave?: any) => ({ isMemesWave: false, isCurationWave: false, isRankWave: true, + isDm: incomingWave?.chat?.scope?.group?.is_direct_message ?? false, + })); + mockUseMemesWaveFooterStats.mockReturnValue({ + isReady: true, + uncastPower: 5000, + unratedCount: 3, + votingLabel: "TDH", }); }); @@ -147,12 +274,15 @@ describe("BrainMobile", () => { await waitFor(() => { expect(screen.getByTestId("notifications")).toBeInTheDocument(); }); + expect(mockDialogMountCount).toBe(0); }); it("shows tabs only when wave active or not in app", async () => { isApp = true; render(child); expect(screen.queryByTestId("tabs")).toBeNull(); + expect(mockUseMemesWaveFooterStats).not.toHaveBeenCalled(); + expect(mockDialogMountCount).toBe(0); mockSearchParams.set("wave", "1"); const { rerender } = render(child); @@ -160,6 +290,244 @@ describe("BrainMobile", () => { rerender(
); }); + it.each([ + { pathname: "/messages", testId: "messages" }, + { pathname: "/notifications", testId: "notifications" }, + ])( + "does not load quick-vote stats on the $pathname shell", + async ({ pathname, testId }) => { + mockPathname = pathname; + + render(child); + + await waitFor(() => { + expect(screen.getByTestId(testId)).toBeInTheDocument(); + }); + + expect(mockUseMemesWaveFooterStats).not.toHaveBeenCalled(); + expect(mockDialogMountCount).toBe(0); + } + ); + + it("shows the floating quick-vote trigger for non-DM wave chat in app", async () => { + mockSearchParams.set("wave", "1"); + waveData = createWave(false); + + render(child); + + await waitFor(() => { + expect(screen.getByTestId("quick-vote-trigger")).toBeInTheDocument(); + }); + + expect(mockUseMemesWaveFooterStats).toHaveBeenCalled(); + expect(mockDialogMountCount).toBe(1); + }); + + it("keeps the floating quick-vote trigger inside the flex pane wrapper", async () => { + mockSearchParams.set("wave", "1"); + waveData = createWave(false); + + const { container } = render(child); + + await waitFor(() => { + expect(screen.getByTestId("quick-vote-trigger")).toBeInTheDocument(); + }); + + const activePane = container.querySelector( + ".tw-relative.tw-min-w-0.tw-flex-1" + ); + + expect(activePane).not.toBeNull(); + expect(activePane).toContainElement( + screen.getByTestId("quick-vote-trigger") + ); + }); + + it("hides the floating quick-vote trigger for DM wave chat", async () => { + mockSearchParams.set("wave", "1"); + waveData = createWave(true); + + render(child); + + await waitFor(() => { + expect(screen.queryByTestId("quick-vote-trigger")).toBeNull(); + }); + + expect(mockUseMemesWaveFooterStats).not.toHaveBeenCalled(); + expect(mockDialogMountCount).toBe(0); + }); + + it("mounts the quick-vote dialog owner on the waves shell", async () => { + mockPathname = "/waves"; + + render(child); + + await waitFor(() => { + expect(screen.getByTestId("waves")).toBeInTheDocument(); + }); + + expect(mockDialogMountCount).toBe(1); + expect(screen.queryByTestId("quick-vote-dialog")).toBeNull(); + }); + + it("hides the floating quick-vote trigger when leaving chat view", async () => { + mockSearchParams.set("wave", "1"); + waveData = createWave(false); + + render(child); + + await waitFor(() => { + expect(screen.getByTestId("quick-vote-trigger")).toBeInTheDocument(); + expect(screen.getByTestId("tabs")).toBeInTheDocument(); + }); + + act(() => { + latestTabsProps.onViewChange(BrainView.ABOUT); + }); + + await waitFor(() => { + expect(screen.queryByTestId("quick-vote-trigger")).toBeNull(); + expect(screen.getByTestId("about")).toBeInTheDocument(); + }); + }); + + it("drops a stale local tab selection when navigation context changes", async () => { + mockSearchParams.set("wave", "1"); + waveData = createWave(false); + + const { rerender } = render(child); + + await waitFor(() => expect(screen.getByTestId("tabs")).toBeInTheDocument()); + + act(() => { + latestTabsProps.onViewChange(BrainView.ABOUT); + }); + + await waitFor(() => { + expect(screen.getByTestId("about")).toBeInTheDocument(); + }); + + mockSearchParams.delete("wave"); + mockPathname = "/messages"; + waveData = null; + rerender(child); + + await waitFor(() => { + expect(screen.getByTestId("messages")).toBeInTheDocument(); + expect(screen.queryByTestId("about")).toBeNull(); + }); + }); + + it("does not resurrect a stale tab selection when revisiting a wave", async () => { + mockSearchParams.set("wave", "1"); + waveData = createWave(false); + + const { rerender } = render(child); + + await waitFor(() => expect(screen.getByTestId("tabs")).toBeInTheDocument()); + + act(() => { + latestTabsProps.onViewChange(BrainView.ABOUT); + }); + + await waitFor(() => { + expect(screen.getByTestId("about")).toBeInTheDocument(); + }); + + mockSearchParams.set("wave", "2"); + waveData = { ...createWave(false), id: "2" }; + rerender(child); + + await waitFor(() => { + expect(screen.queryByTestId("about")).toBeNull(); + expect(screen.getByText("child")).toBeInTheDocument(); + }); + + mockSearchParams.set("wave", "1"); + waveData = createWave(false); + rerender(child); + + await waitFor(() => { + expect(screen.queryByTestId("about")).toBeNull(); + expect(screen.getByText("child")).toBeInTheDocument(); + }); + }); + + it("keeps the selected shell tab when web create modal query changes", async () => { + isApp = false; + mockPathname = "/waves"; + + const { rerender } = render(child); + + await waitFor(() => { + expect(screen.getByTestId("waves")).toBeInTheDocument(); + expect(screen.getByTestId("tabs")).toBeInTheDocument(); + }); + + act(() => { + latestTabsProps.onViewChange(BrainView.MESSAGES); + }); + + await waitFor(() => { + expect(screen.getByTestId("messages")).toBeInTheDocument(); + expect(screen.queryByTestId("waves")).toBeNull(); + }); + + mockSearchParams.set("create", "wave"); + rerender(child); + + await waitFor(() => { + expect(screen.getByTestId("messages")).toBeInTheDocument(); + expect(screen.queryByTestId("waves")).toBeNull(); + }); + + mockSearchParams.delete("create"); + rerender(child); + + await waitFor(() => { + expect(screen.getByTestId("messages")).toBeInTheDocument(); + expect(screen.queryByTestId("waves")).toBeNull(); + }); + }); + + it("reuses the page-owned quick-vote dialog across floating and waves entry points", async () => { + mockSearchParams.set("wave", "1"); + waveData = createWave(false); + + const { rerender } = render(child); + + await waitFor(() => { + expect(screen.getByTestId("quick-vote-trigger")).toBeInTheDocument(); + }); + + expect(mockDialogMountCount).toBe(1); + + fireEvent.click(screen.getByTestId("quick-vote-trigger")); + expect(screen.getByText("Session 1")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Close Quick Vote" })); + expect(screen.queryByTestId("quick-vote-dialog")).not.toBeInTheDocument(); + expect(mockDialogMountCount).toBe(1); + + mockSearchParams.delete("wave"); + mockPathname = "/waves"; + waveData = null; + rerender(child); + + await waitFor(() => { + expect(screen.getByTestId("waves")).toBeInTheDocument(); + }); + + fireEvent.click( + screen.getByRole("button", { + name: "Open quick vote from waves footer", + }) + ); + + expect(screen.getByText("Session 2")).toBeInTheDocument(); + expect(mockDialogMountCount).toBe(1); + }); + it("renders the Sales view for non-rank curation waves and falls back when unavailable", async () => { mockSearchParams.set("wave", "1"); waveData = { id: "1", wave: { type: "APPROVE" } }; @@ -167,6 +535,7 @@ describe("BrainMobile", () => { isMemesWave: false, isCurationWave: true, isRankWave: false, + isDm: false, }); const { rerender } = render(child); @@ -177,13 +546,16 @@ describe("BrainMobile", () => { latestTabsProps.onViewChange(BrainView.SALES); }); - expect(screen.getByTestId("sales")).toBeInTheDocument(); + await waitFor(() => + expect(screen.getByTestId("sales")).toBeInTheDocument() + ); expect(mockMyStreamWaveSales).toHaveBeenCalledWith({ waveId: "1" }); mockUseWave.mockReturnValue({ isMemesWave: false, isCurationWave: false, isRankWave: false, + isDm: false, }); rerender(child); @@ -192,4 +564,60 @@ describe("BrainMobile", () => { expect(screen.getByText("child")).toBeInTheDocument(); }); }); + + it("falls back to default when a rank-only view becomes unavailable", async () => { + mockSearchParams.set("wave", "1"); + waveData = createWave(false); + + const { rerender } = render(child); + + await waitFor(() => expect(screen.getByTestId("tabs")).toBeInTheDocument()); + + act(() => { + latestTabsProps.onViewChange(BrainView.OUTCOME); + }); + + await waitFor(() => + expect(screen.getByTestId("outcome")).toBeInTheDocument() + ); + + mockUseWave.mockReturnValue({ + isMemesWave: false, + isCurationWave: false, + isRankWave: false, + isDm: false, + }); + rerender(child); + + await waitFor(() => { + expect(screen.queryByTestId("outcome")).toBeNull(); + expect(screen.getByText("child")).toBeInTheDocument(); + }); + }); + + it("falls back to default when winners becomes unavailable", async () => { + mockSearchParams.set("wave", "1"); + waveData = createWave(false); + mockFirstDecisionDone = true; + + const { rerender } = render(child); + + await waitFor(() => expect(screen.getByTestId("tabs")).toBeInTheDocument()); + + act(() => { + latestTabsProps.onViewChange(BrainView.WINNERS); + }); + + await waitFor(() => + expect(screen.getByTestId("winners")).toBeInTheDocument() + ); + + mockFirstDecisionDone = false; + rerender(child); + + await waitFor(() => { + expect(screen.queryByTestId("winners")).toBeNull(); + expect(screen.getByText("child")).toBeInTheDocument(); + }); + }); }); diff --git a/__tests__/components/brain/left-sidebar/waves/MemesWaveFooter.test.tsx b/__tests__/components/brain/left-sidebar/waves/MemesWaveFooter.test.tsx new file mode 100644 index 0000000000..27a7cefecc --- /dev/null +++ b/__tests__/components/brain/left-sidebar/waves/MemesWaveFooter.test.tsx @@ -0,0 +1,193 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import React from "react"; +import MemesWaveFooter from "@/components/brain/left-sidebar/waves/MemesWaveFooter"; +import { useMemesWaveFooterStats } from "@/hooks/useMemesWaveFooterStats"; + +jest.mock("@/hooks/useMemesWaveFooterStats", () => ({ + useMemesWaveFooterStats: jest.fn(), +})); + +const useMemesWaveFooterStatsMock = + useMemesWaveFooterStats as jest.MockedFunction< + typeof useMemesWaveFooterStats + >; + +describe("MemesWaveFooter", () => { + const onOpenQuickVote = jest.fn(); + const onPrefetchQuickVote = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + useMemesWaveFooterStatsMock.mockReturnValue({ + uncastPower: null, + unratedCount: 0, + votingLabel: null, + isReady: false, + }); + }); + + it("stays hidden until stats are ready", () => { + render(); + + expect(screen.queryByText("Uncast Power")).not.toBeInTheDocument(); + }); + + it("renders the expanded footer card", () => { + useMemesWaveFooterStatsMock.mockReturnValue({ + uncastPower: 5000, + unratedCount: 3, + votingLabel: "TDH", + isReady: true, + }); + + render(); + + expect(screen.getByText("Uncast Power")).toBeInTheDocument(); + expect(screen.getByText("5,000 TDH")).toBeInTheDocument(); + expect(screen.getByText("3 left")).toBeInTheDocument(); + }); + + it("calls onOpenQuickVote from the expanded card", () => { + useMemesWaveFooterStatsMock.mockReturnValue({ + uncastPower: 5000, + unratedCount: 3, + votingLabel: "TDH", + isReady: true, + }); + + render( + + ); + + fireEvent.click( + screen.getByRole("button", { + name: "Uncast Power, 5,000 TDH, 3 left", + }) + ); + + expect(onOpenQuickVote).toHaveBeenCalledTimes(1); + }); + + it("prefetches quick vote from the expanded card on hover and focus", () => { + useMemesWaveFooterStatsMock.mockReturnValue({ + uncastPower: 5000, + unratedCount: 3, + votingLabel: "TDH", + isReady: true, + }); + + render( + + ); + + const button = screen.getByRole("button", { + name: "Uncast Power, 5,000 TDH, 3 left", + }); + + fireEvent.mouseEnter(button); + fireEvent.focus(button); + + expect(onPrefetchQuickVote).toHaveBeenCalledTimes(2); + }); + + it("renders the compact collapsed pill", () => { + useMemesWaveFooterStatsMock.mockReturnValue({ + uncastPower: 5000, + unratedCount: 4, + votingLabel: "TDH", + isReady: true, + }); + + render( + + ); + + expect(screen.queryByText("Uncast Power")).not.toBeInTheDocument(); + expect( + screen.getByRole("button", { + name: "4 submissions left unrated in memes wave", + }) + ).toBeInTheDocument(); + }); + + it("calls onOpenQuickVote from the collapsed pill", () => { + useMemesWaveFooterStatsMock.mockReturnValue({ + uncastPower: 5000, + unratedCount: 4, + votingLabel: "TDH", + isReady: true, + }); + + render(); + + fireEvent.click( + screen.getByRole("button", { + name: "4 submissions left unrated in memes wave", + }) + ); + + expect(onOpenQuickVote).toHaveBeenCalledTimes(1); + }); + + it("prefetches quick vote from the collapsed pill on hover and focus", () => { + useMemesWaveFooterStatsMock.mockReturnValue({ + uncastPower: 5000, + unratedCount: 4, + votingLabel: "TDH", + isReady: true, + }); + + render( + + ); + + const button = screen.getByRole("button", { + name: "4 submissions left unrated in memes wave", + }); + + fireEvent.mouseEnter(button); + fireEvent.focus(button); + + expect(onPrefetchQuickVote).toHaveBeenCalledTimes(2); + }); + + it("ignores expanded-card clicks when no submissions remain", () => { + useMemesWaveFooterStatsMock.mockReturnValue({ + uncastPower: 5000, + unratedCount: 0, + votingLabel: "TDH", + isReady: true, + }); + + render( + + ); + + 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(); + }); +}); diff --git a/__tests__/components/brain/left-sidebar/waves/UnifiedWavesList.test.tsx b/__tests__/components/brain/left-sidebar/waves/UnifiedWavesList.test.tsx index d825a2d3cd..424ffd2be4 100644 --- a/__tests__/components/brain/left-sidebar/waves/UnifiedWavesList.test.tsx +++ b/__tests__/components/brain/left-sidebar/waves/UnifiedWavesList.test.tsx @@ -1,30 +1,46 @@ -import { act, render, screen } from '@testing-library/react'; -import React from 'react'; -import UnifiedWavesList from '@/components/brain/left-sidebar/waves/UnifiedWavesList'; -import useDeviceInfo from '@/hooks/useDeviceInfo'; -import { createMockMinimalWave } from '@/__tests__/utils/mockFactories'; +import { act, render, screen } from "@testing-library/react"; +import React from "react"; +import UnifiedWavesList from "@/components/brain/left-sidebar/waves/UnifiedWavesList"; +import useDeviceInfo from "@/hooks/useDeviceInfo"; +import { createMockMinimalWave } from "@/__tests__/utils/mockFactories"; -jest.mock('@/hooks/useDeviceInfo'); -jest.mock('@/components/brain/left-sidebar/waves/UnifiedWavesListLoader', () => ({ - UnifiedWavesListLoader: ({ isFetchingNextPage }: any) =>
{String(isFetchingNextPage)}
-})); -jest.mock('@/components/brain/left-sidebar/waves/UnifiedWavesListEmpty', () => ({ - __esModule: true, - default: ({ sortedWaves }: any) =>
{sortedWaves.length}
-})); +jest.mock("@/hooks/useDeviceInfo"); +jest.mock( + "@/components/brain/left-sidebar/waves/UnifiedWavesListLoader", + () => ({ + UnifiedWavesListLoader: ({ isFetchingNextPage }: any) => ( +
{String(isFetchingNextPage)}
+ ), + }) +); +jest.mock( + "@/components/brain/left-sidebar/waves/UnifiedWavesListEmpty", + () => ({ + __esModule: true, + default: ({ sortedWaves }: any) => ( +
{sortedWaves.length}
+ ), + }) +); let sentinel: HTMLElement | null = null; -jest.mock('@/components/brain/left-sidebar/waves/UnifiedWavesListWaves', () => { +jest.mock("@/components/brain/left-sidebar/waves/UnifiedWavesListWaves", () => { return { __esModule: true, - default: React.forwardRef((props: any, ref: any) => { + default: React.forwardRef((_: any, ref: any) => { const sentinelRef = React.useRef(null); const containerRef = React.useRef(null); React.useImperativeHandle(ref, () => ({ sentinelRef, containerRef })); - React.useEffect(() => { sentinel = sentinelRef.current; }, []); - return
; - }) + React.useEffect(() => { + sentinel = sentinelRef.current; + }, []); + return ( +
+
+
+ ); + }), }; }); @@ -34,7 +50,9 @@ type DeviceInfo = { hasTouchScreen: boolean; isAppleMobile: boolean; }; -const useDeviceInfoMock = useDeviceInfo as jest.MockedFunction; +const useDeviceInfoMock = useDeviceInfo as jest.MockedFunction< + typeof useDeviceInfo +>; beforeEach(() => { sentinel = null; @@ -50,8 +68,8 @@ afterEach(() => { jest.resetAllMocks(); }); -describe('UnifiedWavesList', () => { - it('shows create link when not in app', () => { +describe("UnifiedWavesList", () => { + it("shows create link when not in app", () => { render( { scrollContainerRef={React.createRef()} /> ); - expect(screen.getByText('Create Wave')).toBeInTheDocument(); - expect(screen.getByTestId('loader')).toHaveTextContent('false'); + expect(screen.getByText("Create Wave")).toBeInTheDocument(); + expect(screen.getByTestId("loader")).toHaveTextContent("false"); }); - it('triggers fetchNextPage when sentinel intersects', () => { + it("triggers fetchNextPage when sentinel intersects", () => { useDeviceInfoMock.mockReturnValue({ isApp: true, isMobileDevice: false, @@ -78,14 +96,17 @@ describe('UnifiedWavesList', () => { const observerInstances: any[] = []; (global as any).IntersectionObserver = class { callback: any; - constructor(cb: any) { this.callback = cb; observerInstances.push(this); } + constructor(cb: any) { + this.callback = cb; + observerInstances.push(this); + } observe() {} disconnect() {} }; render( ({ + useMemesQuickVoteQueue: jest.fn(), +})); + +jest.mock("@/hooks/isMobileScreen", () => jest.fn()); + +jest.mock("@/components/waves/drops/WaveDropAuthorPfp", () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock("@/components/waves/drops/time/WaveDropTime", () => ({ + __esModule: true, + default: () => just now, +})); + +jest.mock( + "@/components/drops/view/item/content/media/DropListItemContentMedia", + () => ({ + __esModule: true, + default: () =>
, + }) +); + +const useMemesQuickVoteQueueMock = + useMemesQuickVoteQueue as jest.MockedFunction; +const useIsMobileScreenMock = useIsMobileScreen as jest.MockedFunction< + typeof useIsMobileScreen +>; + +const createDrop = (serialNo: number) => + ({ + id: `drop-${serialNo}`, + serial_no: serialNo, + created_at: new Date(serialNo * 1_000).toISOString(), + context_profile_context: { + rating: 0, + max_rating: 5_000, + }, + wave: { + id: "wave-1", + name: "The Memes", + }, + author: { + handle: `artist-${serialNo}`, + primary_address: `0x${serialNo}`, + }, + metadata: [ + { + data_key: "title", + data_value: `Drop ${serialNo}`, + }, + { + data_key: "description", + data_value: `Description ${serialNo}`, + }, + ], + parts: [ + { + media: [ + { + mime_type: "image/png", + url: "https://example.com/drop.png", + }, + ], + }, + ], + }) as any; + +describe("MemesQuickVoteDialog", () => { + const activeDrop = createDrop(42); + const originalShowModal = HTMLDialogElement.prototype.showModal; + const originalClose = HTMLDialogElement.prototype.close; + const originalRequestAnimationFrame = global.requestAnimationFrame; + + const createQueueState = ( + overrides: Partial> = {} + ): ReturnType => ({ + activeDrop, + hasDiscoveryError: false, + isExhausted: false, + isLoading: false, + isReady: true, + isVoting: false, + latestUsedAmount: 250, + queue: [activeDrop], + recentAmounts: [250, 500], + remainingCount: 9, + retryDiscovery: jest.fn(), + submitVote: jest.fn().mockResolvedValue(true), + skipDrop: jest.fn(), + uncastPower: 5_000, + votingLabel: "votes", + ...overrides, + }); + + beforeAll(() => { + Object.defineProperty(HTMLDialogElement.prototype, "showModal", { + configurable: true, + value: function showModal(this: HTMLDialogElement) { + this.open = true; + }, + }); + Object.defineProperty(HTMLDialogElement.prototype, "close", { + configurable: true, + value: function close(this: HTMLDialogElement) { + this.open = false; + }, + }); + global.requestAnimationFrame = ((callback: FrameRequestCallback) => { + callback(0); + return 0; + }) as typeof global.requestAnimationFrame; + }); + + afterAll(() => { + if (originalShowModal) { + Object.defineProperty(HTMLDialogElement.prototype, "showModal", { + configurable: true, + value: originalShowModal, + }); + } else { + delete (HTMLDialogElement.prototype as Partial) + .showModal; + } + + if (originalClose) { + Object.defineProperty(HTMLDialogElement.prototype, "close", { + configurable: true, + value: originalClose, + }); + } else { + delete (HTMLDialogElement.prototype as Partial).close; + } + + global.requestAnimationFrame = originalRequestAnimationFrame; + }); + + beforeEach(() => { + jest.clearAllMocks(); + useIsMobileScreenMock.mockReturnValue(false); + useMemesQuickVoteQueueMock.mockReturnValue(createQueueState()); + }); + + it("keeps the active drop visible during background refetches", () => { + useMemesQuickVoteQueueMock.mockReturnValue( + createQueueState({ isLoading: true }) + ); + + render( + + ); + + expect(screen.queryByText("Loading your queue")).not.toBeInTheDocument(); + expect( + within(screen.getByTestId("quick-vote-preview-mobile-context")).getByText( + "Drop 42" + ) + ).toBeInTheDocument(); + }); + + it("shows a done state instead of closing when the queue is exhausted", () => { + useMemesQuickVoteQueueMock.mockReturnValue( + createQueueState({ + activeDrop: null, + isExhausted: true, + isReady: false, + queue: [], + remainingCount: 0, + }) + ); + + render( + + ); + + expect(screen.getByText("You are done")).toBeInTheDocument(); + expect( + screen.getByText("No unrated memes are left in quick vote right now.") + ).toBeInTheDocument(); + }); + + it("shows structural skeletons while the next item is still hydrating", () => { + useMemesQuickVoteQueueMock.mockReturnValue( + createQueueState({ + activeDrop: null, + isReady: false, + queue: [], + }) + ); + + render( + + ); + + expect( + screen.getByTestId("quick-vote-loading-skeleton") + ).toBeInTheDocument(); + expect( + screen.getByTestId("quick-vote-preview-mobile-context") + ).toBeInTheDocument(); + expect( + screen.getByTestId("quick-vote-controls-desktop-context") + ).toBeInTheDocument(); + }); + + it("shows a retry state when queue discovery fails", () => { + const retryDiscovery = jest.fn(); + + useMemesQuickVoteQueueMock.mockReturnValue( + createQueueState({ + activeDrop: null, + hasDiscoveryError: true, + isReady: false, + queue: [], + retryDiscovery, + }) + ); + + render( + + ); + + expect(screen.getByText("Couldn't load your queue")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Try again" })); + + expect(retryDiscovery).toHaveBeenCalledTimes(1); + }); + + it("removes the dialog header copy while keeping an accessible close button", () => { + render( + + ); + + expect(screen.queryByText("Memes Wave")).not.toBeInTheDocument(); + expect( + screen.queryByText("Newest first. Skip keeps a meme for later.") + ).not.toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Close quick vote" }) + ).toBeInTheDocument(); + }); + + it("keeps single-column preview context available even when js reports desktop", () => { + render( + + ); + + const mobileRemainingPill = within( + screen.getByTestId("quick-vote-preview-status") + ).getByText("5,000 votes left"); + const desktopRemainingPill = within( + screen.getByTestId("quick-vote-controls-desktop-context") + ).getByText("5,000 votes left"); + + expect(mobileRemainingPill).toHaveClass("tw-text-primary-300"); + expect(desktopRemainingPill).toHaveClass("tw-text-primary-300"); + expect(mobileRemainingPill).toBeInTheDocument(); + expect( + within(screen.getByTestId("quick-vote-preview-mobile-context")).getByText( + "Drop 42" + ) + ).toBeInTheDocument(); + expect( + within(screen.getByTestId("quick-vote-preview-mobile-context")).getByText( + "Description 42" + ) + ).toBeInTheDocument(); + expect( + within( + screen.getByTestId("quick-vote-controls-desktop-context") + ).getByText("Drop 42") + ).toBeInTheDocument(); + }); + + it("bottom-aligns the custom amount action row on larger screens", () => { + render( + + ); + + fireEvent.click( + screen.getByRole("button", { + name: "Custom amount", + }) + ); + + const customInput = screen.getByRole("textbox"); + const customActionRow = customInput.closest("label")?.parentElement; + const voteButton = screen.getByRole("button", { name: "Vote 50" }); + + expect(customActionRow).not.toBeNull(); + expect(customActionRow).toHaveClass("sm:tw-items-end"); + expect(voteButton).toHaveClass("tw-shrink-0", "tw-whitespace-nowrap"); + }); + + it("seeds the initial custom amount at one percent when no recent amounts exist", () => { + useMemesQuickVoteQueueMock.mockReturnValue( + createQueueState({ + latestUsedAmount: null, + recentAmounts: [], + }) + ); + + render( + + ); + + expect(screen.getByRole("textbox")).toHaveValue("50"); + expect(screen.getByRole("button", { name: "Vote 50" })).toBeInTheDocument(); + }); + + it("uses the open custom amount for swipe voting on mobile", async () => { + useIsMobileScreenMock.mockReturnValue(true); + const submitVote = jest.fn().mockResolvedValue(true); + + useMemesQuickVoteQueueMock.mockReturnValue( + createQueueState({ submitVote }) + ); + + render( + + ); + + fireEvent.click( + screen.getByRole("button", { + name: "Custom amount", + }) + ); + fireEvent.change(screen.getByRole("textbox"), { + target: { value: "777" }, + }); + + expect( + screen.getByText("Swipe left to skip, right to vote 777 votes") + ).toBeInTheDocument(); + + const previewCard = screen.getByTestId("quick-vote-preview-card"); + + fireEvent.touchStart(previewCard, { + touches: [{ clientX: 0, clientY: 0 }], + }); + fireEvent.touchMove(previewCard, { + touches: [{ clientX: 120, clientY: 0 }], + }); + fireEvent.touchEnd(previewCard); + + await waitFor(() => { + expect(submitVote).toHaveBeenCalledWith(activeDrop, 777); + }); + expect(submitVote).not.toHaveBeenCalledWith(activeDrop, 250); + }); + + it("keeps the swipe-committed card moving off-screen instead of snapping back", () => { + useIsMobileScreenMock.mockReturnValue(true); + const submitVote = jest.fn().mockResolvedValue(true); + + useMemesQuickVoteQueueMock.mockReturnValue( + createQueueState({ submitVote }) + ); + + render( + + ); + + const previewCard = screen.getByTestId("quick-vote-preview-card"); + + fireEvent.touchStart(previewCard, { + touches: [{ clientX: 0, clientY: 0 }], + }); + fireEvent.touchMove(previewCard, { + touches: [{ clientX: 120, clientY: 0 }], + }); + fireEvent.touchEnd(previewCard); + + expect(previewCard).toHaveStyle({ + transform: "translateX(420px) rotate(6deg)", + }); + expect(previewCard).not.toHaveStyle({ + transform: "translateX(0px)", + }); + }); + + it("resets the mobile preview swipe offset when the active drop changes", () => { + useIsMobileScreenMock.mockReturnValue(true); + const nextDrop = createDrop(43); + const { rerender } = render( + + ); + + const previewCard = screen.getByTestId("quick-vote-preview-card"); + + fireEvent.touchStart(previewCard, { + touches: [{ clientX: 0, clientY: 0 }], + }); + fireEvent.touchMove(previewCard, { + touches: [{ clientX: 60, clientY: 0 }], + }); + + expect(previewCard).toHaveStyle({ + transform: "translateX(60px)", + }); + + useMemesQuickVoteQueueMock.mockReturnValue( + createQueueState({ + activeDrop: nextDrop, + queue: [nextDrop], + remainingCount: 8, + }) + ); + + rerender( + + ); + + expect( + within(screen.getByTestId("quick-vote-preview-mobile-context")).getByText( + "Drop 43" + ) + ).toBeInTheDocument(); + expect(screen.getByTestId("quick-vote-preview-card")).toHaveStyle({ + transform: "translateX(0px)", + }); + }); + + it("submits the visible quick amount from the mobile action row", async () => { + useIsMobileScreenMock.mockReturnValue(true); + const submitVote = jest.fn().mockResolvedValue(true); + + useMemesQuickVoteQueueMock.mockReturnValue( + createQueueState({ submitVote }) + ); + + render( + + ); + + fireEvent.click(screen.getByRole("button", { name: /250/i })); + + await waitFor(() => { + expect(submitVote).toHaveBeenCalledWith(activeDrop, 250); + }); + }); + + it("resets dialog-local controls when the session id changes", () => { + const { rerender } = render( + + ); + + fireEvent.click( + screen.getByRole("button", { + name: "Custom amount", + }) + ); + fireEvent.change(screen.getByRole("textbox"), { + target: { value: "777" }, + }); + + expect(screen.getByRole("textbox")).toHaveValue("777"); + + rerender( + + ); + + expect(screen.queryByRole("textbox")).not.toBeInTheDocument(); + }); +}); diff --git a/__tests__/components/brain/left-sidebar/web/WebLeftSidebar.test.tsx b/__tests__/components/brain/left-sidebar/web/WebLeftSidebar.test.tsx new file mode 100644 index 0000000000..0a3721ca3c --- /dev/null +++ b/__tests__/components/brain/left-sidebar/web/WebLeftSidebar.test.tsx @@ -0,0 +1,190 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import React from "react"; +import WebLeftSidebar from "@/components/brain/left-sidebar/web/WebLeftSidebar"; +import { SidebarProvider } from "@/hooks/useSidebarState"; + +const usePathname = jest.fn(); +let mockDialogMountCount = 0; + +jest.mock("next/navigation", () => ({ + usePathname: () => usePathname(), +})); +jest.mock("@/hooks/useMemesQuickVoteDialogController", () => ({ + useMemesQuickVoteDialogController: () => { + const React = require("react"); + const [isQuickVoteOpen, setIsQuickVoteOpen] = React.useState(false); + const [quickVoteSessionId, setQuickVoteSessionId] = React.useState(0); + const nextSessionIdRef = React.useRef(1); + const reservedSessionIdRef = React.useRef(null as number | null); + + return { + closeQuickVote: () => setIsQuickVoteOpen(false), + isQuickVoteOpen, + openQuickVote: () => { + const sessionId = + reservedSessionIdRef.current ?? nextSessionIdRef.current; + reservedSessionIdRef.current = null; + nextSessionIdRef.current = sessionId + 1; + setQuickVoteSessionId(sessionId); + setIsQuickVoteOpen(true); + }, + prefetchQuickVote: () => { + if (reservedSessionIdRef.current === null) { + reservedSessionIdRef.current = nextSessionIdRef.current; + } + }, + quickVoteSessionId, + }; + }, +})); + +jest.mock( + "@/components/brain/left-sidebar/web/WebBrainLeftSidebarWaves", + () => ({ + __esModule: true, + default: () =>
, + }) +); + +jest.mock("@/components/brain/left-sidebar/web/WebDirectMessagesList", () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock("@/components/brain/left-sidebar/waves/MemesWaveFooter", () => ({ + __esModule: true, + default: ({ + collapsed, + onOpenQuickVote, + }: { + readonly collapsed?: boolean; + readonly onOpenQuickVote: () => void; + }) => ( + + ), +})); + +jest.mock( + "@/components/brain/left-sidebar/waves/memes-quick-vote/MemesQuickVoteDialog", + () => ({ + __esModule: true, + default: ({ + isOpen, + onClose, + sessionId, + }: { + readonly isOpen: boolean; + readonly sessionId: number; + readonly onClose: () => void; + }) => { + const React = require("react"); + + React.useEffect(() => { + mockDialogMountCount += 1; + }, []); + + return isOpen ? ( +
+
Session {sessionId}
+ +
+ ) : null; + }, + }) +); + +describe("WebLeftSidebar", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockDialogMountCount = 0; + }); + + const renderSidebar = (ui: React.ReactNode) => + render({ui}); + + it("renders the footer in the waves sidebar", () => { + usePathname.mockReturnValue("/waves"); + + renderSidebar(); + + expect(screen.getByTestId("waves-list")).toBeInTheDocument(); + expect(screen.getByTestId("footer")).toHaveTextContent("expanded"); + }); + + it("passes collapsed mode through to the footer", () => { + usePathname.mockReturnValue("/waves"); + + renderSidebar(); + + expect(screen.getByTestId("footer")).toHaveTextContent("collapsed"); + }); + + it("opens the shared quick-vote dialog from the footer without remounting it", () => { + usePathname.mockReturnValue("/waves"); + + renderSidebar(); + + expect(mockDialogMountCount).toBe(1); + + fireEvent.click(screen.getByTestId("footer")); + expect(screen.getByText("Session 1")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Close Quick Vote" })); + expect(screen.queryByTestId("dialog")).not.toBeInTheDocument(); + + fireEvent.click(screen.getByTestId("footer")); + expect(screen.getByText("Session 2")).toBeInTheDocument(); + expect(mockDialogMountCount).toBe(1); + }); + + it("resets quick-vote state when switching between waves and messages", () => { + usePathname.mockReturnValue("/waves"); + + const { rerender } = renderSidebar(); + + expect(mockDialogMountCount).toBe(1); + + fireEvent.click(screen.getByTestId("footer")); + expect(screen.getByText("Session 1")).toBeInTheDocument(); + + usePathname.mockReturnValue("/messages/thread"); + rerender( + + + + ); + + expect(screen.getByTestId("messages-list")).toBeInTheDocument(); + expect(screen.queryByTestId("footer")).not.toBeInTheDocument(); + expect(screen.queryByTestId("dialog")).not.toBeInTheDocument(); + + usePathname.mockReturnValue("/waves"); + rerender( + + + + ); + + expect(screen.getByTestId("waves-list")).toBeInTheDocument(); + expect(screen.getByTestId("footer")).toBeInTheDocument(); + expect(screen.queryByTestId("dialog")).not.toBeInTheDocument(); + expect(mockDialogMountCount).toBe(2); + + fireEvent.click(screen.getByTestId("footer")); + expect(screen.getByText("Session 1")).toBeInTheDocument(); + }); + + it("hides the footer in messages view", () => { + usePathname.mockReturnValue("/messages/thread"); + + renderSidebar(); + + expect(screen.getByTestId("messages-list")).toBeInTheDocument(); + expect(screen.queryByTestId("footer")).not.toBeInTheDocument(); + expect(screen.queryByTestId("dialog")).not.toBeInTheDocument(); + }); +}); diff --git a/__tests__/components/brain/left-sidebar/web/WebUnifiedWavesList.test.tsx b/__tests__/components/brain/left-sidebar/web/WebUnifiedWavesList.test.tsx new file mode 100644 index 0000000000..fa5b517d66 --- /dev/null +++ b/__tests__/components/brain/left-sidebar/web/WebUnifiedWavesList.test.tsx @@ -0,0 +1,80 @@ +import { render, screen } from "@testing-library/react"; +import React from "react"; +import WebUnifiedWavesList from "@/components/brain/left-sidebar/web/WebUnifiedWavesList"; + +jest.mock("@/hooks/useInfiniteScroll", () => ({ + useInfiniteScroll: jest.fn(), +})); + +let receivedCollapsed = false; + +jest.mock( + "@/components/brain/left-sidebar/web/WebUnifiedWavesListWaves", + () => ({ + __esModule: true, + default: React.forwardRef((props: any, ref: any) => { + const sentinelRef = React.useRef(null); + React.useImperativeHandle(ref, () => ({ sentinelRef })); + receivedCollapsed = props.isCollapsed; + return
; + }), + }) +); + +jest.mock( + "@/components/brain/left-sidebar/waves/UnifiedWavesListLoader", + () => ({ + UnifiedWavesListLoader: () =>
, + }) +); + +jest.mock( + "@/components/brain/left-sidebar/waves/UnifiedWavesListEmpty", + () => ({ + __esModule: true, + default: () =>
, + }) +); + +describe("WebUnifiedWavesList", () => { + beforeEach(() => { + jest.clearAllMocks(); + receivedCollapsed = false; + }); + + it("renders the list content without owning the footer", () => { + render( + + ); + + expect(screen.getByTestId("waves")).toBeInTheDocument(); + expect(screen.getByTestId("loader")).toBeInTheDocument(); + expect(screen.getByTestId("empty")).toBeInTheDocument(); + expect(screen.queryByText("Uncast Power")).not.toBeInTheDocument(); + }); + + it("passes collapsed mode through to the waves renderer", () => { + render( + + ); + + expect(receivedCollapsed).toBe(true); + }); +}); diff --git a/__tests__/components/brain/mobile/BrainMobileViewContent.test.tsx b/__tests__/components/brain/mobile/BrainMobileViewContent.test.tsx new file mode 100644 index 0000000000..d016cd3d80 --- /dev/null +++ b/__tests__/components/brain/mobile/BrainMobileViewContent.test.tsx @@ -0,0 +1,363 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import BrainMobileViewContent from "@/components/brain/mobile/BrainMobileViewContent"; +import { BrainView } from "@/components/brain/mobile/brainMobileViews"; + +const mockBrainMobileAbout = jest.fn(() =>
); +jest.mock("@/components/brain/mobile/BrainMobileAbout", () => ({ + __esModule: true, + default: (props: any) => mockBrainMobileAbout(props), +})); + +const mockBrainMobileWaves = jest.fn( + ({ onOpenQuickVote }: { readonly onOpenQuickVote: () => void }) => ( + + ) +); +jest.mock("@/components/brain/mobile/BrainMobileWaves", () => ({ + __esModule: true, + default: (props: any) => mockBrainMobileWaves(props), +})); + +const mockBrainMobileMessages = jest.fn(() =>
); +jest.mock("@/components/brain/mobile/BrainMobileMessages", () => ({ + __esModule: true, + default: () => mockBrainMobileMessages(), +})); + +const mockBrainNotifications = jest.fn(() => ( +
+)); +jest.mock("@/components/brain/notifications/NotificationsContainer", () => ({ + __esModule: true, + default: () => mockBrainNotifications(), +})); + +const mockMyStreamWaveLeaderboard = jest.fn(() => ( +
+)); +jest.mock("@/components/brain/my-stream/MyStreamWaveLeaderboard", () => ({ + __esModule: true, + default: (props: any) => mockMyStreamWaveLeaderboard(props), +})); + +const mockMyStreamWaveOutcome = jest.fn(() =>
); +jest.mock("@/components/brain/my-stream/MyStreamWaveOutcome", () => ({ + __esModule: true, + default: (props: any) => mockMyStreamWaveOutcome(props), +})); + +const mockMyStreamWaveSales = jest.fn(() =>
); +jest.mock("@/components/brain/my-stream/MyStreamWaveSales", () => ({ + __esModule: true, + default: (props: any) => mockMyStreamWaveSales(props), +})); + +const mockMyStreamWaveMyVotes = jest.fn(() =>
); +jest.mock("@/components/brain/my-stream/votes/MyStreamWaveMyVotes", () => ({ + __esModule: true, + default: (props: any) => mockMyStreamWaveMyVotes(props), +})); + +const mockMyStreamWaveFAQ = jest.fn(() =>
); +jest.mock("@/components/brain/my-stream/MyStreamWaveFAQ", () => ({ + __esModule: true, + default: (props: any) => mockMyStreamWaveFAQ(props), +})); + +const mockWaveWinners = jest.fn(() =>
); +jest.mock("@/components/waves/winners/WaveWinners", () => ({ + __esModule: true, + WaveWinners: (props: any) => mockWaveWinners(props), +})); + +describe("BrainMobileViewContent", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("renders children for the default view", () => { + render( + +
child
+
+ ); + + expect(screen.getByTestId("children")).toBeInTheDocument(); + }); + + it("renders the about view with the active wave id", () => { + render( + +
child
+
+ ); + + expect(screen.getByTestId("about")).toBeInTheDocument(); + expect(mockBrainMobileAbout).toHaveBeenCalledWith( + expect.objectContaining({ + activeWaveId: "wave-123", + }) + ); + }); + + it("renders shell views and forwards the quick-vote opener", () => { + const onOpenQuickVote = jest.fn(); + const { rerender } = render( + +
child
+
+ ); + + fireEvent.click(screen.getByTestId("waves")); + expect(onOpenQuickVote).toHaveBeenCalledTimes(1); + + rerender( + +
child
+
+ ); + expect(screen.getByTestId("messages")).toBeInTheDocument(); + + rerender( + +
child
+
+ ); + expect(screen.getByTestId("notifications")).toBeInTheDocument(); + }); + + it("renders leaderboard and forwards wave props when available", () => { + const onDropClick = jest.fn(); + const wave = { id: "wave-1" } as any; + + render( + +
child
+
+ ); + + expect(screen.getByTestId("leaderboard")).toBeInTheDocument(); + expect(mockMyStreamWaveLeaderboard).toHaveBeenCalledWith( + expect.objectContaining({ + onDropClick, + wave, + }) + ); + }); + + it("renders sales when the wave is curation-enabled", () => { + render( + +
child
+
+ ); + + expect(screen.getByTestId("sales")).toBeInTheDocument(); + expect(mockMyStreamWaveSales).toHaveBeenCalledWith( + expect.objectContaining({ waveId: "wave-1" }) + ); + }); + + it("renders winners with the padded wrapper", () => { + const onDropClick = jest.fn(); + const wave = { id: "wave-1" } as any; + const { container } = render( + +
child
+
+ ); + + expect(screen.getByTestId("winners")).toBeInTheDocument(); + expect(container.firstChild).toHaveClass("tw-px-2", "sm:tw-px-4"); + expect(mockWaveWinners).toHaveBeenCalledWith( + expect.objectContaining({ onDropClick, wave }) + ); + }); + + it("renders outcome, my votes, and faq when their prerequisites are met", () => { + const onDropClick = jest.fn(); + const wave = { id: "wave-1" } as any; + const { rerender } = render( + +
child
+
+ ); + + expect(screen.getByTestId("outcome")).toBeInTheDocument(); + expect(mockMyStreamWaveOutcome).toHaveBeenCalledWith( + expect.objectContaining({ wave }) + ); + + rerender( + +
child
+
+ ); + expect(screen.getByTestId("my-votes")).toBeInTheDocument(); + expect(mockMyStreamWaveMyVotes).toHaveBeenCalledWith( + expect.objectContaining({ onDropClick, wave }) + ); + + rerender( + +
child
+
+ ); + expect(screen.getByTestId("faq")).toBeInTheDocument(); + expect(mockMyStreamWaveFAQ).toHaveBeenCalledWith( + expect.objectContaining({ wave }) + ); + }); + + it.each([ + { + activeView: BrainView.LEADERBOARD, + isCurationWave: false, + isMemesWave: false, + isRankWave: false, + wave: { id: "wave-1" } as any, + }, + { + activeView: BrainView.SALES, + isCurationWave: false, + isMemesWave: false, + isRankWave: false, + wave: { id: "wave-1" } as any, + }, + { + activeView: BrainView.WINNERS, + isCurationWave: false, + isMemesWave: false, + isRankWave: true, + wave: null, + }, + { + activeView: BrainView.FAQ, + isCurationWave: false, + isMemesWave: false, + isRankWave: true, + wave: { id: "wave-1" } as any, + }, + ])( + "returns null when $activeView is unavailable", + ({ activeView, isCurationWave, isMemesWave, isRankWave, wave }) => { + const { container } = render( + +
child
+
+ ); + + expect(container.firstChild).toBeNull(); + } + ); +}); diff --git a/__tests__/components/brain/mobile/BrainMobileWaves.test.tsx b/__tests__/components/brain/mobile/BrainMobileWaves.test.tsx index 37b6de8ca5..3f0a9a3858 100644 --- a/__tests__/components/brain/mobile/BrainMobileWaves.test.tsx +++ b/__tests__/components/brain/mobile/BrainMobileWaves.test.tsx @@ -1,23 +1,44 @@ -import { render } from '@testing-library/react'; -import BrainMobileWaves from '@/components/brain/mobile/BrainMobileWaves'; +import { fireEvent, render, screen } from "@testing-library/react"; +import BrainMobileWaves from "@/components/brain/mobile/BrainMobileWaves"; let receivedRef: any; -jest.mock('@/components/brain/left-sidebar/waves/BrainLeftSidebarWaves', () => ({ +jest.mock( + "@/components/brain/left-sidebar/waves/BrainLeftSidebarWaves", + () => ({ + __esModule: true, + default: ({ scrollContainerRef }: any) => { + receivedRef = scrollContainerRef; + return
; + }, + }) +); + +jest.mock("@/components/brain/left-sidebar/waves/MemesWaveFooter", () => ({ __esModule: true, - default: ({ scrollContainerRef }: any) => { - receivedRef = scrollContainerRef; - return
; - } + default: ({ onOpenQuickVote }: { readonly onOpenQuickVote: () => void }) => ( + + ), })); -jest.mock('@/components/brain/my-stream/layout/LayoutContext', () => ({ - useLayout: () => ({ mobileWavesViewStyle: { height: '42px' } }) +jest.mock("@/components/brain/my-stream/layout/LayoutContext", () => ({ + useLayout: () => ({ mobileWavesViewStyle: { height: "42px" } }), })); -test('applies style and forwards scroll ref', () => { - const { container } = render(); - expect((container.firstChild as HTMLElement).style.height).toBe('42px'); +test("applies style, forwards scroll ref, and passes the quick-vote opener", () => { + const onOpenQuickVote = jest.fn(); + const { container } = render( + + ); + + expect((container.firstChild as HTMLElement).style.height).toBe("42px"); expect(receivedRef).toBeDefined(); - expect(receivedRef.current).toBe(container.firstChild); + expect(receivedRef.current).not.toBe(container.firstChild); + expect(receivedRef.current).toContainElement(screen.getByTestId("waves")); + + fireEvent.click(screen.getByTestId("footer")); + + expect(onOpenQuickVote).toHaveBeenCalledTimes(1); }); diff --git a/__tests__/components/brain/mobile/FloatingMemesQuickVoteTrigger.test.tsx b/__tests__/components/brain/mobile/FloatingMemesQuickVoteTrigger.test.tsx new file mode 100644 index 0000000000..6f012c5a31 --- /dev/null +++ b/__tests__/components/brain/mobile/FloatingMemesQuickVoteTrigger.test.tsx @@ -0,0 +1,89 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import FloatingMemesQuickVoteTrigger from "@/components/brain/mobile/FloatingMemesQuickVoteTrigger"; +import { useMemesWaveFooterStats } from "@/hooks/useMemesWaveFooterStats"; + +jest.mock("@/hooks/useMemesWaveFooterStats", () => ({ + useMemesWaveFooterStats: jest.fn(), +})); + +jest.mock( + "@/components/brain/left-sidebar/waves/MemesWaveQuickVoteTrigger", + () => ({ + __esModule: true, + default: ({ + onOpenQuickVote, + onPrefetchQuickVote, + unratedCount, + }: { + readonly onOpenQuickVote: () => void; + readonly onPrefetchQuickVote?: (() => void) | undefined; + readonly unratedCount: number; + }) => ( + + ), + }) +); + +const useMemesWaveFooterStatsMock = + useMemesWaveFooterStats as jest.MockedFunction< + typeof useMemesWaveFooterStats + >; + +describe("FloatingMemesQuickVoteTrigger", () => { + const onOpenQuickVote = jest.fn(); + const onPrefetchQuickVote = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + useMemesWaveFooterStatsMock.mockReturnValue({ + isReady: false, + uncastPower: null, + unratedCount: 0, + votingLabel: null, + }); + }); + + it("stays hidden when footer stats are unavailable", () => { + render( + + ); + + expect(screen.queryByTestId("floating-quick-vote-trigger")).toBeNull(); + }); + + it("passes hover and focus prefetch intent through to the floating trigger", () => { + useMemesWaveFooterStatsMock.mockReturnValue({ + isReady: true, + uncastPower: 5000, + unratedCount: 3, + votingLabel: "TDH", + }); + + render( + + ); + + const trigger = screen.getByTestId("floating-quick-vote-trigger"); + + fireEvent.mouseEnter(trigger); + fireEvent.focus(trigger); + fireEvent.click(trigger); + + expect(onPrefetchQuickVote).toHaveBeenCalledTimes(2); + expect(onOpenQuickVote).toHaveBeenCalledTimes(1); + }); +}); diff --git a/__tests__/components/brain/my-stream/MyStreamWaveTabsLeaderboard.test.tsx b/__tests__/components/brain/my-stream/MyStreamWaveTabsLeaderboard.test.tsx index 4570256e39..97a34e9685 100644 --- a/__tests__/components/brain/my-stream/MyStreamWaveTabsLeaderboard.test.tsx +++ b/__tests__/components/brain/my-stream/MyStreamWaveTabsLeaderboard.test.tsx @@ -2,16 +2,7 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import React from "react"; import MyStreamWaveTabsLeaderboard from "@/components/brain/my-stream/MyStreamWaveTabsLeaderboard"; -import { BrainView } from "@/components/brain/BrainMobile"; - -jest.mock("@/components/brain/BrainMobile", () => ({ - BrainView: { - DEFAULT: "DEFAULT", - LEADERBOARD: "LEADERBOARD", - SALES: "SALES", - WINNERS: "WINNERS", - }, -})); +import { BrainView } from "@/components/brain/mobile/brainMobileViews"; jest.mock("@/hooks/useWaveTimers", () => ({ useWaveTimers: () => ({ diff --git a/__tests__/components/layout/AppLayout.test.tsx b/__tests__/components/layout/AppLayout.test.tsx index 01fb4ee8ca..9f1099f0ac 100644 --- a/__tests__/components/layout/AppLayout.test.tsx +++ b/__tests__/components/layout/AppLayout.test.tsx @@ -1,6 +1,6 @@ import { editSlice } from "@/store/editSlice"; import { configureStore } from "@reduxjs/toolkit"; -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import React from "react"; import { Provider } from "react-redux"; @@ -9,6 +9,7 @@ const registerRef = jest.fn(); const setHeaderRef = jest.fn(); const usePathname = jest.fn(); const useSearchParams = jest.fn(); +let mockDialogMountCount = 0; jest.mock("next/dynamic", () => () => { const MockDynamicComponent = () =>
; @@ -28,8 +29,18 @@ jest.mock( jest.mock( "@/components/brain/mobile/BrainMobileWaves", () => - function BrainMobileWaves() { - return
; + function BrainMobileWaves({ + onOpenQuickVote, + }: { + readonly onOpenQuickVote: () => void; + }) { + return ( +
+ +
+ ); } ); jest.mock( @@ -56,6 +67,64 @@ jest.mock("@/components/providers/PullToRefresh", () => ({ __esModule: true, default: () => null, })); +jest.mock("@/hooks/useMemesQuickVoteDialogController", () => ({ + useMemesQuickVoteDialogController: () => { + const React = require("react"); + const [isQuickVoteOpen, setIsQuickVoteOpen] = React.useState(false); + const [quickVoteSessionId, setQuickVoteSessionId] = React.useState(0); + const nextSessionIdRef = React.useRef(1); + const reservedSessionIdRef = React.useRef(null as number | null); + + return { + closeQuickVote: () => setIsQuickVoteOpen(false), + isQuickVoteOpen, + openQuickVote: () => { + const sessionId = + reservedSessionIdRef.current ?? nextSessionIdRef.current; + reservedSessionIdRef.current = null; + nextSessionIdRef.current = sessionId + 1; + setQuickVoteSessionId(sessionId); + setIsQuickVoteOpen(true); + }, + prefetchQuickVote: () => { + if (reservedSessionIdRef.current === null) { + reservedSessionIdRef.current = nextSessionIdRef.current; + } + }, + quickVoteSessionId, + }; + }, +})); +jest.mock( + "@/components/brain/left-sidebar/waves/memes-quick-vote/MemesQuickVoteDialog", + () => ({ + __esModule: true, + default: ({ + isOpen, + onClose, + sessionId, + }: { + readonly isOpen: boolean; + readonly sessionId: number; + readonly onClose: () => void; + }) => { + const React = require("react"); + + React.useEffect(() => { + mockDialogMountCount += 1; + }, []); + + return isOpen ? ( +
+
Session {sessionId}
+ +
+ ) : null; + }, + }) +); const AppLayout = require("@/components/layout/AppLayout").default; @@ -63,11 +132,13 @@ describe("AppLayout", () => { let store: any; beforeEach(() => { + jest.clearAllMocks(); store = configureStore({ reducer: { edit: editSlice.reducer }, }); usePathname.mockReturnValue("/"); useSearchParams.mockReturnValue({ get: () => null } as any); + mockDialogMountCount = 0; }); const renderWithProvider = (children: React.ReactElement) => { @@ -95,4 +166,63 @@ describe("AppLayout", () => { ); expect(screen.getByTestId("messages")).toBeInTheDocument(); }); + + it("owns a persistent quick-vote dialog for the waves view", () => { + useViewContext.mockReturnValue({ activeView: "waves" }); + + renderWithProvider(child); + + expect(mockDialogMountCount).toBe(1); + + fireEvent.click( + screen.getByRole("button", { name: "Open quick vote from app layout" }) + ); + expect(screen.getByText("Session 1")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Close Quick Vote" })); + expect(screen.queryByTestId("quick-vote-dialog")).not.toBeInTheDocument(); + + fireEvent.click( + screen.getByRole("button", { name: "Open quick vote from app layout" }) + ); + expect(screen.getByText("Session 2")).toBeInTheDocument(); + expect(mockDialogMountCount).toBe(1); + }); + + it("closes the quick-vote dialog when leaving the waves view", () => { + useViewContext.mockReturnValue({ activeView: "waves" }); + + const { rerender } = renderWithProvider(child); + + fireEvent.click( + screen.getByRole("button", { name: "Open quick vote from app layout" }) + ); + expect(screen.getByText("Session 1")).toBeInTheDocument(); + + useViewContext.mockReturnValue({ activeView: "messages" }); + rerender( + + child + + ); + + expect(screen.getByTestId("messages")).toBeInTheDocument(); + expect(screen.queryByTestId("quick-vote-dialog")).not.toBeInTheDocument(); + + useViewContext.mockReturnValue({ activeView: "waves" }); + rerender( + + child + + ); + + expect(screen.getByTestId("waves")).toBeInTheDocument(); + expect(screen.queryByTestId("quick-vote-dialog")).not.toBeInTheDocument(); + expect(mockDialogMountCount).toBe(2); + + fireEvent.click( + screen.getByRole("button", { name: "Open quick vote from app layout" }) + ); + expect(screen.getByText("Session 1")).toBeInTheDocument(); + }); }); diff --git a/__tests__/hooks/memesQuickVote.helpers.test.ts b/__tests__/hooks/memesQuickVote.helpers.test.ts new file mode 100644 index 0000000000..b20b545a81 --- /dev/null +++ b/__tests__/hooks/memesQuickVote.helpers.test.ts @@ -0,0 +1,124 @@ +import { ApiWaveCreditType } from "@/generated/models/ApiWaveCreditType"; +import { + addRecentQuickVoteAmount, + appendSkippedDropId, + deriveMemesQuickVoteStatsFromDrop, + getDefaultQuickVoteAmount, + getDisplayQuickVoteAmounts, + normalizeQuickVoteAmount, + sanitizeStoredAmounts, + sanitizeStoredDropIds, +} from "@/hooks/memesQuickVote.helpers"; + +const createDrop = ({ + id = "drop-1", + maxRating = 5_000, +}: { + readonly id?: string; + readonly maxRating?: number; +} = {}) => + ({ + id, + serial_no: 1, + drop_type: "PARTICIPATORY", + context_profile_context: { + rating: 0, + max_rating: maxRating, + }, + wave: { + id: "wave-1", + name: "The Memes", + voting_credit_type: ApiWaveCreditType.Tdh, + authenticated_user_eligible_to_vote: true, + voting_period_start: null, + voting_period_end: null, + }, + author: { + handle: "artist", + primary_address: "0x123", + }, + parts: [ + { + content: "hello", + media: [], + }, + ], + metadata: [], + created_at: new Date(1_000).toISOString(), + }) as any; + +describe("memesQuickVote.helpers", () => { + it("sanitizes stored drop ids by trimming, deduping, and removing invalid values", () => { + expect( + sanitizeStoredDropIds([ + " drop-1 ", + "drop-2", + "drop-1", + "", + " ", + 10, + null, + ]) + ).toEqual(["drop-1", "drop-2"]); + }); + + it("sanitizes stored quick-vote amounts and keeps the last five unique values", () => { + expect( + sanitizeStoredAmounts([ + 500, 500, 250, 0, -1, 1000, 2000, 3000, 4000, 5000, + ]) + ).toEqual([250, 1000, 2000, 3000, 4000, 5000].slice(-5)); + }); + + it("derives footer stats from the first returned unvoted drop", () => { + expect( + deriveMemesQuickVoteStatsFromDrop({ + count: 7, + drop: createDrop({ maxRating: 750 }), + }) + ).toEqual({ + uncastPower: 750, + unratedCount: 7, + votingLabel: "TDH", + }); + }); + + it("returns empty stats when there is no usable first drop", () => { + expect( + deriveMemesQuickVoteStatsFromDrop({ + count: 0, + drop: createDrop(), + }) + ).toEqual({ + uncastPower: null, + unratedCount: 0, + votingLabel: null, + }); + }); + + it("keeps the last five unique quick-vote amounts and renders them ascending", () => { + const recent = [50, 125, 250, 500]; + const withDuplicate = addRecentQuickVoteAmount(recent, 125); + const withNewAmount = addRecentQuickVoteAmount(withDuplicate, 1_000); + const capped = addRecentQuickVoteAmount(withNewAmount, 2_000); + + expect(capped).toEqual([250, 500, 125, 1_000, 2_000]); + expect(getDisplayQuickVoteAmounts(capped)).toEqual([ + 125, 250, 500, 1000, 2000, + ]); + }); + + it("moves re-skipped drop ids to the tail", () => { + expect( + appendSkippedDropId(["drop-30", "drop-10", "drop-20"], "drop-10") + ).toEqual(["drop-30", "drop-20", "drop-10"]); + }); + + it("derives and clamps quick-vote amounts safely", () => { + expect(getDefaultQuickVoteAmount(5_000)).toBe(50); + expect(getDefaultQuickVoteAmount(99)).toBe(1); + expect(normalizeQuickVoteAmount("777", 500)).toBe(500); + expect(normalizeQuickVoteAmount("0", 500)).toBe(1); + expect(normalizeQuickVoteAmount("nope", 500)).toBeNull(); + }); +}); diff --git a/__tests__/hooks/memesQuickVote.queue.helpers.test.ts b/__tests__/hooks/memesQuickVote.queue.helpers.test.ts new file mode 100644 index 0000000000..d6ca999a1d --- /dev/null +++ b/__tests__/hooks/memesQuickVote.queue.helpers.test.ts @@ -0,0 +1,143 @@ +import { ApiDropType } from "@/generated/models/ApiDropType"; +import { ApiWaveCreditType } from "@/generated/models/ApiWaveCreditType"; +import { + createInitialMemesQuickVoteDiscoveryState, + deferMemesQuickVoteDropId, + deriveMemesQuickVoteDiscoverySnapshot, + isMemesQuickVoteExhausted, + removeMemesQuickVoteDropId, +} from "@/hooks/memesQuickVote.queue.helpers"; + +const createDrop = (id: string) => + ({ + id, + serial_no: 1, + drop_type: ApiDropType.Participatory, + context_profile_context: { + rating: 0, + max_rating: 5_000, + }, + wave: { + id: "wave-1", + name: "The Memes", + voting_credit_type: ApiWaveCreditType.Tdh, + authenticated_user_eligible_to_vote: true, + voting_period_start: null, + voting_period_end: null, + }, + author: { + handle: "artist", + primary_address: "0x123", + }, + parts: [ + { + content: "hello", + media: [], + }, + ], + metadata: [], + created_at: new Date(1_000).toISOString(), + }) as any; + +const createPage = ({ + dropIds, + nextPage, + pageCount, +}: { + readonly dropIds: readonly string[]; + readonly nextPage: number | null; + readonly pageCount: number; +}) => ({ + drops: dropIds.map(createDrop), + nextPage, + pageCount, +}); + +describe("memesQuickVote.queue.helpers", () => { + it("rebuilds deferred drops using persisted skip order instead of page order", () => { + const snapshot = deriveMemesQuickVoteDiscoverySnapshot({ + enabled: true, + pages: [ + createPage({ + dropIds: ["drop-a", "drop-b", "drop-c", "drop-d"], + nextPage: null, + pageCount: 4, + }), + ], + skippedDropIds: ["drop-a", "drop-c", "drop-b"], + state: createInitialMemesQuickVoteDiscoveryState(), + }); + + expect(snapshot.deferredIds).toEqual(["drop-a", "drop-c", "drop-b"]); + expect(snapshot.activeIds).toEqual(["drop-d"]); + }); + + it("preserves persisted skip order across multiple discovery pages", () => { + const snapshot = deriveMemesQuickVoteDiscoverySnapshot({ + enabled: true, + pages: [ + createPage({ + dropIds: ["drop-b"], + nextPage: 2, + pageCount: 4, + }), + createPage({ + dropIds: ["drop-a", "drop-c", "drop-d"], + nextPage: null, + pageCount: 4, + }), + ], + skippedDropIds: ["drop-a", "drop-c", "drop-b"], + state: createInitialMemesQuickVoteDiscoveryState(), + }); + + expect(snapshot.deferredIds).toEqual(["drop-a", "drop-c", "drop-b"]); + expect(snapshot.activeIds).toEqual(["drop-d"]); + }); + + it("keeps local defer order ahead of stale persisted skip ordering", () => { + const localState = deferMemesQuickVoteDropId({ + dropId: "drop-b", + state: createInitialMemesQuickVoteDiscoveryState(), + }); + const snapshot = deriveMemesQuickVoteDiscoverySnapshot({ + enabled: true, + pages: [ + createPage({ + dropIds: ["drop-a", "drop-b", "drop-c", "drop-d"], + nextPage: null, + pageCount: 4, + }), + ], + skippedDropIds: ["drop-a", "drop-b", "drop-c"], + state: localState, + }); + + expect(snapshot.deferredIds).toEqual(["drop-a", "drop-c", "drop-b"]); + expect(snapshot.activeIds).toEqual(["drop-d"]); + }); + + it("filters removed ids out of the derived queue", () => { + const localState = removeMemesQuickVoteDropId({ + dropId: "drop-a", + state: createInitialMemesQuickVoteDiscoveryState(), + }); + const snapshot = deriveMemesQuickVoteDiscoverySnapshot({ + enabled: true, + pages: [ + createPage({ + dropIds: ["drop-a"], + nextPage: null, + pageCount: 1, + }), + ], + skippedDropIds: [], + state: localState, + }); + + expect(snapshot.activeIds).toEqual([]); + expect(snapshot.deferredIds).toEqual([]); + expect(snapshot.nextPage).toBeNull(); + expect(isMemesQuickVoteExhausted(snapshot)).toBe(true); + }); +}); diff --git a/__tests__/hooks/useMemesQuickVoteDialogController.test.tsx b/__tests__/hooks/useMemesQuickVoteDialogController.test.tsx new file mode 100644 index 0000000000..9224a6525e --- /dev/null +++ b/__tests__/hooks/useMemesQuickVoteDialogController.test.tsx @@ -0,0 +1,58 @@ +import { act, renderHook } from "@testing-library/react"; +import { useMemesQuickVoteDialogController } from "@/hooks/useMemesQuickVoteDialogController"; +import { usePrefetchMemesQuickVote } from "@/hooks/usePrefetchMemesQuickVote"; + +jest.mock("@/hooks/usePrefetchMemesQuickVote", () => ({ + usePrefetchMemesQuickVote: jest.fn(), +})); + +const usePrefetchMemesQuickVoteMock = + usePrefetchMemesQuickVote as jest.MockedFunction< + typeof usePrefetchMemesQuickVote + >; + +describe("useMemesQuickVoteDialogController", () => { + const prefetchMemesQuickVote = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + usePrefetchMemesQuickVoteMock.mockReturnValue(prefetchMemesQuickVote); + }); + + it("reuses the prefetched session id when quick vote opens", () => { + const { result } = renderHook(() => useMemesQuickVoteDialogController()); + + act(() => { + result.current.prefetchQuickVote(); + }); + + expect(prefetchMemesQuickVote).toHaveBeenCalledTimes(1); + expect(prefetchMemesQuickVote).toHaveBeenCalledWith(1); + + act(() => { + result.current.openQuickVote(); + }); + + expect(result.current.isQuickVoteOpen).toBe(true); + expect(result.current.quickVoteSessionId).toBe(1); + + act(() => { + result.current.closeQuickVote(); + result.current.openQuickVote(); + }); + + expect(result.current.quickVoteSessionId).toBe(2); + }); + + it("does not re-prefetch the same reserved session more than once", () => { + const { result } = renderHook(() => useMemesQuickVoteDialogController()); + + act(() => { + result.current.prefetchQuickVote(); + result.current.prefetchQuickVote(); + }); + + expect(prefetchMemesQuickVote).toHaveBeenCalledTimes(1); + expect(prefetchMemesQuickVote).toHaveBeenCalledWith(1); + }); +}); diff --git a/__tests__/hooks/useMemesQuickVoteQueue.test.tsx b/__tests__/hooks/useMemesQuickVoteQueue.test.tsx new file mode 100644 index 0000000000..698ced9cfa --- /dev/null +++ b/__tests__/hooks/useMemesQuickVoteQueue.test.tsx @@ -0,0 +1,1144 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import React from "react"; +import { AuthContext } from "@/components/auth/Auth"; +import { ReactQueryWrapperContext } from "@/components/react-query-wrapper/ReactQueryWrapper"; +import { useSeizeSettings } from "@/contexts/SeizeSettingsContext"; +import { ApiDropType } from "@/generated/models/ApiDropType"; +import { ApiWaveCreditType } from "@/generated/models/ApiWaveCreditType"; +import { useMemesQuickVoteQueue } from "@/hooks/useMemesQuickVoteQueue"; +import { commonApiFetch, commonApiPost } from "@/services/api/common-api"; + +jest.mock("@/contexts/SeizeSettingsContext", () => ({ + useSeizeSettings: jest.fn(), +})); + +jest.mock("@/services/api/common-api", () => ({ + commonApiFetch: jest.fn(), + commonApiPost: jest.fn(), +})); + +const useSeizeSettingsMock = useSeizeSettings as jest.MockedFunction< + typeof useSeizeSettings +>; +const commonApiFetchMock = commonApiFetch as jest.MockedFunction< + typeof commonApiFetch +>; +const commonApiPostMock = commonApiPost as jest.MockedFunction< + typeof commonApiPost +>; + +const DEFAULT_CONTEXT_PROFILE = "me"; +const DEFAULT_MEMES_WAVE_ID = "memes-wave"; +const DEFAULT_WAVE = { + id: DEFAULT_MEMES_WAVE_ID, + name: "The Memes", + voting_credit_type: ApiWaveCreditType.Tdh, + authenticated_user_eligible_to_vote: true, + voting_period_start: null, + voting_period_end: null, +} as const; + +const getSkippedStorageKey = ( + memesWaveId = DEFAULT_MEMES_WAVE_ID, + contextProfile = DEFAULT_CONTEXT_PROFILE +) => `memesQuickVoteSkipped:${memesWaveId}:${contextProfile}`; + +const getAmountsStorageKey = ( + memesWaveId = DEFAULT_MEMES_WAVE_ID, + contextProfile = DEFAULT_CONTEXT_PROFILE +) => `memesQuickVoteAmounts:${memesWaveId}:${contextProfile}`; + +const createWrapper = ({ + activeProfileProxy = null, + connectedProfileHandle = DEFAULT_CONTEXT_PROFILE, + invalidateDrops = jest.fn(), + requestAuth = jest.fn().mockResolvedValue({ success: false }), + setToast = jest.fn(), +}: { + readonly activeProfileProxy?: { readonly id: string } | null; + readonly connectedProfileHandle?: string | null; + readonly invalidateDrops?: jest.Mock; + readonly requestAuth?: jest.Mock; + readonly setToast?: jest.Mock; +} = {}) => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + mutations: { + retry: false, + }, + }, + }); + + return ({ children }: { children: React.ReactNode }) => ( + + + + {children} + + + + ); +}; + +const createDrop = ({ + id, + serialNo, + rating = 0, + maxRating = 5_000, + eligible = true, +}: { + readonly id: string; + readonly serialNo: number; + readonly rating?: number; + readonly maxRating?: number; + readonly eligible?: boolean; +}) => + ({ + id, + serial_no: serialNo, + drop_type: ApiDropType.Participatory, + context_profile_context: { + rating, + max_rating: maxRating, + }, + wave: { + ...DEFAULT_WAVE, + authenticated_user_eligible_to_vote: eligible, + }, + author: { + handle: `artist-${serialNo}`, + primary_address: `0x${serialNo}`, + }, + title: null, + reply_to: null, + parts: [ + { + content: `content-${serialNo}`, + media: [], + }, + ], + metadata: [], + created_at: new Date(serialNo * 1_000).toISOString(), + }) as any; + +const readStoredStrings = (key: string): string[] => + JSON.parse(localStorage.getItem(key) || "[]"); + +const readStoredNumbers = (key: string): number[] => + JSON.parse(localStorage.getItem(key) || "[]"); + +const createDeferred = () => { + let resolve!: (value: T) => void; + let reject!: (error?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + + return { promise, resolve, reject }; +}; + +const getLeaderboardFetchCalls = (pageSize: number) => + commonApiFetchMock.mock.calls.filter(([request]) => { + const candidate = request as { + readonly endpoint: string; + readonly params?: Record; + }; + + return ( + candidate.endpoint === `waves/${DEFAULT_MEMES_WAVE_ID}/leaderboard` && + candidate.params?.page_size === `${pageSize}` + ); + }); + +describe("useMemesQuickVoteQueue", () => { + let currentLeaderboardIds: string[]; + let currentLeaderboardDropsById: Record; + let currentHydratedDropsById: Record; + + beforeEach(() => { + localStorage.clear(); + jest.clearAllMocks(); + + currentLeaderboardIds = []; + currentLeaderboardDropsById = {}; + currentHydratedDropsById = {}; + + useSeizeSettingsMock.mockReturnValue({ + isLoaded: true, + seizeSettings: { + memes_wave_id: DEFAULT_MEMES_WAVE_ID, + }, + } as any); + + commonApiPostMock.mockResolvedValue({} as any); + commonApiFetchMock.mockImplementation(async (request: any) => { + const { endpoint, params } = request as { + readonly endpoint: string; + readonly params?: Record; + }; + + if (endpoint === `waves/${DEFAULT_MEMES_WAVE_ID}/leaderboard`) { + const page = Number(params?.page ?? "1"); + const pageSize = Number(params?.page_size ?? "20"); + const startIndex = (page - 1) * pageSize; + const pageIds = currentLeaderboardIds.slice( + startIndex, + startIndex + pageSize + ); + + return { + count: currentLeaderboardIds.length, + page, + next: startIndex + pageSize < currentLeaderboardIds.length, + wave: DEFAULT_WAVE, + drops: pageIds.map((id) => currentLeaderboardDropsById[id]), + } as any; + } + + if (endpoint.startsWith("drops/")) { + const dropId = endpoint.replace(/^drops\//, ""); + const drop = currentHydratedDropsById[dropId]; + + if (!drop) { + throw new Error("not found"); + } + + return drop; + } + + throw new Error(`Unexpected endpoint: ${endpoint}`); + }); + }); + + it("hydrates recent quick-vote amounts from storage on mount", () => { + localStorage.setItem( + getAmountsStorageKey(), + JSON.stringify([500, 100, 250]) + ); + + const { result } = renderHook( + () => useMemesQuickVoteQueue({ enabled: false, sessionId: 1 }), + { + wrapper: createWrapper(), + } + ); + + expect(result.current.recentAmounts).toEqual([100, 250, 500]); + expect(result.current.latestUsedAmount).toBe(250); + }); + + it("persists skipped drop ids and advances to the next discovered item", async () => { + const drop30 = createDrop({ id: "drop-30", serialNo: 30 }); + const drop20 = createDrop({ id: "drop-20", serialNo: 20 }); + const drop10 = createDrop({ id: "drop-10", serialNo: 10 }); + + currentLeaderboardIds = [drop30.id, drop20.id, drop10.id]; + currentLeaderboardDropsById = { + [drop30.id]: drop30, + [drop20.id]: drop20, + [drop10.id]: drop10, + }; + currentHydratedDropsById = { + [drop30.id]: drop30, + [drop20.id]: drop20, + [drop10.id]: drop10, + }; + + const { result } = renderHook( + () => useMemesQuickVoteQueue({ sessionId: 1 }), + { + wrapper: createWrapper(), + } + ); + + await waitFor(() => expect(result.current.activeDrop?.id).toBe(drop30.id)); + + act(() => { + result.current.skipDrop(result.current.activeDrop!); + }); + + expect(readStoredStrings(getSkippedStorageKey())).toEqual([drop30.id]); + + await waitFor(() => expect(result.current.activeDrop?.id).toBe(drop20.id)); + expect(result.current.queue.map((drop) => drop.id)).toEqual([ + drop20.id, + drop10.id, + drop30.id, + ]); + expect(result.current.remainingCount).toBe(3); + }); + + it("stores the vote amount, removes the voted item, and advances after a successful vote", async () => { + const drop30 = createDrop({ id: "drop-30", serialNo: 30 }); + const drop20 = createDrop({ id: "drop-20", serialNo: 20 }); + const invalidateDrops = jest.fn(); + const requestAuth = jest.fn().mockResolvedValue({ success: true }); + + currentLeaderboardIds = [drop30.id, drop20.id]; + currentLeaderboardDropsById = { + [drop30.id]: drop30, + [drop20.id]: drop20, + }; + currentHydratedDropsById = { + [drop30.id]: drop30, + [drop20.id]: drop20, + }; + + const { result } = renderHook( + () => useMemesQuickVoteQueue({ sessionId: 1 }), + { + wrapper: createWrapper({ + invalidateDrops, + requestAuth, + }), + } + ); + + await waitFor(() => expect(result.current.activeDrop?.id).toBe(drop30.id)); + + await act(async () => { + await result.current.submitVote(result.current.activeDrop!, 250); + }); + + expect(commonApiPostMock).toHaveBeenCalledWith({ + endpoint: `drops/${drop30.id}/ratings`, + body: { + rating: 250, + category: "Rep", + }, + }); + expect(readStoredNumbers(getAmountsStorageKey())).toEqual([250]); + expect(invalidateDrops).toHaveBeenCalledTimes(1); + + await waitFor(() => expect(result.current.activeDrop?.id).toBe(drop20.id)); + expect(result.current.latestUsedAmount).toBe(250); + expect(result.current.queue.map((drop) => drop.id)).toEqual([drop20.id]); + }); + + it("advances immediately before the vote request resolves", async () => { + const drop30 = createDrop({ id: "drop-30", serialNo: 30 }); + const drop20 = createDrop({ id: "drop-20", serialNo: 20 }); + const deferredVote = createDeferred(); + const requestAuth = jest.fn().mockResolvedValue({ success: true }); + + currentLeaderboardIds = [drop30.id, drop20.id]; + currentLeaderboardDropsById = { + [drop30.id]: drop30, + [drop20.id]: drop20, + }; + currentHydratedDropsById = { + [drop30.id]: drop30, + [drop20.id]: drop20, + }; + + commonApiPostMock.mockImplementationOnce(() => deferredVote.promise); + + const { result } = renderHook( + () => useMemesQuickVoteQueue({ sessionId: 1 }), + { + wrapper: createWrapper({ requestAuth }), + } + ); + + await waitFor(() => expect(result.current.activeDrop?.id).toBe(drop30.id)); + + await act(async () => { + await result.current.submitVote(result.current.activeDrop!, 250); + }); + + await waitFor(() => expect(result.current.activeDrop?.id).toBe(drop20.id)); + expect(result.current.latestUsedAmount).toBe(250); + expect(readStoredNumbers(getAmountsStorageKey())).toEqual([250]); + expect(commonApiPostMock).toHaveBeenCalledTimes(1); + + await act(async () => { + deferredVote.resolve( + createDrop({ + id: drop30.id, + serialNo: drop30.serial_no, + rating: 250, + maxRating: 4_750, + }) + ); + await Promise.resolve(); + }); + }); + + it("keeps advancing while an earlier vote is still in flight", async () => { + const drop30 = createDrop({ id: "drop-30", serialNo: 30 }); + const drop20 = createDrop({ id: "drop-20", serialNo: 20 }); + const drop10 = createDrop({ id: "drop-10", serialNo: 10 }); + const firstVote = createDeferred(); + const requestAuth = jest.fn().mockResolvedValue({ success: true }); + + currentLeaderboardIds = [drop30.id, drop20.id, drop10.id]; + currentLeaderboardDropsById = { + [drop30.id]: drop30, + [drop20.id]: drop20, + [drop10.id]: drop10, + }; + currentHydratedDropsById = { + [drop30.id]: drop30, + [drop20.id]: drop20, + [drop10.id]: drop10, + }; + + commonApiPostMock + .mockImplementationOnce(() => firstVote.promise) + .mockResolvedValueOnce( + createDrop({ + id: drop20.id, + serialNo: drop20.serial_no, + rating: 500, + maxRating: 4_000, + }) + ); + + const { result } = renderHook( + () => useMemesQuickVoteQueue({ sessionId: 1 }), + { + wrapper: createWrapper({ requestAuth }), + } + ); + + await waitFor(() => expect(result.current.activeDrop?.id).toBe(drop30.id)); + + await act(async () => { + await result.current.submitVote(result.current.activeDrop!, 250); + }); + + await waitFor(() => expect(result.current.activeDrop?.id).toBe(drop20.id)); + + await act(async () => { + await result.current.submitVote(result.current.activeDrop!, 500); + }); + + await waitFor(() => expect(result.current.activeDrop?.id).toBe(drop10.id)); + expect(commonApiPostMock).toHaveBeenCalledTimes(1); + + await act(async () => { + firstVote.resolve( + createDrop({ + id: drop30.id, + serialNo: drop30.serial_no, + rating: 250, + maxRating: 4_750, + }) + ); + await Promise.resolve(); + }); + + await waitFor(() => expect(commonApiPostMock).toHaveBeenCalledTimes(2)); + expect(commonApiPostMock).toHaveBeenNthCalledWith(2, { + endpoint: `drops/${drop20.id}/ratings`, + body: { + rating: 500, + category: "Rep", + }, + }); + }); + + it("treats zero optimistic remaining power as exhaustion while the next card is still refetching", async () => { + const drop30 = createDrop({ + id: "drop-30", + serialNo: 30, + maxRating: 5_000, + }); + const drop20 = createDrop({ + id: "drop-20", + serialNo: 20, + maxRating: 5_000, + }); + const hydratedDrop20 = createDrop({ + id: drop20.id, + serialNo: drop20.serial_no, + maxRating: 0, + }); + const deferredDrop20 = createDeferred(); + const requestAuth = jest.fn().mockResolvedValue({ success: true }); + + currentLeaderboardIds = [drop30.id, drop20.id]; + currentLeaderboardDropsById = { + [drop30.id]: drop30, + [drop20.id]: drop20, + }; + currentHydratedDropsById = { + [drop30.id]: drop30, + }; + + commonApiFetchMock.mockImplementation(async (request: any) => { + const { endpoint, params } = request as { + readonly endpoint: string; + readonly params?: Record; + }; + + if (endpoint === `waves/${DEFAULT_MEMES_WAVE_ID}/leaderboard`) { + const page = Number(params?.page ?? "1"); + const pageSize = Number(params?.page_size ?? "20"); + const startIndex = (page - 1) * pageSize; + const pageIds = currentLeaderboardIds.slice( + startIndex, + startIndex + pageSize + ); + + return { + count: currentLeaderboardIds.length, + page, + next: startIndex + pageSize < currentLeaderboardIds.length, + wave: DEFAULT_WAVE, + drops: pageIds.map((id) => currentLeaderboardDropsById[id]), + } as any; + } + + if (endpoint === `drops/${drop20.id}`) { + return deferredDrop20.promise; + } + + if (endpoint === `drops/${drop30.id}`) { + return drop30; + } + + throw new Error(`Unexpected endpoint: ${endpoint}`); + }); + commonApiPostMock.mockResolvedValueOnce( + createDrop({ + id: drop30.id, + serialNo: drop30.serial_no, + rating: 5_000, + maxRating: 0, + }) + ); + + const { result } = renderHook( + () => useMemesQuickVoteQueue({ sessionId: 1 }), + { + wrapper: createWrapper({ requestAuth }), + } + ); + + await waitFor(() => expect(result.current.activeDrop?.id).toBe(drop30.id)); + + await act(async () => { + await result.current.submitVote(result.current.activeDrop!, 5_000); + }); + + await waitFor(() => expect(result.current.isExhausted).toBe(true)); + expect(result.current.activeDrop).toBeNull(); + expect(result.current.isReady).toBe(false); + expect(result.current.uncastPower).toBeNull(); + expect(result.current.queue[0]?.context_profile_context?.max_rating).toBe( + 0 + ); + expect(commonApiPostMock).toHaveBeenCalledTimes(1); + + await act(async () => { + deferredDrop20.resolve(hydratedDrop20); + await Promise.resolve(); + }); + + await waitFor(() => expect(result.current.isExhausted).toBe(true)); + expect(result.current.queue.map((drop) => drop.id)).toEqual([drop20.id]); + expect(result.current.queue[0]?.context_profile_context?.max_rating).toBe( + 0 + ); + }); + + it("clears the optimistic cap after fresh next-card data arrives", async () => { + const drop30 = createDrop({ + id: "drop-30", + serialNo: 30, + maxRating: 5_000, + }); + const drop20 = createDrop({ + id: "drop-20", + serialNo: 20, + maxRating: 5_000, + }); + const hydratedDrop20 = createDrop({ + id: drop20.id, + serialNo: drop20.serial_no, + maxRating: 1_200, + }); + const deferredDrop20 = createDeferred(); + const requestAuth = jest.fn().mockResolvedValue({ success: true }); + + currentLeaderboardIds = [drop30.id, drop20.id]; + currentLeaderboardDropsById = { + [drop30.id]: drop30, + [drop20.id]: drop20, + }; + currentHydratedDropsById = { + [drop30.id]: drop30, + }; + + commonApiFetchMock.mockImplementation(async (request: any) => { + const { endpoint, params } = request as { + readonly endpoint: string; + readonly params?: Record; + }; + + if (endpoint === `waves/${DEFAULT_MEMES_WAVE_ID}/leaderboard`) { + const page = Number(params?.page ?? "1"); + const pageSize = Number(params?.page_size ?? "20"); + const startIndex = (page - 1) * pageSize; + const pageIds = currentLeaderboardIds.slice( + startIndex, + startIndex + pageSize + ); + + return { + count: currentLeaderboardIds.length, + page, + next: startIndex + pageSize < currentLeaderboardIds.length, + wave: DEFAULT_WAVE, + drops: pageIds.map((id) => currentLeaderboardDropsById[id]), + } as any; + } + + if (endpoint === `drops/${drop20.id}`) { + return deferredDrop20.promise; + } + + if (endpoint === `drops/${drop30.id}`) { + return drop30; + } + + throw new Error(`Unexpected endpoint: ${endpoint}`); + }); + commonApiPostMock + .mockResolvedValueOnce( + createDrop({ + id: drop30.id, + serialNo: drop30.serial_no, + rating: 4_700, + maxRating: 300, + }) + ) + .mockResolvedValueOnce( + createDrop({ + id: drop20.id, + serialNo: drop20.serial_no, + rating: 1_000, + maxRating: 200, + }) + ); + + const { result } = renderHook( + () => useMemesQuickVoteQueue({ sessionId: 1 }), + { + wrapper: createWrapper({ requestAuth }), + } + ); + + await waitFor(() => expect(result.current.activeDrop?.id).toBe(drop30.id)); + + await act(async () => { + await result.current.submitVote(result.current.activeDrop!, 4_700); + }); + + await waitFor(() => expect(result.current.activeDrop?.id).toBe(drop20.id)); + expect(result.current.activeDrop?.context_profile_context?.max_rating).toBe( + 300 + ); + + await act(async () => { + deferredDrop20.resolve(hydratedDrop20); + await Promise.resolve(); + }); + + await waitFor(() => + expect( + result.current.activeDrop?.context_profile_context?.max_rating + ).toBe(1_200) + ); + + await act(async () => { + await result.current.submitVote(result.current.activeDrop!, 1_000); + }); + + expect(commonApiPostMock).toHaveBeenNthCalledWith(2, { + endpoint: `drops/${drop20.id}/ratings`, + body: { + rating: 1_000, + category: "Rep", + }, + }); + }); + + it("keeps a lower server max rating when the next card refetch completes first", async () => { + const drop30 = createDrop({ + id: "drop-30", + serialNo: 30, + maxRating: 5_000, + }); + const drop20 = createDrop({ + id: "drop-20", + serialNo: 20, + maxRating: 5_000, + }); + const hydratedDrop20 = createDrop({ + id: drop20.id, + serialNo: drop20.serial_no, + maxRating: 200, + }); + const requestAuth = jest.fn().mockResolvedValue({ success: true }); + + currentLeaderboardIds = [drop30.id, drop20.id]; + currentLeaderboardDropsById = { + [drop30.id]: drop30, + [drop20.id]: drop20, + }; + currentHydratedDropsById = { + [drop30.id]: drop30, + [drop20.id]: hydratedDrop20, + }; + + commonApiPostMock.mockResolvedValueOnce( + createDrop({ + id: drop30.id, + serialNo: drop30.serial_no, + rating: 4_700, + maxRating: 300, + }) + ); + + const { result } = renderHook( + () => useMemesQuickVoteQueue({ sessionId: 1 }), + { + wrapper: createWrapper({ requestAuth }), + } + ); + + await waitFor(() => expect(result.current.activeDrop?.id).toBe(drop30.id)); + + await act(async () => { + await result.current.submitVote(result.current.activeDrop!, 4_700); + }); + + await waitFor(() => expect(result.current.activeDrop?.id).toBe(drop20.id)); + expect(result.current.activeDrop?.context_profile_context?.max_rating).toBe( + 200 + ); + }); + + it("resets optimistic remaining power when the session changes", async () => { + const drop30 = createDrop({ + id: "drop-30", + serialNo: 30, + maxRating: 5_000, + }); + const drop20 = createDrop({ + id: "drop-20", + serialNo: 20, + maxRating: 5_000, + }); + const deferredDrop20 = createDeferred(); + const requestAuth = jest.fn().mockResolvedValue({ success: true }); + + currentLeaderboardIds = [drop30.id, drop20.id]; + currentLeaderboardDropsById = { + [drop30.id]: drop30, + [drop20.id]: drop20, + }; + currentHydratedDropsById = { + [drop30.id]: drop30, + }; + + commonApiFetchMock.mockImplementation(async (request: any) => { + const { endpoint, params } = request as { + readonly endpoint: string; + readonly params?: Record; + }; + + if (endpoint === `waves/${DEFAULT_MEMES_WAVE_ID}/leaderboard`) { + const page = Number(params?.page ?? "1"); + const pageSize = Number(params?.page_size ?? "20"); + const startIndex = (page - 1) * pageSize; + const pageIds = currentLeaderboardIds.slice( + startIndex, + startIndex + pageSize + ); + + return { + count: currentLeaderboardIds.length, + page, + next: startIndex + pageSize < currentLeaderboardIds.length, + wave: DEFAULT_WAVE, + drops: pageIds.map((id) => currentLeaderboardDropsById[id]), + } as any; + } + + if (endpoint === `drops/${drop20.id}`) { + return deferredDrop20.promise; + } + + if (endpoint === `drops/${drop30.id}`) { + return drop30; + } + + throw new Error(`Unexpected endpoint: ${endpoint}`); + }); + + commonApiPostMock.mockResolvedValueOnce( + createDrop({ + id: drop30.id, + serialNo: drop30.serial_no, + rating: 4_700, + maxRating: 300, + }) + ); + + const { result, rerender } = renderHook( + ({ sessionId }) => useMemesQuickVoteQueue({ sessionId }), + { + initialProps: { sessionId: 1 }, + wrapper: createWrapper({ requestAuth }), + } + ); + + await waitFor(() => expect(result.current.activeDrop?.id).toBe(drop30.id)); + + await act(async () => { + await result.current.submitVote(result.current.activeDrop!, 4_700); + }); + + await waitFor(() => expect(result.current.activeDrop?.id).toBe(drop20.id)); + expect(result.current.activeDrop?.context_profile_context?.max_rating).toBe( + 300 + ); + + rerender({ sessionId: 2 }); + + await waitFor(() => expect(result.current.activeDrop?.id).toBe(drop30.id)); + expect(result.current.activeDrop?.context_profile_context?.max_rating).toBe( + 5_000 + ); + }); + + it("prunes invalidated skipped items before exhausting the queue", async () => { + const leaderboardDrop = createDrop({ id: "drop-30", serialNo: 30 }); + const hydratedDrop = createDrop({ + id: "drop-30", + serialNo: 30, + rating: 1, + }); + + currentLeaderboardIds = [leaderboardDrop.id]; + currentLeaderboardDropsById = { + [leaderboardDrop.id]: leaderboardDrop, + }; + currentHydratedDropsById = { + [hydratedDrop.id]: hydratedDrop, + }; + localStorage.setItem( + getSkippedStorageKey(), + JSON.stringify([leaderboardDrop.id]) + ); + + const { result } = renderHook( + () => useMemesQuickVoteQueue({ sessionId: 1 }), + { + wrapper: createWrapper(), + } + ); + + await waitFor(() => expect(result.current.isExhausted).toBe(true)); + + expect(result.current.activeDrop).toBeNull(); + expect(result.current.queue).toEqual([]); + expect(readStoredStrings(getSkippedStorageKey())).toEqual([]); + + expect(getLeaderboardFetchCalls(20)).toHaveLength(1); + }); + + it("refetches the shared summary when a hydrated drop is discarded", async () => { + const leaderboardDrop = createDrop({ id: "drop-30", serialNo: 30 }); + const hydratedDrop = createDrop({ + id: leaderboardDrop.id, + serialNo: leaderboardDrop.serial_no, + rating: 1, + }); + + currentLeaderboardIds = [leaderboardDrop.id]; + currentLeaderboardDropsById = { + [leaderboardDrop.id]: leaderboardDrop, + }; + currentHydratedDropsById = { + [hydratedDrop.id]: hydratedDrop, + }; + localStorage.setItem( + getSkippedStorageKey(), + JSON.stringify([leaderboardDrop.id, "drop-20"]) + ); + + const { result } = renderHook( + () => useMemesQuickVoteQueue({ sessionId: 1 }), + { + wrapper: createWrapper(), + } + ); + + await waitFor(() => expect(result.current.isExhausted).toBe(true)); + await waitFor(() => expect(getLeaderboardFetchCalls(1)).toHaveLength(2)); + + expect(result.current.activeDrop).toBeNull(); + expect(result.current.queue).toEqual([]); + expect(readStoredStrings(getSkippedStorageKey())).toEqual(["drop-20"]); + }); + + it("does not refetch the shared summary when the hydrated drop stays eligible", async () => { + const leaderboardDrop = createDrop({ id: "drop-30", serialNo: 30 }); + + currentLeaderboardIds = [leaderboardDrop.id]; + currentLeaderboardDropsById = { + [leaderboardDrop.id]: leaderboardDrop, + }; + currentHydratedDropsById = { + [leaderboardDrop.id]: leaderboardDrop, + }; + + const { result } = renderHook( + () => useMemesQuickVoteQueue({ sessionId: 1 }), + { + wrapper: createWrapper(), + } + ); + + await waitFor(() => + expect(result.current.activeDrop?.id).toBe(leaderboardDrop.id) + ); + + expect(result.current.queue.map((drop) => drop.id)).toEqual([ + leaderboardDrop.id, + ]); + expect(getLeaderboardFetchCalls(1)).toHaveLength(1); + }); + + it("advances past a candidate whose hydrated drop fails after retries", async () => { + const drop30 = createDrop({ id: "drop-30", serialNo: 30 }); + const drop20 = createDrop({ id: "drop-20", serialNo: 20 }); + + currentLeaderboardIds = [drop30.id, drop20.id]; + currentLeaderboardDropsById = { + [drop30.id]: drop30, + [drop20.id]: drop20, + }; + currentHydratedDropsById = { + [drop20.id]: drop20, + }; + + commonApiFetchMock.mockImplementation(async (request: any) => { + const { endpoint, params } = request as { + readonly endpoint: string; + readonly params?: Record; + }; + + if (endpoint === `waves/${DEFAULT_MEMES_WAVE_ID}/leaderboard`) { + const page = Number(params?.page ?? "1"); + const pageSize = Number(params?.page_size ?? "20"); + const startIndex = (page - 1) * pageSize; + const pageIds = currentLeaderboardIds.slice( + startIndex, + startIndex + pageSize + ); + + return { + count: currentLeaderboardIds.length, + page, + next: startIndex + pageSize < currentLeaderboardIds.length, + wave: DEFAULT_WAVE, + drops: pageIds.map((id) => currentLeaderboardDropsById[id]), + } as any; + } + + if (endpoint === `drops/${drop30.id}`) { + throw new Error("network error"); + } + + if (endpoint === `drops/${drop20.id}`) { + return drop20; + } + + throw new Error(`Unexpected endpoint: ${endpoint}`); + }); + + const { result } = renderHook( + () => useMemesQuickVoteQueue({ sessionId: 1 }), + { + wrapper: createWrapper(), + } + ); + + await waitFor(() => expect(result.current.activeDrop?.id).toBe(drop30.id)); + await waitFor(() => expect(result.current.activeDrop?.id).toBe(drop20.id), { + timeout: 10_000, + }); + expect(result.current.isLoading).toBe(false); + expect(result.current.queue.map((drop) => drop.id)).toEqual([drop20.id]); + expect(readStoredStrings(getSkippedStorageKey())).toEqual([]); + }, 15_000); + + it("keeps discovery failures retryable until the leaderboard loads", async () => { + const leaderboardDrop = createDrop({ id: "drop-30", serialNo: 30 }); + let discoveryAttemptCount = 0; + + currentLeaderboardIds = [leaderboardDrop.id]; + currentLeaderboardDropsById = { + [leaderboardDrop.id]: leaderboardDrop, + }; + currentHydratedDropsById = { + [leaderboardDrop.id]: leaderboardDrop, + }; + + commonApiFetchMock.mockImplementation(async (request: any) => { + const { endpoint, params } = request as { + readonly endpoint: string; + readonly params?: Record; + }; + + if (endpoint === `waves/${DEFAULT_MEMES_WAVE_ID}/leaderboard`) { + const page = Number(params?.page ?? "1"); + const pageSize = Number(params?.page_size ?? "20"); + + if (page === 1 && pageSize === 20) { + discoveryAttemptCount += 1; + + if (discoveryAttemptCount === 1) { + throw new Error("network error"); + } + } + + const startIndex = (page - 1) * pageSize; + const pageIds = currentLeaderboardIds.slice( + startIndex, + startIndex + pageSize + ); + + return { + count: currentLeaderboardIds.length, + page, + next: startIndex + pageSize < currentLeaderboardIds.length, + wave: DEFAULT_WAVE, + drops: pageIds.map((id) => currentLeaderboardDropsById[id]), + } as any; + } + + if (endpoint.startsWith("drops/")) { + const dropId = endpoint.replace(/^drops\//, ""); + const drop = currentHydratedDropsById[dropId]; + + if (!drop) { + throw new Error("not found"); + } + + return drop; + } + + throw new Error(`Unexpected endpoint: ${endpoint}`); + }); + + const { result } = renderHook( + () => useMemesQuickVoteQueue({ sessionId: 1 }), + { + wrapper: createWrapper(), + } + ); + + await waitFor(() => expect(result.current.hasDiscoveryError).toBe(true)); + + expect(result.current.isExhausted).toBe(false); + expect(result.current.isLoading).toBe(false); + expect(result.current.activeDrop).toBeNull(); + + act(() => { + result.current.retryDiscovery(); + }); + + await waitFor(() => + expect(result.current.activeDrop?.id).toBe(leaderboardDrop.id) + ); + expect(result.current.hasDiscoveryError).toBe(false); + + expect(getLeaderboardFetchCalls(20)).toHaveLength(2); + }); + + it("treats missing wave ids as unavailable instead of loading forever", () => { + useSeizeSettingsMock.mockReturnValue({ + isLoaded: true, + seizeSettings: { + memes_wave_id: null, + }, + } as any); + + const { result } = renderHook( + () => useMemesQuickVoteQueue({ sessionId: 1 }), + { + wrapper: createWrapper(), + } + ); + + expect(commonApiFetchMock).not.toHaveBeenCalled(); + expect(result.current.isExhausted).toBe(true); + expect(result.current.isLoading).toBe(false); + expect(result.current.activeDrop).toBeNull(); + }); + + it("keeps quick vote loading while seize settings are unresolved", () => { + useSeizeSettingsMock.mockReturnValue({ + isLoaded: false, + seizeSettings: { + memes_wave_id: null, + }, + } as any); + + const { result } = renderHook( + () => useMemesQuickVoteQueue({ sessionId: 1 }), + { + wrapper: createWrapper(), + } + ); + + expect(commonApiFetchMock).not.toHaveBeenCalled(); + expect(result.current.isExhausted).toBe(false); + expect(result.current.isLoading).toBe(true); + expect(result.current.activeDrop).toBeNull(); + }); + + it("treats proxy sessions as unavailable instead of loading forever", () => { + const { result } = renderHook( + () => useMemesQuickVoteQueue({ sessionId: 1 }), + { + wrapper: createWrapper({ + activeProfileProxy: { + id: "proxy-1", + }, + }), + } + ); + + expect(commonApiFetchMock).not.toHaveBeenCalled(); + expect(result.current.isExhausted).toBe(true); + expect(result.current.isLoading).toBe(false); + expect(result.current.activeDrop).toBeNull(); + }); +}); diff --git a/__tests__/hooks/useMemesWaveFooterStats.test.tsx b/__tests__/hooks/useMemesWaveFooterStats.test.tsx new file mode 100644 index 0000000000..da39189354 --- /dev/null +++ b/__tests__/hooks/useMemesWaveFooterStats.test.tsx @@ -0,0 +1,227 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import React from "react"; +import { useMemesWaveFooterStats } from "@/hooks/useMemesWaveFooterStats"; +import { commonApiFetch } from "@/services/api/common-api"; +import { ApiDropType } from "@/generated/models/ApiDropType"; +import { ApiWaveCreditType } from "@/generated/models/ApiWaveCreditType"; + +jest.mock("@/services/api/common-api", () => ({ + commonApiFetch: jest.fn(), +})); + +const useAuth = jest.fn(); +const useSeizeSettings = jest.fn(); + +jest.mock("@/components/auth/Auth", () => ({ + useAuth: () => useAuth(), +})); + +jest.mock("@/contexts/SeizeSettingsContext", () => ({ + useSeizeSettings: () => useSeizeSettings(), +})); + +const commonApiFetchMock = commonApiFetch as jest.MockedFunction< + typeof commonApiFetch +>; + +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + + return ({ children }: { children: React.ReactNode }) => ( + {children} + ); +}; + +const createDrop = ({ + id, + serialNo, + rating, + maxRating = 5_000, +}: { + readonly id: string; + readonly serialNo: number; + readonly rating: number; + readonly maxRating?: number; +}) => + ({ + id, + serial_no: serialNo, + drop_type: ApiDropType.Participatory, + context_profile_context: { + rating, + max_rating: maxRating, + }, + 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, + }, + }) as any; + +describe("useMemesWaveFooterStats", () => { + beforeEach(() => { + jest.clearAllMocks(); + + useAuth.mockReturnValue({ + connectedProfile: { + id: "profile-1", + handle: "me", + primary_wallet: "0x123", + }, + activeProfileProxy: null, + }); + + useSeizeSettings.mockReturnValue({ + isLoaded: true, + seizeSettings: { + memes_wave_id: "memes-wave", + }, + }); + }); + + it("requires a handle to request quick-vote summary data", () => { + useAuth.mockReturnValue({ + connectedProfile: { + id: "profile-1", + handle: null, + primary_wallet: "0x123", + }, + activeProfileProxy: null, + }); + + const { result } = renderHook(() => useMemesWaveFooterStats(), { + wrapper: createWrapper(), + }); + + expect(commonApiFetchMock).not.toHaveBeenCalled(); + expect(result.current.isReady).toBe(false); + }); + + it("stays disabled for proxy sessions", () => { + useAuth.mockReturnValue({ + connectedProfile: { + id: "profile-1", + handle: "me", + primary_wallet: "0x123", + }, + activeProfileProxy: { + id: "proxy-1", + }, + }); + + const { result } = renderHook(() => useMemesWaveFooterStats(), { + wrapper: createWrapper(), + }); + + expect(commonApiFetchMock).not.toHaveBeenCalled(); + expect(result.current.isReady).toBe(false); + }); + + it("does not fetch until memes settings are loaded", () => { + useSeizeSettings.mockReturnValue({ + isLoaded: false, + seizeSettings: { + memes_wave_id: "memes-wave", + }, + }); + + const { result } = renderHook(() => useMemesWaveFooterStats(), { + wrapper: createWrapper(), + }); + + expect(commonApiFetchMock).not.toHaveBeenCalled(); + expect(result.current.isReady).toBe(false); + }); + + 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, + }), + ], + } as any); + + const { result } = renderHook(() => useMemesWaveFooterStats(), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isReady).toBe(true)); + + expect(result.current.uncastPower).toBe(5_000); + expect(result.current.unratedCount).toBe(2); + 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", + }, + }); + }); + + 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, + }, + drops: [ + { + ...createDrop({ + id: "drop-40", + serialNo: 40, + rating: 0, + }), + context_profile_context: null, + }, + ], + } as any); + + const { result } = renderHook(() => useMemesWaveFooterStats(), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(commonApiFetchMock).toHaveBeenCalledTimes(1)); + + expect(result.current.isReady).toBe(false); + expect(result.current.uncastPower).toBeNull(); + expect(result.current.unratedCount).toBe(0); + expect(result.current.votingLabel).toBeNull(); + }); +}); diff --git a/__tests__/hooks/usePrefetchMemesQuickVote.test.tsx b/__tests__/hooks/usePrefetchMemesQuickVote.test.tsx new file mode 100644 index 0000000000..0cbd0c8dbd --- /dev/null +++ b/__tests__/hooks/usePrefetchMemesQuickVote.test.tsx @@ -0,0 +1,190 @@ +import { act, renderHook } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; +import { ApiWaveCreditType } from "@/generated/models/ApiWaveCreditType"; +import { + getMemesQuickVoteDiscoveryQueryKey, + getMemesQuickVoteDiscoveryStateKey, + getMemesQuickVoteDropQueryKey, + getMemesQuickVoteSummaryQueryKey, +} from "@/hooks/memesQuickVote.query"; +import { useMemesQuickVoteContext } from "@/hooks/useMemesQuickVoteContext"; +import { useMemesQuickVoteStorage } from "@/hooks/useMemesQuickVoteStorage"; +import { usePrefetchMemesQuickVote } from "@/hooks/usePrefetchMemesQuickVote"; +import { commonApiFetch } from "@/services/api/common-api"; + +jest.mock("@/hooks/useMemesQuickVoteContext", () => ({ + useMemesQuickVoteContext: jest.fn(), +})); + +jest.mock("@/hooks/useMemesQuickVoteStorage", () => ({ + useMemesQuickVoteStorage: jest.fn(), +})); + +jest.mock("@/services/api/common-api", () => ({ + commonApiFetch: jest.fn(), +})); + +const useMemesQuickVoteContextMock = + useMemesQuickVoteContext as jest.MockedFunction< + typeof useMemesQuickVoteContext + >; +const useMemesQuickVoteStorageMock = + useMemesQuickVoteStorage as jest.MockedFunction< + typeof useMemesQuickVoteStorage + >; +const commonApiFetchMock = commonApiFetch as jest.MockedFunction< + typeof commonApiFetch +>; + +const WAVE_ID = "memes-wave"; +const CONTEXT_PROFILE = "me"; +const WAVE = { + id: WAVE_ID, + name: "The Memes", + voting_credit_type: ApiWaveCreditType.Tdh, +} as const; + +const createDrop = (id: string, serialNo: number) => + ({ + id, + serial_no: serialNo, + wave: WAVE, + context_profile_context: { + rating: 0, + max_rating: 5_000, + }, + author: { + handle: `artist-${serialNo}`, + primary_address: `0x${serialNo}`, + }, + metadata: [], + parts: [], + }) as any; + +describe("usePrefetchMemesQuickVote", () => { + let queryClient: QueryClient; + + beforeEach(() => { + jest.clearAllMocks(); + queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + + useMemesQuickVoteContextMock.mockReturnValue({ + contextProfile: CONTEXT_PROFILE, + isEnabled: true, + isLoaded: true, + memesWaveId: WAVE_ID, + proxyId: null, + }); + useMemesQuickVoteStorageMock.mockReturnValue({ + skippedDropIds: ["drop-10"], + } as any); + + const summaryDrop = createDrop("drop-30", 30); + const discoveryDrop = createDrop("drop-20", 20); + + commonApiFetchMock.mockImplementation(async (request: any) => { + const { endpoint, params } = request as { + readonly endpoint: string; + readonly params?: Record; + }; + + if ( + endpoint === `waves/${WAVE_ID}/leaderboard` && + params?.page_size === "1" + ) { + return { + count: 2, + drops: [summaryDrop], + next: true, + page: 1, + wave: WAVE, + } as any; + } + + if ( + endpoint === `waves/${WAVE_ID}/leaderboard` && + params?.page_size === "20" + ) { + return { + count: 2, + drops: [summaryDrop, discoveryDrop], + next: false, + page: 1, + wave: WAVE, + } as any; + } + + if (endpoint === `drops/${summaryDrop.id}`) { + return summaryDrop; + } + + if (endpoint === `drops/${discoveryDrop.id}`) { + return discoveryDrop; + } + + throw new Error(`Unexpected request: ${JSON.stringify(request)}`); + }); + }); + + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + + it("prefetches summary, discovery, and the first two hydrated drops for the reserved session", async () => { + const { result } = renderHook(() => usePrefetchMemesQuickVote(), { + wrapper, + }); + + await act(async () => { + await result.current(7); + }); + + expect( + queryClient.getQueryData( + getMemesQuickVoteSummaryQueryKey({ + contextProfile: CONTEXT_PROFILE, + proxyId: null, + waveId: WAVE_ID, + }) + ) + ).toBeDefined(); + expect( + queryClient.getQueryData( + getMemesQuickVoteDiscoveryQueryKey({ + discoveryStateKey: getMemesQuickVoteDiscoveryStateKey({ + contextProfile: CONTEXT_PROFILE, + enabled: true, + memesWaveId: WAVE_ID, + sessionId: 7, + }), + fetchVersion: 0, + waveId: WAVE_ID, + }) + ) + ).toEqual({ + pages: [ + { + drops: expect.arrayContaining([ + expect.objectContaining({ id: "drop-30" }), + expect.objectContaining({ id: "drop-20" }), + ]), + nextPage: null, + pageCount: 2, + }, + ], + }); + expect( + queryClient.getQueryData(getMemesQuickVoteDropQueryKey("drop-30")) + ).toEqual(expect.objectContaining({ id: "drop-30" })); + expect( + queryClient.getQueryData(getMemesQuickVoteDropQueryKey("drop-20")) + ).toEqual(expect.objectContaining({ id: "drop-20" })); + }); +}); diff --git a/__tests__/services/auth.utils.test.ts b/__tests__/services/auth.utils.test.ts index 8d73a2c629..c8704c9ca7 100644 --- a/__tests__/services/auth.utils.test.ts +++ b/__tests__/services/auth.utils.test.ts @@ -7,6 +7,7 @@ import { getRefreshToken, getStagingAuth, getWalletAddress, + isAuthAddressAuthorized, removeAuthJwt, setActiveWalletAccount, setAuthJwt, @@ -143,6 +144,57 @@ describe("auth.utils", () => { expect(getWalletAddress()).toBe("stored"); }); + it("authorizes a stored connected address", () => { + expect( + isAuthAddressAuthorized({ + address: "0xAbC", + connectedAccounts: [{ address: "0xabc" }], + }) + ).toBe(true); + }); + + it("authorizes a matching dev-auth address and jwt without stored accounts", () => { + const { publicEnv } = require("@/config/env"); + publicEnv.USE_DEV_AUTH = "true"; + publicEnv.DEV_MODE_WALLET_ADDRESS = "0xDev"; + publicEnv.DEV_MODE_AUTH_JWT = "dev-jwt"; + + expect( + isAuthAddressAuthorized({ + address: "0xdev", + connectedAccounts: [], + }) + ).toBe(true); + }); + + it("does not authorize dev auth when the active address differs", () => { + const { publicEnv } = require("@/config/env"); + publicEnv.USE_DEV_AUTH = "true"; + publicEnv.DEV_MODE_WALLET_ADDRESS = "0xDev"; + publicEnv.DEV_MODE_AUTH_JWT = "dev-jwt"; + + expect( + isAuthAddressAuthorized({ + address: "0xother", + connectedAccounts: [], + }) + ).toBe(false); + }); + + it("does not authorize dev auth when the jwt is missing", () => { + const { publicEnv } = require("@/config/env"); + publicEnv.USE_DEV_AUTH = "true"; + publicEnv.DEV_MODE_WALLET_ADDRESS = "0xDev"; + publicEnv.DEV_MODE_AUTH_JWT = null; + + expect( + isAuthAddressAuthorized({ + address: "0xdev", + connectedAccounts: [], + }) + ).toBe(false); + }); + it("removeAuthJwt clears storage and cookie", () => { (safeLocalStorage.getItem as jest.Mock).mockReturnValue("Addr"); removeAuthJwt(); diff --git a/components/auth/Auth.tsx b/components/auth/Auth.tsx index a88aa49d3c..4844fb229e 100644 --- a/components/auth/Auth.tsx +++ b/components/auth/Auth.tsx @@ -40,6 +40,7 @@ import { canStoreAnotherWalletAccount, getAuthJwt, getWalletAddress, + isAuthAddressAuthorized, PROFILE_SWITCHED_EVENT, removeAuthJwt, setActiveWalletAccount, @@ -149,13 +150,10 @@ export default function Auth({ } = useSeizeConnectContext(); const isAddressAuthorized = useMemo(() => { - if (!address) { - return false; - } - - return connectedAccounts.some( - (account) => account.address.toLowerCase() === address.toLowerCase() - ); + return isAuthAddressAuthorized({ + address, + connectedAccounts, + }); }, [address, connectedAccounts]); const { diff --git a/components/brain/BrainMobile.tsx b/components/brain/BrainMobile.tsx index ff0f4c9088..185b98bd3b 100644 --- a/components/brain/BrainMobile.tsx +++ b/components/brain/BrainMobile.tsx @@ -8,24 +8,15 @@ import { useRouter, useSearchParams, usePathname } from "next/navigation"; import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { commonApiFetch } from "@/services/api/common-api"; import BrainDesktopDrop from "./BrainDesktopDrop"; -import BrainMobileAbout from "./mobile/BrainMobileAbout"; import type { ExtendedDrop } from "@/helpers/waves/drop.helpers"; import { DropSize } from "@/helpers/waves/drop.helpers"; import { useWaveData } from "@/hooks/useWaveData"; -import MyStreamWaveLeaderboard from "./my-stream/MyStreamWaveLeaderboard"; -import MyStreamWaveOutcome from "./my-stream/MyStreamWaveOutcome"; -import { WaveWinners } from "../waves/winners/WaveWinners"; import { useWaveTimers } from "@/hooks/useWaveTimers"; import { QueryKey } from "../react-query-wrapper/ReactQueryWrapper"; -import MyStreamWaveMyVotes from "./my-stream/votes/MyStreamWaveMyVotes"; -import MyStreamWaveFAQ from "./my-stream/MyStreamWaveFAQ"; -import MyStreamWaveSales from "./my-stream/MyStreamWaveSales"; import { useWave } from "@/hooks/useWave"; import type { ApiDrop } from "@/generated/models/ApiDrop"; -import BrainMobileWaves from "./mobile/BrainMobileWaves"; -import BrainMobileMessages from "./mobile/BrainMobileMessages"; import useDeviceInfo from "@/hooks/useDeviceInfo"; -import BrainNotifications from "./notifications/NotificationsContainer"; +import MemesQuickVoteDialog from "./left-sidebar/waves/memes-quick-vote/MemesQuickVoteDialog"; import { getActiveWaveIdFromUrl, getHomeRoute, @@ -37,20 +28,11 @@ import CreateDirectMessageModal from "@/components/waves/create-dm/CreateDirectM import { useAuth } from "@/components/auth/Auth"; import { useMyStreamOptional } from "@/contexts/wave/MyStreamContext"; import { useClosingDropId } from "@/hooks/useClosingDropId"; - -export enum BrainView { - DEFAULT = "DEFAULT", - ABOUT = "ABOUT", - LEADERBOARD = "LEADERBOARD", - SALES = "SALES", - WINNERS = "WINNERS", - OUTCOME = "OUTCOME", - MY_VOTES = "MY_VOTES", - FAQ = "FAQ", - WAVES = "WAVES", - MESSAGES = "MESSAGES", - NOTIFICATIONS = "NOTIFICATIONS", -} +import { useMemesQuickVoteDialogController } from "@/hooks/useMemesQuickVoteDialogController"; +import BrainMobileViewContent from "./mobile/BrainMobileViewContent"; +import FloatingMemesQuickVoteTrigger from "./mobile/FloatingMemesQuickVoteTrigger"; +import { BrainView } from "./mobile/brainMobileViews"; +import { useBrainMobileActiveView } from "./mobile/useBrainMobileActiveView"; interface Props { readonly children: ReactNode; @@ -62,6 +44,13 @@ const BrainMobile: React.FC = ({ children }) => { const pathname = usePathname(); const { isApp } = useDeviceInfo(); const { connectedProfile } = useAuth(); + const { + closeQuickVote, + isQuickVoteOpen, + openQuickVote, + prefetchQuickVote, + quickVoteSessionId, + } = useMemesQuickVoteDialogController(); const [hydrated, setHydrated] = useState(false); const myStream = useMyStreamOptional(); @@ -69,7 +58,6 @@ const BrainMobile: React.FC = ({ children }) => { setHydrated(true); }, []); - const [activeView, setActiveView] = useState(BrainView.DEFAULT); const dropId = searchParams.get("drop") ?? undefined; const { effectiveDropId, beginClosingDrop } = useClosingDropId(dropId); const { data: drop } = useQuery({ @@ -108,12 +96,24 @@ const BrainMobile: React.FC = ({ children }) => { }, }); - const { isMemesWave, isCurationWave, isRankWave } = useWave(wave); + const { isMemesWave, isCurationWave, isRankWave, isDm } = useWave(wave); const { voting: { isCompleted }, decisions: { firstDecisionDone }, } = useWaveTimers(wave); + const { activeView, onViewChange } = useBrainMobileActiveView({ + firstDecisionDone, + isApp, + isCompleted, + isCurationWave, + isMemesWave, + isRankWave, + pathname, + searchParams, + wave, + waveId, + }); const onDropClick = (drop: ExtendedDrop) => { const params = new URLSearchParams(searchParams.toString() || ""); @@ -141,103 +141,6 @@ const BrainMobile: React.FC = ({ children }) => { const hasWave = Boolean(waveId); - useEffect(() => { - const viewParam = searchParams.get("view"); - const createParam = searchParams.get("create"); - - if (createParam && isApp) { - setActiveView(BrainView.DEFAULT); - return; - } - - if ( - (!waveId && pathname === "/notifications") || - (!waveId && viewParam === "notifications") - ) { - setActiveView(BrainView.NOTIFICATIONS); - return; - } - - if ( - (!waveId && pathname === "/messages") || - (!waveId && viewParam === "messages") - ) { - setActiveView(BrainView.MESSAGES); - return; - } - - if ( - (!waveId && pathname === "/waves") || - (!waveId && viewParam === "waves") - ) { - setActiveView(BrainView.WAVES); - return; - } - - if (pathname === "/" && !waveId && !viewParam) { - setActiveView(BrainView.DEFAULT); - } - }, [pathname, searchParams, waveId, isApp]); - - // Handle tab visibility and reset on wave changes - useEffect(() => { - const globalViews = new Set([ - BrainView.DEFAULT, - BrainView.WAVES, - BrainView.MESSAGES, - BrainView.NOTIFICATIONS, - ]); - - const routeToView: Record = { - "/waves": BrainView.WAVES, - "/messages": BrainView.MESSAGES, - "/notifications": BrainView.NOTIFICATIONS, - }; - - if (!hasWave) { - const isWaveSpecificView = !globalViews.has(activeView); - if (isWaveSpecificView) { - setActiveView(routeToView[pathname ?? ""] ?? BrainView.DEFAULT); - } - return; - } - - if (!wave) return; - - const shouldResetToDefault = - (activeView === BrainView.LEADERBOARD && isCompleted) || - (activeView === BrainView.SALES && !isCurationWave) || - (activeView === BrainView.WINNERS && !firstDecisionDone) || - (activeView === BrainView.MY_VOTES && !isMemesWave && !isCurationWave) || - (activeView === BrainView.FAQ && !isMemesWave); - - if (shouldResetToDefault) { - setActiveView(BrainView.DEFAULT); - return; - } - - const nonWaveViews = new Set([ - BrainView.NOTIFICATIONS, - BrainView.MESSAGES, - BrainView.WAVES, - ]); - - if (waveId && nonWaveViews.has(activeView)) { - setActiveView(BrainView.DEFAULT); - } - }, [ - hasWave, - wave, - isCompleted, - firstDecisionDone, - activeView, - isMemesWave, - isCurationWave, - isRankWave, - waveId, - pathname, - ]); - const closeCreateOverlay = useCallback(() => { const params = new URLSearchParams(searchParams.toString() || ""); params.delete("create"); @@ -275,39 +178,17 @@ const BrainMobile: React.FC = ({ children }) => { return null; }, [isApp, searchParams, connectedProfile, closeCreateOverlay]); - const viewComponents: Record = { - [BrainView.ABOUT]: , - [BrainView.DEFAULT]: children, - [BrainView.LEADERBOARD]: - isRankWave && !!wave ? ( - - ) : null, - [BrainView.SALES]: - isCurationWave && !!wave ? : null, - [BrainView.WINNERS]: - isRankWave && !!wave ? ( -
- -
- ) : null, - [BrainView.OUTCOME]: - isRankWave && !!wave ? : null, - [BrainView.MY_VOTES]: - isRankWave && !!wave ? ( - - ) : null, - [BrainView.FAQ]: - isRankWave && isMemesWave && !!wave ? ( - - ) : null, - [BrainView.WAVES]: , - [BrainView.MESSAGES]: , - [BrainView.NOTIFICATIONS]: , - }; + const shouldMountFloatingQuickVoteEntry = + isApp && + hasWave && + !!wave && + activeView === BrainView.DEFAULT && + !isDropOpen && + !isDm; + const shouldMountQuickVoteDialog = + isQuickVoteOpen || + shouldMountFloatingQuickVoteEntry || + activeView === BrainView.WAVES; const dropOverlayClass = isApp ? "tw-fixed tw-inset-0 tw-z-[1010] tw-bg-black tailwind-scope" @@ -332,7 +213,7 @@ const BrainMobile: React.FC = ({ children }) => { {(hasWave || !isApp) && ( = ({ children }) => { animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -20 }} transition={{ duration: 0.2, ease: "easeInOut" }} - className="tw-min-w-0 tw-flex-1" + className="tw-relative tw-min-w-0 tw-flex-1" > - {viewComponents[activeView]} + {shouldMountFloatingQuickVoteEntry && ( + + )} + + {children} + + {shouldMountQuickVoteDialog && ( + + )}
); }; diff --git a/components/brain/left-sidebar/waves/MemesWaveFooter.tsx b/components/brain/left-sidebar/waves/MemesWaveFooter.tsx new file mode 100644 index 0000000000..e4aa89e664 --- /dev/null +++ b/components/brain/left-sidebar/waves/MemesWaveFooter.tsx @@ -0,0 +1,99 @@ +"use client"; + +import MemesWaveQuickVoteTrigger from "@/components/brain/left-sidebar/waves/MemesWaveQuickVoteTrigger"; +import { useMemesWaveFooterStats } from "@/hooks/useMemesWaveFooterStats"; +import { formatNumberWithCommas } from "@/helpers/Helpers"; +import { AnimatePresence, motion } from "framer-motion"; +import { BoltIcon } from "@heroicons/react/24/solid"; +import React from "react"; + +interface MemesWaveFooterProps { + readonly collapsed?: boolean | undefined; + readonly onOpenQuickVote: () => void; + readonly onPrefetchQuickVote?: (() => void) | undefined; +} + +const revealTransition = { + duration: 0.22, + ease: "easeOut", +} as const; + +const MemesWaveFooter: React.FC = ({ + collapsed = false, + onOpenQuickVote, + onPrefetchQuickVote, +}) => { + const { isReady, uncastPower, unratedCount, votingLabel } = + useMemesWaveFooterStats(); + + const handleOpenQuickVote = () => { + if (unratedCount <= 0) { + return; + } + + onOpenQuickVote(); + }; + + const handlePrefetchQuickVote = () => { + if (unratedCount <= 0) { + return; + } + + onPrefetchQuickVote?.(); + }; + + return ( + + {isReady && typeof uncastPower === "number" && ( + + {collapsed ? ( + + ) : ( + + )} + + )} + + ); +}; + +export default MemesWaveFooter; diff --git a/components/brain/left-sidebar/waves/MemesWaveQuickVoteTrigger.tsx b/components/brain/left-sidebar/waves/MemesWaveQuickVoteTrigger.tsx new file mode 100644 index 0000000000..158c9211ec --- /dev/null +++ b/components/brain/left-sidebar/waves/MemesWaveQuickVoteTrigger.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { formatNumberWithCommas } from "@/helpers/Helpers"; +import { BoltIcon } from "@heroicons/react/24/solid"; +import React from "react"; + +interface MemesWaveQuickVoteTriggerProps { + readonly className?: string | undefined; + readonly onOpenQuickVote: () => void; + readonly onPrefetchQuickVote?: (() => void) | undefined; + readonly unratedCount: number; +} + +const MemesWaveQuickVoteTrigger: React.FC = ({ + className, + onOpenQuickVote, + onPrefetchQuickVote, + unratedCount, +}) => { + if (unratedCount <= 0) { + return null; + } + + return ( + + ); +}; + +export default MemesWaveQuickVoteTrigger; diff --git a/components/brain/left-sidebar/waves/memes-quick-vote/MemesQuickVoteControls.tsx b/components/brain/left-sidebar/waves/memes-quick-vote/MemesQuickVoteControls.tsx new file mode 100644 index 0000000000..6e02451ff1 --- /dev/null +++ b/components/brain/left-sidebar/waves/memes-quick-vote/MemesQuickVoteControls.tsx @@ -0,0 +1,237 @@ +"use client"; + +import { AdjustmentsHorizontalIcon } from "@heroicons/react/24/outline"; +import type { ExtendedDrop } from "@/helpers/waves/drop.helpers"; +import { formatNumberWithCommas } from "@/helpers/Helpers"; +import WaveDropAuthorPfp from "@/components/waves/drops/WaveDropAuthorPfp"; +import WaveDropTime from "@/components/waves/drops/time/WaveDropTime"; +import clsx from "clsx"; + +interface MemesQuickVoteControlsProps { + readonly customValue: string; + readonly drop: ExtendedDrop; + readonly isCustomOpen: boolean; + readonly isSubmitting: boolean; + readonly latestUsedAmount: number | null; + readonly remainingCount: number; + readonly quickAmounts: readonly number[]; + readonly uncastPower: number | null; + readonly votingLabel: string | null; + readonly onCustomChange: (value: string) => void; + readonly onCustomSubmit: () => Promise; + readonly onOpenCustom: () => void; + readonly onSkip: () => void; + readonly onVoteAmount: (amount: number) => Promise; +} + +export default function MemesQuickVoteControls({ + customValue, + drop, + isCustomOpen, + isSubmitting, + latestUsedAmount, + remainingCount, + quickAmounts, + uncastPower, + votingLabel, + onCustomChange, + onCustomSubmit, + onOpenCustom, + onSkip, + onVoteAmount, +}: MemesQuickVoteControlsProps) { + const hasQuickAmounts = quickAmounts.length > 0; + const title = + drop.metadata.find((entry) => entry.data_key === "title")?.data_value ?? + "Untitled submission"; + const description = + drop.metadata.find((entry) => entry.data_key === "description") + ?.data_value ?? ""; + const authorLabel = drop.author.handle ?? drop.author.primary_address; + const customAmountLabel = + customValue.trim().length > 0 && Number.parseInt(customValue, 10) > 0 + ? formatNumberWithCommas(Number.parseInt(customValue, 10)) + : null; + + return ( +
+
+
+ {typeof uncastPower === "number" && ( + + {formatNumberWithCommas(uncastPower)} {votingLabel ?? "votes"}{" "} + left + + )} + + {formatNumberWithCommas(remainingCount)} left + +
+ +
+
+ +
+
+ + {authorLabel} + + + + + +
+

+ {drop.wave.name} +

+
+
+ +

+ {title} +

+ + {description && ( +

+ {description} +

+ )} +
+
+ +
+
+

+ Quick Vote +

+

+ Tap once to vote. Skip keeps it for later. +

+
+ + {hasQuickAmounts && ( +
+ {quickAmounts.map((amount) => { + const isLatestUsed = latestUsedAmount === amount; + + return ( + + ); + })} + + +
+ )} + + {(!hasQuickAmounts || isCustomOpen) && ( +
+
+ + + +
+
+ )} + + +
+
+ ); +} diff --git a/components/brain/left-sidebar/waves/memes-quick-vote/MemesQuickVoteDialog.tsx b/components/brain/left-sidebar/waves/memes-quick-vote/MemesQuickVoteDialog.tsx new file mode 100644 index 0000000000..37d7a30866 --- /dev/null +++ b/components/brain/left-sidebar/waves/memes-quick-vote/MemesQuickVoteDialog.tsx @@ -0,0 +1,351 @@ +"use client"; + +import { XMarkIcon } from "@heroicons/react/24/outline"; +import { + getDefaultQuickVoteAmount, + normalizeQuickVoteAmount, +} from "@/hooks/memesQuickVote.helpers"; +import { useMemesQuickVoteQueue } from "@/hooks/useMemesQuickVoteQueue"; +import useIsMobileScreen from "@/hooks/isMobileScreen"; +import { type ReactNode, useEffect, useMemo, useRef, useState } from "react"; +import MemesQuickVoteControls from "./MemesQuickVoteControls"; +import MemesQuickVoteDialogSkeleton from "./MemesQuickVoteDialogSkeleton"; +import MemesQuickVotePreview from "./MemesQuickVotePreview"; + +interface MemesQuickVoteDialogProps { + readonly isOpen: boolean; + readonly sessionId: number; + readonly onClose: () => void; +} + +interface MemesQuickVoteDialogContentProps { + readonly activeDrop: NonNullable< + ReturnType["activeDrop"] + >; + readonly isMobile: boolean; + readonly latestUsedAmount: number | null; + readonly remainingCount: number; + readonly recentAmounts: number[]; + readonly submitVote: ReturnType["submitVote"]; + readonly skipDrop: ReturnType["skipDrop"]; + readonly uncastPower: number | null; + readonly votingLabel: string | null; +} + +function MemesQuickVoteDialogContent({ + activeDrop, + isMobile, + latestUsedAmount, + remainingCount, + recentAmounts, + submitVote, + skipDrop, + uncastPower, + votingLabel, +}: MemesQuickVoteDialogContentProps) { + const maxRating = activeDrop.context_profile_context?.max_rating ?? 0; + const defaultAmount = useMemo( + () => (maxRating > 0 ? getDefaultQuickVoteAmount(maxRating) : 1), + [maxRating] + ); + const [customValue, setCustomValue] = useState(() => `${defaultAmount}`); + const [isCustomOpen, setIsCustomOpen] = useState( + () => recentAmounts.length === 0 + ); + const [isAdvancing, setIsAdvancing] = useState(false); + const normalizedLatestUsedAmount = + latestUsedAmount === null + ? null + : normalizeQuickVoteAmount(latestUsedAmount, maxRating); + const normalizedCustomAmount = normalizeQuickVoteAmount( + customValue, + maxRating + ); + const visibleQuickAmounts = useMemo(() => { + if (maxRating <= 0) { + return []; + } + + return Array.from( + new Set( + recentAmounts + .map((amount) => normalizeQuickVoteAmount(amount, maxRating)) + .filter((amount): amount is number => amount !== null) + ) + ).sort((left, right) => left - right); + }, [maxRating, recentAmounts]); + + const swipeVoteAmount = isCustomOpen + ? normalizedCustomAmount + : (normalizedLatestUsedAmount ?? normalizedCustomAmount); + + const queueVoteAmount = async (amount: number | string) => { + const wasQueued = await submitVote(activeDrop, amount); + + if (!wasQueued) { + setIsAdvancing(false); + } + }; + + const handleVoteAmount = async (amount: number | string) => { + if (isAdvancing) { + return; + } + + setIsAdvancing(true); + await queueVoteAmount(amount); + }; + + const queueSkip = () => { + skipDrop(activeDrop); + }; + + const handleSkip = () => { + if (isAdvancing) { + return; + } + + setIsAdvancing(true); + queueSkip(); + }; + + return ( +
+ { + setIsAdvancing(true); + }} + onSkip={queueSkip} + onVoteWithSwipe={() => { + if (swipeVoteAmount === null) { + setIsAdvancing(false); + return; + } + + void queueVoteAmount(swipeVoteAmount); + }} + /> + +
+ { + if (value === "") { + setCustomValue(""); + return; + } + + setCustomValue(value.replace(/[^\d]/g, "")); + }} + onCustomSubmit={async () => { + await handleVoteAmount(customValue); + }} + onOpenCustom={() => { + setIsCustomOpen((current) => !current); + }} + onSkip={handleSkip} + onVoteAmount={handleVoteAmount} + /> +
+
+ ); +} + +function MemesQuickVoteDialogDoneState() { + return ( +
+
+

+ You are done +

+

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

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

+ Couldn't load your queue +

+

+ Quick vote couldn't reach the leaderboard. Try again. +

+ +
+
+ ); +} + +export default function MemesQuickVoteDialog({ + isOpen, + sessionId, + onClose, +}: MemesQuickVoteDialogProps) { + const dialogRef = useRef(null); + const previouslyFocusedElementRef = useRef(null); + const previousBodyOverflowRef = useRef(""); + const isMobile = useIsMobileScreen(); + const { + activeDrop, + hasDiscoveryError, + isExhausted, + isLoading, + latestUsedAmount, + recentAmounts, + remainingCount, + retryDiscovery, + submitVote, + skipDrop, + uncastPower, + votingLabel, + } = useMemesQuickVoteQueue({ enabled: isOpen, sessionId }); + + useEffect(() => { + const dialog = dialogRef.current; + + if (!dialog) { + return; + } + + if (!isOpen) { + if (dialog.open) { + dialog.close(); + } + document.body.style.overflow = previousBodyOverflowRef.current; + previouslyFocusedElementRef.current?.focus(); + return; + } + + previouslyFocusedElementRef.current = document.activeElement as HTMLElement; + previousBodyOverflowRef.current = document.body.style.overflow; + + if (!dialog.open) { + dialog.showModal(); + } + + document.body.style.overflow = "hidden"; + requestAnimationFrame(() => { + const autofocusTarget = dialog.querySelector( + "[data-autofocus='true']" + ); + autofocusTarget?.focus(); + }); + + return () => { + document.body.style.overflow = previousBodyOverflowRef.current; + }; + }, [isOpen]); + + useEffect(() => { + const dialog = dialogRef.current; + + if (!dialog) { + return; + } + + const handleCancel = (event: Event) => { + event.preventDefault(); + + onClose(); + }; + + dialog.addEventListener("cancel", handleCancel); + return () => { + dialog.removeEventListener("cancel", handleCancel); + }; + }, [onClose]); + + let dialogBody: ReactNode; + + if (isExhausted) { + dialogBody = ; + } else if (!activeDrop && hasDiscoveryError) { + dialogBody = ; + } else if (!activeDrop && isLoading) { + dialogBody = ; + } else if (!activeDrop) { + dialogBody = ; + } else { + dialogBody = ( + + ); + } + + return ( + +
{ + if (event.target !== event.currentTarget) { + return; + } + + onClose(); + }} + > +
+ + +
+ {dialogBody} +
+
+
+
+ ); +} diff --git a/components/brain/left-sidebar/waves/memes-quick-vote/MemesQuickVoteDialogSkeleton.tsx b/components/brain/left-sidebar/waves/memes-quick-vote/MemesQuickVoteDialogSkeleton.tsx new file mode 100644 index 0000000000..f39a91b192 --- /dev/null +++ b/components/brain/left-sidebar/waves/memes-quick-vote/MemesQuickVoteDialogSkeleton.tsx @@ -0,0 +1,140 @@ +"use client"; + +const QUICK_AMOUNT_KEYS = [ + "quick-vote-amount-1", + "quick-vote-amount-2", +] as const; + +function SkeletonBlock({ className }: { readonly className: string }) { + return
; +} + +export default function MemesQuickVoteDialogSkeleton() { + return ( +
+

+ Loading your queue. Pulling unrated memes and your recent quick-vote + amounts. +

+ +
+
+ + + +
+ +
+
+
+
+ +
+
+ + + +
+ +
+
+
+ +
+
+ +
+ + + +
+
+ +
+
+ +
+
+
+
+
+
+ +
+
+
+
+ + +
+ +
+
+ +
+
+ + + +
+ +
+
+ + +
+ + + +
+
+
+ +
+
+ + +
+ +
+ +
+ {QUICK_AMOUNT_KEYS.map((key) => ( + + ))} +
+ +
+
+
+ + +
+ +
+
+ + +
+
+
+ ); +} diff --git a/components/brain/left-sidebar/waves/memes-quick-vote/MemesQuickVotePreview.tsx b/components/brain/left-sidebar/waves/memes-quick-vote/MemesQuickVotePreview.tsx new file mode 100644 index 0000000000..f6dd01a287 --- /dev/null +++ b/components/brain/left-sidebar/waves/memes-quick-vote/MemesQuickVotePreview.tsx @@ -0,0 +1,302 @@ +"use client"; + +import DropListItemContentMedia from "@/components/drops/view/item/content/media/DropListItemContentMedia"; +import WaveDropAuthorPfp from "@/components/waves/drops/WaveDropAuthorPfp"; +import WaveDropTime from "@/components/waves/drops/time/WaveDropTime"; +import { formatNumberWithCommas } from "@/helpers/Helpers"; +import type { ExtendedDrop } from "@/helpers/waves/drop.helpers"; +import clsx from "clsx"; +import React, { useEffect, useMemo, useRef, useState } from "react"; + +const SWIPE_TRIGGER_THRESHOLD = 96; +const MAX_SWIPE_OFFSET = 132; +const SWIPE_EXIT_DURATION_MS = 180; +const SWIPE_EXIT_OFFSET = 420; + +interface MemesQuickVotePreviewProps { + readonly drop: ExtendedDrop; + readonly isBusy: boolean; + readonly isMobile: boolean; + readonly remainingCount: number; + readonly swipeVoteAmount: number | null; + readonly uncastPower: number | null; + readonly votingLabel: string | null; + readonly onAdvanceStart: () => void; + readonly onSkip: () => void; + readonly onVoteWithSwipe: () => void; +} + +function MemesQuickVotePreviewContent({ + drop, + isBusy, + isMobile, + remainingCount, + swipeVoteAmount, + uncastPower, + votingLabel, + onAdvanceStart, + onSkip, + onVoteWithSwipe, +}: MemesQuickVotePreviewProps) { + const [swipeOffset, setSwipeOffset] = useState(0); + const [swipeExitDirection, setSwipeExitDirection] = useState< + "left" | "right" | null + >(null); + const swipeCommitTimeoutRef = useRef(null); + const touchStartXRef = useRef(null); + const touchStartYRef = useRef(null); + + const title = + drop.metadata.find((entry) => entry.data_key === "title")?.data_value ?? + "Untitled submission"; + const description = + drop.metadata.find((entry) => entry.data_key === "description") + ?.data_value ?? ""; + const artworkMedia = drop.parts.at(0)?.media.at(0); + const authorLabel = drop.author.handle ?? drop.author.primary_address; + const swipeHint = useMemo(() => { + if (swipeVoteAmount === null) { + return null; + } + + return `${formatNumberWithCommas(swipeVoteAmount)} ${ + votingLabel ?? "votes" + }`; + }, [swipeVoteAmount, votingLabel]); + + const resetSwipe = () => { + if (swipeExitDirection) { + return; + } + + setSwipeOffset(0); + touchStartXRef.current = null; + touchStartYRef.current = null; + }; + + const clearSwipeCommitTimeout = () => { + if (swipeCommitTimeoutRef.current !== null) { + window.clearTimeout(swipeCommitTimeoutRef.current); + swipeCommitTimeoutRef.current = null; + } + }; + + useEffect(() => { + return () => { + if (swipeCommitTimeoutRef.current !== null) { + window.clearTimeout(swipeCommitTimeoutRef.current); + swipeCommitTimeoutRef.current = null; + } + }; + }, []); + + const beginSwipeCommit = ( + direction: "left" | "right", + action: () => void + ) => { + clearSwipeCommitTimeout(); + setSwipeExitDirection(direction); + setSwipeOffset( + direction === "left" ? -SWIPE_EXIT_OFFSET : SWIPE_EXIT_OFFSET + ); + touchStartXRef.current = null; + touchStartYRef.current = null; + onAdvanceStart(); + swipeCommitTimeoutRef.current = window.setTimeout(() => { + action(); + }, SWIPE_EXIT_DURATION_MS); + }; + + const handleTouchStart = (event: React.TouchEvent) => { + if (isBusy || !isMobile || swipeExitDirection || !event.touches[0]) { + return; + } + + touchStartXRef.current = event.touches[0].clientX; + touchStartYRef.current = event.touches[0].clientY; + }; + + const handleTouchMove = (event: React.TouchEvent) => { + if ( + isBusy || + !isMobile || + swipeExitDirection || + touchStartXRef.current === null || + touchStartYRef.current === null || + !event.touches[0] + ) { + return; + } + + const deltaX = event.touches[0].clientX - touchStartXRef.current; + const deltaY = event.touches[0].clientY - touchStartYRef.current; + + if (Math.abs(deltaY) > Math.abs(deltaX)) { + return; + } + + setSwipeOffset( + Math.max(-MAX_SWIPE_OFFSET, Math.min(deltaX, MAX_SWIPE_OFFSET)) + ); + }; + + const handleTouchEnd = () => { + if (isBusy || !isMobile) { + resetSwipe(); + return; + } + + if (swipeOffset <= -SWIPE_TRIGGER_THRESHOLD) { + beginSwipeCommit("left", onSkip); + return; + } + + if (swipeOffset >= SWIPE_TRIGGER_THRESHOLD && swipeVoteAmount !== null) { + beginSwipeCommit("right", onVoteWithSwipe); + return; + } + + resetSwipe(); + }; + + let cardTransform: React.CSSProperties["transform"]; + if (isMobile) { + if (swipeExitDirection === "left") { + cardTransform = `translateX(-${SWIPE_EXIT_OFFSET}px) rotate(-6deg)`; + } else if (swipeExitDirection === "right") { + cardTransform = `translateX(${SWIPE_EXIT_OFFSET}px) rotate(6deg)`; + } else { + cardTransform = `translateX(${swipeOffset}px)`; + } + } + + return ( +
+
+ {typeof uncastPower === "number" && ( + + {formatNumberWithCommas(uncastPower)} {votingLabel ?? "votes"} left + + )} + + {remainingCount} left + + {isMobile && ( + + Swipe left to skip{swipeHint ? `, right to vote ${swipeHint}` : ""} + + )} +
+ +
+ {isMobile && ( + <> +
+ Skip +
+
0 ? "tw-opacity-100" : "tw-opacity-0" + )} + > + {swipeHint ? `Vote ${swipeHint}` : "Vote"} +
+ + )} + +
+
+
+ +
+
+ + {authorLabel} + + + + + +
+

+ {drop.wave.name} +

+
+
+
+ +
+
+

+ {title} +

+ {description && ( +

+ {description} +

+ )} +
+ + {artworkMedia ? ( +
+
+ +
+
+ ) : ( +
+ Preview unavailable +
+ )} +
+
+
+
+ ); +} + +export default function MemesQuickVotePreview( + props: MemesQuickVotePreviewProps +) { + return ; +} diff --git a/components/brain/left-sidebar/web/WebLeftSidebar.tsx b/components/brain/left-sidebar/web/WebLeftSidebar.tsx index 0625b95572..433a38533c 100644 --- a/components/brain/left-sidebar/web/WebLeftSidebar.tsx +++ b/components/brain/left-sidebar/web/WebLeftSidebar.tsx @@ -3,7 +3,10 @@ import React, { useRef } from "react"; import WebBrainLeftSidebarWaves from "./WebBrainLeftSidebarWaves"; import WebDirectMessagesList from "./WebDirectMessagesList"; +import MemesWaveFooter from "../waves/MemesWaveFooter"; +import MemesQuickVoteDialog from "../waves/memes-quick-vote/MemesQuickVoteDialog"; import { usePathname } from "next/navigation"; +import { useMemesQuickVoteDialogController } from "@/hooks/useMemesQuickVoteDialogController"; import { useSidebarState } from "../../../../hooks/useSidebarState"; import { ChevronDoubleRightIcon } from "@heroicons/react/24/outline"; @@ -21,6 +24,35 @@ interface WebLeftSidebarProps { readonly isCollapsed?: boolean | undefined; } +const WebLeftSidebarQuickVoteOwner: React.FC<{ + readonly isCollapsed: boolean; +}> = ({ isCollapsed }) => { + const { + closeQuickVote, + isQuickVoteOpen, + openQuickVote, + prefetchQuickVote, + quickVoteSessionId, + } = useMemesQuickVoteDialogController(); + + return ( + <> +
+ +
+ + + ); +}; + const WebLeftSidebar: React.FC = ({ isCollapsed = false, }) => { @@ -29,52 +61,59 @@ const WebLeftSidebar: React.FC = ({ const { closeRightSidebar } = useSidebarState(); // Determine content type based on current route/context - WEB SPECIFIC LOGIC - const isMessagesView = pathname?.startsWith("/messages"); + const isMessagesView = pathname.startsWith("/messages"); const expandLabel = isMessagesView ? "Expand messages panel" : "Expand waves panel"; return (
- {isCollapsed && ( -
- -
- )} +
+ {isCollapsed && ( +
+ +
+ )} + {!isMessagesView && ( + + )} + {isMessagesView && ( + + )} +
{!isMessagesView && ( - - )} - {isMessagesView && ( - + )}
diff --git a/components/brain/mobile/BrainMobileTabs.tsx b/components/brain/mobile/BrainMobileTabs.tsx index a30f4a027f..96e370662b 100644 --- a/components/brain/mobile/BrainMobileTabs.tsx +++ b/components/brain/mobile/BrainMobileTabs.tsx @@ -2,7 +2,7 @@ import React, { useCallback, useRef } from "react"; import { useRouter } from "next/navigation"; -import { BrainView } from "../BrainMobile"; +import { BrainView } from "./brainMobileViews"; import type { ApiWave } from "@/generated/models/ApiWave"; import MyStreamWaveTabsLeaderboard from "../my-stream/MyStreamWaveTabsLeaderboard"; import { useLayout } from "../my-stream/layout/LayoutContext"; diff --git a/components/brain/mobile/BrainMobileViewContent.tsx b/components/brain/mobile/BrainMobileViewContent.tsx new file mode 100644 index 0000000000..611b75db9b --- /dev/null +++ b/components/brain/mobile/BrainMobileViewContent.tsx @@ -0,0 +1,95 @@ +"use client"; + +import type { ReactNode } from "react"; +import BrainMobileAbout from "./BrainMobileAbout"; +import type { ExtendedDrop } from "@/helpers/waves/drop.helpers"; +import type { ApiWave } from "@/generated/models/ApiWave"; +import MyStreamWaveLeaderboard from "../my-stream/MyStreamWaveLeaderboard"; +import MyStreamWaveOutcome from "../my-stream/MyStreamWaveOutcome"; +import MyStreamWaveSales from "../my-stream/MyStreamWaveSales"; +import MyStreamWaveMyVotes from "../my-stream/votes/MyStreamWaveMyVotes"; +import MyStreamWaveFAQ from "../my-stream/MyStreamWaveFAQ"; +import BrainMobileWaves from "./BrainMobileWaves"; +import BrainMobileMessages from "./BrainMobileMessages"; +import BrainNotifications from "../notifications/NotificationsContainer"; +import { WaveWinners } from "@/components/waves/winners/WaveWinners"; +import { BrainView } from "./brainMobileViews"; + +interface BrainMobileViewContentProps { + readonly activeView: BrainView; + readonly activeWaveId: string | null; + readonly children: ReactNode; + readonly isCurationWave: boolean; + readonly isMemesWave: boolean; + readonly isRankWave: boolean; + readonly onDropClick: (drop: ExtendedDrop) => void; + readonly onOpenQuickVote: () => void; + readonly onPrefetchQuickVote?: (() => void) | undefined; + readonly wave: ApiWave | null | undefined; +} + +export default function BrainMobileViewContent({ + activeView, + activeWaveId, + children, + isCurationWave, + isMemesWave, + isRankWave, + onDropClick, + onOpenQuickVote, + onPrefetchQuickVote, + wave, +}: BrainMobileViewContentProps) { + const rankWave = isRankWave ? (wave ?? null) : null; + const curationWave = isCurationWave ? (wave ?? null) : null; + const faqWave = isRankWave && isMemesWave ? (wave ?? null) : null; + + const leaderboardContent = rankWave ? ( + + ) : null; + + const salesContent = curationWave ? ( + + ) : null; + + const winnersContent = rankWave ? ( +
+ +
+ ) : null; + + const outcomeContent = rankWave ? ( + + ) : null; + + const myVotesContent = rankWave ? ( + + ) : null; + + const faqContent = faqWave ? : null; + + const contentByView: Record = { + [BrainView.DEFAULT]: <>{children}, + [BrainView.ABOUT]: , + [BrainView.LEADERBOARD]: leaderboardContent, + [BrainView.SALES]: salesContent, + [BrainView.WINNERS]: winnersContent, + [BrainView.OUTCOME]: outcomeContent, + [BrainView.MY_VOTES]: myVotesContent, + [BrainView.FAQ]: faqContent, + [BrainView.WAVES]: ( + + ), + [BrainView.MESSAGES]: , + [BrainView.NOTIFICATIONS]: , + }; + + return contentByView[activeView]; +} diff --git a/components/brain/mobile/BrainMobileWaves.tsx b/components/brain/mobile/BrainMobileWaves.tsx index 1c00bab35f..224756b701 100644 --- a/components/brain/mobile/BrainMobileWaves.tsx +++ b/components/brain/mobile/BrainMobileWaves.tsx @@ -2,20 +2,36 @@ import React, { useRef } from "react"; import BrainLeftSidebarWaves from "../left-sidebar/waves/BrainLeftSidebarWaves"; +import MemesWaveFooter from "../left-sidebar/waves/MemesWaveFooter"; import { useLayout } from "../my-stream/layout/LayoutContext"; -const BrainMobileWaves: React.FC = () => { +interface BrainMobileWavesProps { + readonly onOpenQuickVote: () => void; + readonly onPrefetchQuickVote?: (() => void) | undefined; +} + +const BrainMobileWaves: React.FC = ({ + onOpenQuickVote, + onPrefetchQuickVote, +}) => { const { mobileWavesViewStyle } = useLayout(); const scrollContainerRef = useRef(null); // We'll use the mobileWavesViewStyle for capacitor spacing - let containerClassName = `tw-overflow-y-auto tw-scrollbar-thin tw-scrollbar-thumb-iron-500 tw-scrollbar-track-iron-800 desktop-hover:hover:tw-scrollbar-thumb-iron-300 tw-space-y-4 tw-px-2 sm:tw-px-4 md:tw-px-6 tw-pt-2`; + const scrollContainerClassName = + "tw-min-h-0 tw-flex-1 tw-overflow-y-auto tw-scrollbar-thin tw-scrollbar-thumb-iron-500 tw-scrollbar-track-iron-800 desktop-hover:hover:tw-scrollbar-thumb-iron-300 tw-space-y-4 tw-px-2 sm:tw-px-4 md:tw-px-6 tw-pt-2"; return (
- + > +
+ +
+
); }; diff --git a/components/brain/mobile/FloatingMemesQuickVoteTrigger.tsx b/components/brain/mobile/FloatingMemesQuickVoteTrigger.tsx new file mode 100644 index 0000000000..c446932712 --- /dev/null +++ b/components/brain/mobile/FloatingMemesQuickVoteTrigger.tsx @@ -0,0 +1,30 @@ +"use client"; + +import MemesWaveQuickVoteTrigger from "../left-sidebar/waves/MemesWaveQuickVoteTrigger"; +import { useMemesWaveFooterStats } from "@/hooks/useMemesWaveFooterStats"; + +interface FloatingMemesQuickVoteTriggerProps { + readonly onOpenQuickVote: () => void; + readonly onPrefetchQuickVote?: (() => void) | undefined; +} + +export default function FloatingMemesQuickVoteTrigger({ + onOpenQuickVote, + onPrefetchQuickVote, +}: FloatingMemesQuickVoteTriggerProps) { + const { isReady, uncastPower, unratedCount } = useMemesWaveFooterStats(); + + if (!isReady || typeof uncastPower !== "number" || unratedCount <= 0) { + return null; + } + + return ( +
+ +
+ ); +} diff --git a/components/brain/mobile/brainMobileViews.ts b/components/brain/mobile/brainMobileViews.ts new file mode 100644 index 0000000000..208fb2790c --- /dev/null +++ b/components/brain/mobile/brainMobileViews.ts @@ -0,0 +1,15 @@ +"use client"; + +export enum BrainView { + DEFAULT = "DEFAULT", + ABOUT = "ABOUT", + LEADERBOARD = "LEADERBOARD", + SALES = "SALES", + WINNERS = "WINNERS", + OUTCOME = "OUTCOME", + MY_VOTES = "MY_VOTES", + FAQ = "FAQ", + WAVES = "WAVES", + MESSAGES = "MESSAGES", + NOTIFICATIONS = "NOTIFICATIONS", +} diff --git a/components/brain/mobile/useBrainMobileActiveView.ts b/components/brain/mobile/useBrainMobileActiveView.ts new file mode 100644 index 0000000000..0afcc3770d --- /dev/null +++ b/components/brain/mobile/useBrainMobileActiveView.ts @@ -0,0 +1,189 @@ +"use client"; + +import type { ReadonlyURLSearchParams } from "next/navigation"; +import { useMemo, useState } from "react"; +import type { ApiWave } from "@/generated/models/ApiWave"; +import { BrainView } from "./brainMobileViews"; + +const GLOBAL_VIEWS = new Set([ + BrainView.DEFAULT, + BrainView.WAVES, + BrainView.MESSAGES, + BrainView.NOTIFICATIONS, +]); + +const NON_WAVE_VIEWS = new Set([ + BrainView.NOTIFICATIONS, + BrainView.MESSAGES, + BrainView.WAVES, +]); + +interface UseBrainMobileActiveViewParams { + readonly firstDecisionDone: boolean; + readonly isApp: boolean; + readonly isCompleted: boolean; + readonly isCurationWave: boolean; + readonly isMemesWave: boolean; + readonly isRankWave: boolean; + readonly pathname: string; + readonly searchParams: ReadonlyURLSearchParams; + readonly wave: ApiWave | null | undefined; + readonly waveId: string | null; +} + +interface UseBrainMobileActiveViewResult { + readonly activeView: BrainView; + readonly onViewChange: (view: BrainView) => void; +} + +function getRouteDefaultView({ + createParam, + isApp, + pathname, + viewParam, + waveId, +}: Pick & { + readonly createParam: string | null; + readonly viewParam: string | null; +}): BrainView | null { + if (createParam && isApp) { + return BrainView.DEFAULT; + } + + if ( + (!waveId && pathname === "/notifications") || + (!waveId && viewParam === "notifications") + ) { + return BrainView.NOTIFICATIONS; + } + + if ( + (!waveId && pathname === "/messages") || + (!waveId && viewParam === "messages") + ) { + return BrainView.MESSAGES; + } + + if ( + (!waveId && pathname === "/waves") || + (!waveId && viewParam === "waves") + ) { + return BrainView.WAVES; + } + + if (pathname === "/" && !waveId && !viewParam) { + return BrainView.DEFAULT; + } + + return null; +} + +function normalizeActiveView({ + activeView, + firstDecisionDone, + hasWave, + isCompleted, + isCurationWave, + isMemesWave, + isRankWave, + routeDefaultView, + wave, +}: { + readonly activeView: BrainView; + readonly firstDecisionDone: boolean; + readonly hasWave: boolean; + readonly isCompleted: boolean; + readonly isCurationWave: boolean; + readonly isMemesWave: boolean; + readonly isRankWave: boolean; + readonly routeDefaultView: BrainView | null; + readonly wave: ApiWave | null | undefined; +}): BrainView { + if (!hasWave) { + if (!GLOBAL_VIEWS.has(activeView)) { + return routeDefaultView ?? BrainView.DEFAULT; + } + + return activeView; + } + + if (!wave) { + return activeView; + } + + if (NON_WAVE_VIEWS.has(activeView)) { + return BrainView.DEFAULT; + } + + const shouldResetToDefault = + (activeView === BrainView.LEADERBOARD && (!isRankWave || isCompleted)) || + (activeView === BrainView.SALES && !isCurationWave) || + (activeView === BrainView.WINNERS && (!isRankWave || !firstDecisionDone)) || + (activeView === BrainView.OUTCOME && !isRankWave) || + (activeView === BrainView.MY_VOTES && !isMemesWave && !isCurationWave) || + (activeView === BrainView.FAQ && !isMemesWave); + + return shouldResetToDefault ? BrainView.DEFAULT : activeView; +} + +interface ActiveViewSelection { + readonly contextToken: symbol; + readonly view: BrainView; +} + +export function useBrainMobileActiveView({ + firstDecisionDone, + isApp, + isCompleted, + isCurationWave, + isMemesWave, + isRankWave, + pathname, + searchParams, + wave, + waveId, +}: UseBrainMobileActiveViewParams): UseBrainMobileActiveViewResult { + const [selection, setSelection] = useState(null); + const hasWave = Boolean(waveId); + const viewParam = searchParams.get("view"); + const createParam = searchParams.get("create"); + const routeDefaultView = getRouteDefaultView({ + createParam, + isApp, + pathname, + viewParam, + waveId, + }); + const shellContextKey = `shell:${pathname}:${viewParam ?? ""}`; + const currentContextKey = waveId ? `wave:${waveId}` : shellContextKey; + const currentContextToken = useMemo( + () => Symbol(currentContextKey), + [currentContextKey] + ); + const baseView = hasWave + ? BrainView.DEFAULT + : (routeDefaultView ?? BrainView.DEFAULT); + const candidateView = + selection?.contextToken === currentContextToken ? selection.view : baseView; + + const onViewChange = (view: BrainView) => { + setSelection({ + contextToken: currentContextToken, + view, + }); + }; + + const activeView = normalizeActiveView({ + activeView: candidateView, + firstDecisionDone, + hasWave, + isCompleted, + isCurationWave, + isMemesWave, + isRankWave, + routeDefaultView, + wave, + }); + + return { activeView, onViewChange }; +} diff --git a/components/brain/my-stream/MyStreamWaveTabsLeaderboard.tsx b/components/brain/my-stream/MyStreamWaveTabsLeaderboard.tsx index e86ff53043..85d68dd6e9 100644 --- a/components/brain/my-stream/MyStreamWaveTabsLeaderboard.tsx +++ b/components/brain/my-stream/MyStreamWaveTabsLeaderboard.tsx @@ -1,6 +1,6 @@ import React from "react"; import type { ApiWave } from "@/generated/models/ApiWave"; -import { BrainView } from "../BrainMobile"; +import { BrainView } from "../mobile/brainMobileViews"; import { useWaveTimers } from "@/hooks/useWaveTimers"; type RegisterTabRef = (view: BrainView, el: HTMLButtonElement | null) => void; diff --git a/components/layout/AppLayout.tsx b/components/layout/AppLayout.tsx index 3ea9cc4e57..143e475632 100644 --- a/components/layout/AppLayout.tsx +++ b/components/layout/AppLayout.tsx @@ -19,6 +19,8 @@ import { useAndroidKeyboard } from "@/hooks/useAndroidKeyboard"; import useCapacitor from "@/hooks/useCapacitor"; import PullToRefresh from "../providers/PullToRefresh"; import { getActiveWaveIdFromUrl } from "@/helpers/navigation.helpers"; +import { useMemesQuickVoteDialogController } from "@/hooks/useMemesQuickVoteDialogController"; +import MemesQuickVoteDialog from "../brain/left-sidebar/waves/memes-quick-vote/MemesQuickVoteDialog"; const TouchDeviceHeader = dynamic(() => import("../header/AppHeader"), { ssr: false, @@ -29,6 +31,30 @@ interface Props { readonly children: ReactNode; } +function WavesQuickVoteView() { + const { + closeQuickVote, + isQuickVoteOpen, + openQuickVote, + prefetchQuickVote, + quickVoteSessionId, + } = useMemesQuickVoteDialogController(); + + return ( + <> + + + + ); +} + export default function AppLayout({ children }: Props) { useDeepLinkNavigation(); const { registerRef } = useLayout(); @@ -37,9 +63,9 @@ export default function AppLayout({ children }: Props) { const { activeView } = useViewContext(); const pathname = usePathname(); const searchParams = useSearchParams(); - const isSingleDropOpen = searchParams?.get("drop") !== null; + const isSingleDropOpen = searchParams.get("drop") !== null; const waveParam = getActiveWaveIdFromUrl({ pathname, searchParams }); - const viewParam = searchParams?.get("view"); + const viewParam = searchParams.get("view"); const hasWaveParam = Boolean(waveParam); const isViewingWavesOrMessages = viewParam === "waves" || viewParam === "messages"; @@ -86,7 +112,7 @@ export default function AppLayout({ children }: Props) { {activeView === "messages" ? ( ) : activeView === "waves" ? ( - + ) : (
{children}
)} diff --git a/config/nextConfig.ts b/config/nextConfig.ts index fa51d0f963..0eafd97d98 100644 --- a/config/nextConfig.ts +++ b/config/nextConfig.ts @@ -14,6 +14,7 @@ export function sharedConfig( compress: true, productionBrowserSourceMaps: true, sassOptions: { quietDeps: true }, + allowedDevOrigins: ["192.168.1.133"], images: { loader: "default", remotePatterns: [ diff --git a/docs/specs/2026-03-20-memes-quick-vote-refactor.md b/docs/specs/2026-03-20-memes-quick-vote-refactor.md new file mode 100644 index 0000000000..333468af38 --- /dev/null +++ b/docs/specs/2026-03-20-memes-quick-vote-refactor.md @@ -0,0 +1,297 @@ +--- +title: Memes Quick Vote Refactor +version: 0.1 +status: draft +created: 2026-03-20 +--- + +# Memes Quick Vote Refactor + +## Problem Statement + +Quick vote currently depends on loading the full set of participatory submissions for the memes wave and then deriving quick-vote eligibility on the client. That creates a poor fit for both the footer button and the voting dialog. + +For the footer button, the user only needs a small summary: how much voting power remains and how many submissions are still unrated. Today we still pay the cost of loading every page of participatory submissions before we can show that one number. As the wave grows, the button becomes coupled to repeated pagination, unnecessary network traffic, and avoidable waiting. + +For the voting dialog, the current model also starts from the full dataset, then filters to unrated and still-eligible items, then reorders the queue around skipped submissions. This works, but it is heavier than necessary and pushes too much responsibility for pagination, freshness, and queue assembly into the client. + +The result is a system that is more complex than the user experience requires: + +- The button needs summary data but depends on full queue data. +- The queue needs only the next workable item but depends on the entire history. +- The client has no lightweight way to validate that a skipped or queued submission is still safe to show when it comes back to the front. +- Skip behavior is correct enough today, but it is built on top of a queue assembled from a full historical scan. + +## Goals + +- Decouple the quick-vote button from full submission pagination. +- Reduce initial network work required to open quick vote. +- Move summary ownership and item freshness checks closer to the server. +- Preserve the existing quick-vote behavior from the user perspective where it still makes sense. +- Keep skipped submissions deferred rather than lost. +- Ensure stale or deleted submissions disappear cleanly without breaking the flow. + +## Non-Goals + +- Redesign the quick-vote UI. +- Change the meaning of an unrated submission. +- Change the voting action itself. +- Remove skip as a concept. +- Solve every wave voting use case beyond memes quick vote. + +## Current Behavior Summary + +The current quick-vote flow works roughly like this: + +1. Load every page of participatory submissions for the memes wave for the active viewer context. +2. Filter on the client to keep only submissions that are still eligible for quick vote. +3. Derive footer stats from that filtered set. +4. Build the dialog queue from that same filtered set. +5. Move skipped submissions to the back of the queue using locally stored identifiers. +6. After each vote, update recent amounts, clear the skipped state for the voted submission, optimistically reduce remaining voting power, and invalidate cached drops so the full dataset can be fetched again. + +Important behavior already embedded in the current system: + +- Only unrated submissions are treated as quick-vote candidates. +- A submission must still be votable for the viewer right now. +- Temporary optimistic items are excluded. +- Voting window constraints are enforced. +- Skipped submissions are deferred, not removed. +- Stored skipped entries are cleaned up when they no longer match eligible submissions. + +## Proposed Direction + +The refactor should split quick vote into two distinct concerns: + +### 1. Lightweight Summary Query for the Entry Point + +Reuse the leaderboard endpoint for the footer trigger query by requesting `unvoted_by_me=true` with `page_size=1`. + +The leaderboard endpoint exposes `unvoted_by_me=true` for both the footer summary query and the dialog pagination query used by this refactor. + +- Does the viewer currently have quick-vote work to do? +- How many unvoted quick-vote submissions remain? +- How much voting power remains for this mode? +- What label should be used for that power? + +The footer button should rely on this lightweight query only. It should not need to page through submissions just to render the count and remaining power. + +The intended behavior is: + +- Read `count` from the leaderboard response to determine how many unvoted submissions remain. +- If `count` is `0`, hide the quick-vote button and do not depend on a returned drop. +- If `count` is greater than `0`, use the first returned drop to derive the remaining voting power for this mode. +- If `count` is greater than `0`, use the first returned drop to derive the voting label for that power. + +For memes quick vote, `unvoted_by_me=true` is treated as a confirmed match for the candidate set this flow needs. The first returned drop is treated as a safe, authoritative source for remaining voting power and the corresponding label. + +### 2. Paginated Unvoted Stream for the Dialog + +The dialog should stop treating the full submission history as its source of truth. Instead, it should build a working queue from paginated server results fetched from the leaderboard endpoint with `unvoted_by_me=true`, `sort=CREATED_AT`, and `sort_direction=DESC`, while hydrating only the item that is about to be shown. + +The page endpoint is used to discover submission identifiers in server order. The client should use those pages mainly as a source of ids and order, not as the final truth for what is safe to show on screen. + +With `unvoted_by_me=true`, the paginated stream should already exclude submissions the viewer has rated. That means quick vote no longer needs to scan a broader stream and then strip out already-voted items during page processing. + +The actual item presented to the user should come from a separate single-item fetch. That single-item fetch becomes the freshness checkpoint for display. If the item is no longer present, no longer unvoted, no longer votable, or otherwise no longer usable for quick vote, it should be discarded before the user interacts with it. + +If a prefetched next item becomes active before its fresh single-item revalidation completes, the client may render that prefetched payload briefly while the revalidation request is in flight. Once revalidation completes, the client should either update the rendered item in place with the fresh payload or discard it and advance if it is no longer valid. + +For this refactor, the single-item endpoint is assumed to already expose the viewer-specific state required to decide whether the item is still safe to display and vote. + +For implementation purposes, `GET /drops/{id}` should be treated as returning the viewer-specific quick-vote freshness fields this flow needs. That includes enough information to determine whether the item is still unvoted, still votable, and what voting power and label should be rendered for the active viewer. + +Quick vote remains unavailable for proxy sessions, matching the current behavior. + +This keeps the network work bounded while still protecting the user from seeing stale skipped items or voting against outdated data. + +## Queue Model + +The queue should be treated as a rolling id buffer instead of a full in-memory history. + +Recommended model: + +- Load the first page of submission ids when quick vote opens. +- Build a working client queue from the server-returned unvoted ids, after removing ids that are already deferred in local skip storage. +- Treat the active queue depth as the number of usable non-skipped ids only. Deferred skipped ids do not count toward replenishment thresholds. +- Hydrate only the current item to render, and prefetch only the next item so normal progression feels instant. Do not keep a deeper hydrated lookahead for now. +- Treat the prefetched next item as a fast-path candidate, not as final truth. When it becomes active, immediately revalidate it with a fresh single-item fetch. +- After each vote or skip, advance to the next hydrated item immediately when available. +- When the remaining usable unvoted id buffer reaches 5 or fewer items, prefetch the next page of ids in the background. +- Keep at most one page-pagination request in flight at a time. +- Merge page results idempotently. An id that is already present in the active queue, already deferred, or already removed as handled or stale during this session must not be re-added. +- If a fetched page contributes no usable active ids because all returned ids are currently deferred as skipped, fetch the next page immediately when more pages remain. +- If the active usable queue still has 5 or fewer items after a page is merged, continue fetching subsequent pages one page at a time until the usable queue is replenished or the server reports no more pages. +- Do not request another page if the server has already indicated there are no more pages. +- If the user advances faster than the next item can be hydrated, show a loader until the next item is ready. +- A prefetched item may render briefly before revalidation completes. That is acceptable for this flow. +- Stop fetching when the server indicates there are no more pages. + +The dialog state machine should distinguish between these states explicitly: + +- `waiting-for-item`: there is no currently renderable hydrated item, but the flow still has pending work. That includes cases where a page fetch is in flight, an item hydration or revalidation is in flight, usable ids remain in the working buffer, or deferred skipped items still remain to be checked. In this state, keep the dialog open and show a loader or transition state instead of treating the session as complete. +- `exhausted`: there is no currently renderable hydrated item, no usable ids remain in the working buffer, no deferred skipped items remain, no more pages are available, no page fetch is in flight, no item hydration or revalidation is in flight, and the required fresh restart discovery pass has already produced no usable items. Only then should the flow move to the final done state. + +This gives the user a fast first interaction while still avoiding full-history loading. It also lets the client protect the user from stale or deleted skipped items by validating the actual item only when it is about to matter. + +## Handling Newly Stale or Newly Voted Submissions + +The paginated endpoint should be called with `unvoted_by_me=true`, so the server stream is already narrowed to submissions the viewer has not rated yet. + +The spec should therefore state this behavior explicitly: + +- Already voted submissions should be excluded by the server before they reach quick-vote pagination. +- Already voted submissions must never enter the working queue. +- Already voted submissions must never enter the local skipped pool. +- If an item was unvoted when the page was fetched but is already voted by the time it is hydrated, the client should drop it and continue. + +This keeps the queue aligned with the purpose of quick vote while still acknowledging that items can change state after page fetch. + +## Skip Semantics + +Skip should remain a local deferral mechanism, not a server-side rating state. + +The intended behavior is: + +- Skipping a submission moves it behind currently unskipped submissions. +- A skipped submission remains available later in the same session and in future browser sessions if it is still valid. +- Skipping does not change the user’s remaining voting power. +- Skipping does not count as completing work. + +To preserve this with paginated loading: + +- Keep skipped identifiers in local storage scoped to the viewer and the memes wave so they persist across browser sessions. +- Preserve skip order in local storage so deferred items return in a stable local order later. +- When unvoted ids arrive from paginated fetches, remove skipped ids from the immediate working queue and keep them in the deferred pool instead. +- When the user eventually gets back to deferred items, hydrate the actual item before showing it. +- If a deferred item is gone, already rated, or otherwise no longer valid when hydrated, remove it from both the working queue and local skip storage. + +Queue exhaustion should work like this: + +- First consume the normal paginated non-skipped queue in server order. +- When there are no more normal pages in the current pass, switch to deferred skipped items. +- When a skipped item is voted successfully, remove it from local skip storage immediately. +- When the deferred skipped pool is exhausted, restart paginated discovery from page `1`. +- If the new page-`1` pass yields valid non-skipped items, continue the normal queue again. +- If the new page-`1` pass yields no valid non-skipped items, fall back to deferred skipped items again if any still exist. +- Show the final "you are done" state only when a fresh page-`1` discovery pass yields no usable items and the deferred skipped pool is also empty. + +This makes skip behave like a real local “not now” mechanism. Items the user does not want to see immediately should not keep jumping back to the front just because the server still sorts them first. + +## Validity and Freshness Rules + +The new design should assume queued ids can become stale at any time. + +That means the system must handle all of these cleanly: + +- a submission is deleted +- a submission becomes ineligible +- the voting window closes +- the user votes on the submission elsewhere +- remaining voting power changes unexpectedly + +The safest rule is: + +- the lightweight summary query owns entry-point truth +- paginated fetch owns server ordering +- single-item hydration owns display freshness +- vote response owns write-time truth + +Implications: + +- Summary values should always come from the leaderboard response fetched with `unvoted_by_me=true` and `page_size=1`, not from locally counting buffered ids. +- If that response reports `count = 0`, the button should be hidden even if the client still has stale local quick-vote state. +- The page endpoint should be treated as a discovery source, not as a guarantee that the next item is still displayable. +- The paginated stream should be requested with `unvoted_by_me=true`, so page-fetch results can be treated as unvoted at fetch time. +- The item about to be shown should be hydrated individually before display whenever needed. +- The dialog should not hold or optimistically recompute a separate local remaining-voting-power value. Each hydrated drop should carry the viewer-specific voting data it needs to render itself. +- After a vote succeeds, a prefetched next item may still be shown briefly, but it must be revalidated when it becomes active and then either updated in place or discarded if the fresh payload shows it is no longer usable. +- A successful vote response should immediately update local queue state and clear local skip state for that id. +- Skip should not trigger remaining-power refresh behavior, because skipping does not change remaining voting power or complete any voting work. +- If hydration shows that the next item is gone, already voted, or no longer usable, the client should drop that id from the queue, remove it from local skip storage if present, and advance again. +- A temporary absence of a hydrated active item must not be treated as completion if pagination, hydration, revalidation, or deferred-item recovery is still in progress. +- If the user advances faster than hydration completes, showing a loader is acceptable. +- If background prefetch returns fewer useful unvoted items than expected because items disappeared or were already handled elsewhere, that should be treated as normal. + +## Data Responsibility Split + +The refactor is cleaner if responsibilities are sharply separated. + +Server responsibilities: + +- compute remaining unvoted count +- compute remaining voting power +- return `count` for the leaderboard query used by the footer trigger +- return the first unvoted submission when `page_size=1` and unvoted work exists +- return paginated submissions in stable server order +- apply `unvoted_by_me=true` so already-voted submissions are excluded from the quick-vote stream +- return fresh single-item data for the item being shown, including the viewer-specific voting data that item needs to render itself + +Client responsibilities: + +- show summary state +- hold the current working id buffer +- hydrate the current and next item only +- persist and apply skip ordering locally across browser sessions +- persist recent quick-vote amounts locally +- handle optimistic UI movement after success +- avoid maintaining a separate local remaining-voting-power state for the dialog +- dedupe ids across page merges and ignore late page results that no longer fit current queue state +- remove stale, deleted, or already-handled ids from local state when hydration exposes them +- recover smoothly from stale-item failures + +## Proposed Rollout + +### Phase 1: Footer Summary Query + +Replace footer-button derivation with a lightweight leaderboard query using `unvoted_by_me=true` and `page_size=1`. This gives immediate value because it removes the need to fetch the entire participatory set just to show the entry point. + +Expected outcome: + +- the button becomes cheap to render +- the button no longer waits on multi-page submission fetching +- the remaining count and remaining power become explicitly server-owned + +### Phase 2: Paginated Queue + +Refactor the dialog to use paginated discovery plus single-item hydration for the current and next item only. + +Expected outcome: + +- opening quick vote becomes faster +- memory and network usage are bounded +- the client stops rebuilding the queue from full history +- the client no longer needs to filter already voted items out of paginated results +- skipped items no longer need to be blindly trusted when they come back later +- stale-item handling becomes simpler and more explicit + +### Phase 3: Hardening + +After the core shift is working, tighten the edge cases: + +- confirm skip persistence behavior across browser sessions +- confirm restart-from-page-`0` behavior after deferred skipped items are exhausted +- confirm id-deduping and usable-queue replenishment behavior during background pagination + +## Success Criteria + +- The quick-vote button no longer requires full submission pagination. +- Opening the quick-vote dialog does not require loading the complete participatory history up front. +- Already voted submissions never become active quick-vote items. +- The queue continues to support skip without repeatedly surfacing deferred items at the front. +- Skipped submissions persist across browser sessions in stable local order until they are voted or become invalid. +- Deleted, stale, or already-voted submissions are removed before or during display without breaking the flow. +- Each displayed quick-vote item renders from its own fresh viewer-specific voting data without a separate locally tracked remaining-power state. + +## Risks + +- If single-item hydration does not consistently return the viewer-specific voting data needed for rendering, the dialog may drift back toward shared remaining-power state or incomplete item payloads. +- If page filtering against skipped ids is not applied before queue-depth checks, the client may stop fetching too early and surface skipped items before the normal pass is truly exhausted. +- If page-merge logic does not dedupe ids correctly across pagination passes and page-`0` restarts, the queue may resurface handled items or feel unstable. +- If skip ordering is applied too aggressively across newly fetched pages, the queue may feel unstable or surprising. +- If stale-item failures are not treated as normal, the flow will still feel brittle even with better pagination. +- If the server order is not stable enough, background prefetch may produce duplicates or confusing jumps. + +## Open Questions + +- None currently. This spec assumes skipped submissions persist across browser sessions and hydration stays limited to the immediate current item plus the next item. diff --git a/hooks/memesQuickVote.helpers.ts b/hooks/memesQuickVote.helpers.ts new file mode 100644 index 0000000000..cbcbf04ac3 --- /dev/null +++ b/hooks/memesQuickVote.helpers.ts @@ -0,0 +1,143 @@ +import type { ApiDrop } from "@/generated/models/ApiDrop"; +import type { ApiDropWithoutWave } from "@/generated/models/ApiDropWithoutWave"; +import type { ApiWaveMin } from "@/generated/models/ApiWaveMin"; +import { WAVE_VOTING_LABELS } from "@/helpers/waves/waves.constants"; + +export const MEMES_QUICK_VOTE_DISCOVERY_PAGE_SIZE = 20 as const; +export const MEMES_QUICK_VOTE_REPLENISH_THRESHOLD = 5 as const; +export const MEMES_QUICK_VOTE_SUMMARY_PAGE_SIZE = 1 as const; +const QUICK_VOTE_DEFAULT_PERCENTAGE = 0.01; +const MAX_QUICK_VOTE_AMOUNTS = 5; + +export type MemesQuickVoteStats = { + readonly uncastPower: number | null; + readonly unratedCount: number; + readonly votingLabel: string | null; +}; + +export const sanitizeStoredDropIds = (value: unknown): string[] => { + if (!Array.isArray(value)) { + return []; + } + + const seen = new Set(); + const ids: string[] = []; + + for (const entry of value) { + if (typeof entry !== "string") { + continue; + } + + const trimmed = entry.trim(); + + if (trimmed.length === 0 || seen.has(trimmed)) { + continue; + } + + seen.add(trimmed); + ids.push(trimmed); + } + + return ids; +}; + +export const sanitizeStoredAmounts = (value: unknown): number[] => { + if (!Array.isArray(value)) { + return []; + } + + const seen = new Set(); + const amounts: number[] = []; + + for (const entry of value) { + if ( + typeof entry !== "number" || + !Number.isFinite(entry) || + !Number.isInteger(entry) || + entry <= 0 || + seen.has(entry) + ) { + continue; + } + + seen.add(entry); + amounts.push(entry); + } + + return amounts.slice(-MAX_QUICK_VOTE_AMOUNTS); +}; + +export const buildMemesQuickVoteApiDrop = ( + drop: ApiDropWithoutWave, + wave: ApiWaveMin +): ApiDrop => ({ + ...drop, + wave, +}); + +export const deriveMemesQuickVoteStatsFromDrop = ({ + count, + drop, +}: { + readonly count: number; + readonly drop: ApiDrop | null; +}): MemesQuickVoteStats => { + if (count <= 0 || !drop) { + return { + uncastPower: null, + unratedCount: 0, + votingLabel: null, + }; + } + + const uncastPower = drop.context_profile_context?.max_rating ?? null; + + return { + uncastPower: + typeof uncastPower === "number" && uncastPower > 0 ? uncastPower : null, + unratedCount: count, + votingLabel: WAVE_VOTING_LABELS[drop.wave.voting_credit_type], + }; +}; + +export const appendSkippedDropId = ( + ids: readonly string[], + dropId: string +): string[] => [...ids.filter((value) => value !== dropId), dropId]; + +export const addRecentQuickVoteAmount = ( + amounts: readonly number[], + amount: number +): number[] => + [...amounts.filter((value) => value !== amount), amount].slice( + -MAX_QUICK_VOTE_AMOUNTS + ); + +export const getDisplayQuickVoteAmounts = ( + amounts: readonly number[] +): number[] => [...amounts].sort((left, right) => left - right); + +export const getDefaultQuickVoteAmount = (maxRating: number): number => { + const normalizedMaxRating = Math.max(1, Math.floor(maxRating)); + return Math.max( + 1, + Math.min( + normalizedMaxRating, + Math.round(normalizedMaxRating * QUICK_VOTE_DEFAULT_PERCENTAGE) + ) + ); +}; + +export const normalizeQuickVoteAmount = ( + rawValue: number | string, + maxRating: number +): number | null => { + const parsedValue = + typeof rawValue === "number" ? rawValue : Number.parseInt(rawValue, 10); + + if (!Number.isFinite(parsedValue) || maxRating <= 0) { + return null; + } + + return Math.max(1, Math.min(Math.round(parsedValue), Math.floor(maxRating))); +}; diff --git a/hooks/memesQuickVote.query.ts b/hooks/memesQuickVote.query.ts new file mode 100644 index 0000000000..c85eedc9b2 --- /dev/null +++ b/hooks/memesQuickVote.query.ts @@ -0,0 +1,172 @@ +import { QueryKey } from "@/components/react-query-wrapper/ReactQueryWrapper"; +import type { ApiDrop } from "@/generated/models/ApiDrop"; +import type { ApiDropsLeaderboardPage } from "@/generated/models/ApiDropsLeaderboardPage"; +import { + buildMemesQuickVoteApiDrop, + MEMES_QUICK_VOTE_DISCOVERY_PAGE_SIZE, + MEMES_QUICK_VOTE_REPLENISH_THRESHOLD, + MEMES_QUICK_VOTE_SUMMARY_PAGE_SIZE, +} from "@/hooks/memesQuickVote.helpers"; +import { + deriveMemesQuickVoteDiscoverySnapshot, + shouldFetchMemesQuickVotePage, + type MemesQuickVoteDiscoveryPage, + type MemesQuickVoteDiscoveryState, +} from "@/hooks/memesQuickVote.queue.helpers"; +import { commonApiFetch } from "@/services/api/common-api"; + +const MEMES_QUICK_VOTE_DISCOVERY_SORT = "CREATED_AT" as const; +const MEMES_QUICK_VOTE_DISCOVERY_SORT_DIRECTION = "DESC" as const; + +export type MemesQuickVoteDiscoveryQueryData = { + readonly pages: readonly MemesQuickVoteDiscoveryPage[]; +}; + +export const getMemesQuickVoteDropQueryKey = (dropId: string) => + [ + QueryKey.DROP, + { + context: "memes-quick-vote", + drop_id: dropId, + }, + ] as const; + +export const fetchMemesQuickVoteDrop = async (dropId: string) => + await commonApiFetch({ + endpoint: `drops/${dropId}`, + }); + +export const getMemesQuickVoteSummaryQueryKey = ({ + contextProfile, + proxyId, + waveId, +}: { + readonly contextProfile: string | null | undefined; + readonly proxyId: string | null | undefined; + readonly waveId: string | null; +}) => + [ + QueryKey.DROPS_LEADERBOARD, + { + context: "memes-quick-vote-summary", + context_profile: contextProfile, + page: 1, + page_size: MEMES_QUICK_VOTE_SUMMARY_PAGE_SIZE, + proxyId, + sort: MEMES_QUICK_VOTE_DISCOVERY_SORT, + sort_direction: MEMES_QUICK_VOTE_DISCOVERY_SORT_DIRECTION, + unvoted_by_me: true, + waveId, + }, + ] as const; + +export const fetchMemesQuickVoteSummary = async (waveId: string | null) => { + if (waveId === null) { + throw new Error("Memes quick vote summary requires a wave id"); + } + + return await commonApiFetch({ + endpoint: `waves/${waveId}/leaderboard`, + params: { + page: "1", + page_size: `${MEMES_QUICK_VOTE_SUMMARY_PAGE_SIZE}`, + sort: MEMES_QUICK_VOTE_DISCOVERY_SORT, + sort_direction: MEMES_QUICK_VOTE_DISCOVERY_SORT_DIRECTION, + unvoted_by_me: "true", + }, + }); +}; + +export const getMemesQuickVoteDiscoveryStateKey = ({ + contextProfile, + enabled, + memesWaveId, + sessionId, +}: { + readonly contextProfile: string | null | undefined; + readonly enabled: boolean; + readonly memesWaveId: string | null | undefined; + readonly sessionId: number; +}): string => + `${sessionId}:${memesWaveId ?? ""}:${contextProfile ?? ""}:${enabled ? "1" : "0"}`; + +export const getMemesQuickVoteDiscoveryQueryKey = ({ + discoveryStateKey, + fetchVersion, + waveId, +}: { + readonly discoveryStateKey: string; + readonly fetchVersion: number; + readonly waveId: string | null; +}) => + [ + QueryKey.DROPS_LEADERBOARD, + { + context: "memes-quick-vote-discovery", + fetchVersion, + identity: discoveryStateKey, + page_size: MEMES_QUICK_VOTE_DISCOVERY_PAGE_SIZE, + sort: MEMES_QUICK_VOTE_DISCOVERY_SORT, + sort_direction: MEMES_QUICK_VOTE_DISCOVERY_SORT_DIRECTION, + unvoted_by_me: true, + waveId, + }, + ] as const; + +export const fetchMemesQuickVoteDiscoveryBatch = async ({ + discoveryState, + skippedDropIds, + waveId, +}: { + readonly discoveryState: MemesQuickVoteDiscoveryState; + readonly skippedDropIds: readonly string[]; + readonly waveId: string | null; +}): Promise => { + if (waveId === null) { + throw new Error("Memes quick vote discovery requires a wave id"); + } + + const pages: MemesQuickVoteDiscoveryPage[] = []; + let nextPageToFetch: number | null = 1; + + while (nextPageToFetch !== null) { + const page = await commonApiFetch({ + endpoint: `waves/${waveId}/leaderboard`, + params: { + page: `${nextPageToFetch}`, + page_size: `${MEMES_QUICK_VOTE_DISCOVERY_PAGE_SIZE}`, + sort: MEMES_QUICK_VOTE_DISCOVERY_SORT, + sort_direction: MEMES_QUICK_VOTE_DISCOVERY_SORT_DIRECTION, + unvoted_by_me: "true", + }, + }); + + pages.push({ + drops: page.drops.map((drop) => + buildMemesQuickVoteApiDrop(drop, page.wave) + ), + nextPage: page.next ? page.page + 1 : null, + pageCount: page.count, + }); + + const snapshot = deriveMemesQuickVoteDiscoverySnapshot({ + enabled: true, + pages, + skippedDropIds, + state: discoveryState, + }); + + if ( + !shouldFetchMemesQuickVotePage({ + replenishThreshold: MEMES_QUICK_VOTE_REPLENISH_THRESHOLD, + state: snapshot, + }) + ) { + break; + } + + nextPageToFetch = snapshot.nextPage; + } + + return { pages }; +}; diff --git a/hooks/memesQuickVote.queue.helpers.ts b/hooks/memesQuickVote.queue.helpers.ts new file mode 100644 index 0000000000..dc92b65977 --- /dev/null +++ b/hooks/memesQuickVote.queue.helpers.ts @@ -0,0 +1,215 @@ +import type { ApiDrop } from "@/generated/models/ApiDrop"; + +export type MemesQuickVoteDiscoveryState = { + readonly deferredIds: string[]; + readonly removedIds: string[]; +}; + +export type MemesQuickVoteDiscoveryPage = { + readonly drops: readonly ApiDrop[]; + readonly nextPage: number | null; + readonly pageCount: number; +}; + +type MemesQuickVoteDiscoverySnapshot = { + readonly activeIds: string[]; + readonly deferredIds: string[]; + readonly discoveredDropsById: Record; + readonly nextPage: number | null; + readonly serverCount: number | null; +}; + +export const createInitialMemesQuickVoteDiscoveryState = ({ + enabled: _enabled, +}: { + readonly enabled?: boolean | undefined; +} = {}): MemesQuickVoteDiscoveryState => ({ + deferredIds: [], + removedIds: [], +}); + +const hasId = (ids: readonly string[], targetId: string): boolean => + ids.includes(targetId); + +const getActiveDeferredIds = ({ + discoveredDropsById, + removedIds, + skippedDropIds, + state, +}: { + readonly discoveredDropsById: Record; + readonly removedIds: ReadonlySet; + readonly skippedDropIds: readonly string[]; + readonly state: MemesQuickVoteDiscoveryState; +}): string[] => { + const localDeferredIds = state.deferredIds.filter( + (dropId) => !removedIds.has(dropId) && !!discoveredDropsById[dropId] + ); + const localDeferredIdSet = new Set(localDeferredIds); + const persistedDeferredIds = skippedDropIds.filter( + (dropId) => + !removedIds.has(dropId) && + !!discoveredDropsById[dropId] && + !localDeferredIdSet.has(dropId) + ); + + return [...persistedDeferredIds, ...localDeferredIds]; +}; + +export const deriveMemesQuickVoteDiscoverySnapshot = ({ + enabled, + pages, + skippedDropIds, + state, +}: { + readonly enabled: boolean; + readonly pages: readonly MemesQuickVoteDiscoveryPage[]; + readonly skippedDropIds: readonly string[]; + readonly state: MemesQuickVoteDiscoveryState; +}): MemesQuickVoteDiscoverySnapshot => { + if (pages.length === 0) { + return { + activeIds: [], + deferredIds: [], + discoveredDropsById: {}, + nextPage: enabled ? 1 : null, + serverCount: null, + }; + } + + const discoveredDropsById: Record = {}; + const discoveredIds: string[] = []; + + for (const page of pages) { + for (const drop of page.drops) { + if (!discoveredDropsById[drop.id]) { + discoveredIds.push(drop.id); + } + + discoveredDropsById[drop.id] = drop; + } + } + + const removedIds = new Set(state.removedIds); + const deferredIds = getActiveDeferredIds({ + discoveredDropsById, + removedIds, + skippedDropIds, + state, + }); + const deferredIdSet = new Set(deferredIds); + const activeIds = discoveredIds.filter( + (dropId) => !removedIds.has(dropId) && !deferredIdSet.has(dropId) + ); + const lastPage = pages[pages.length - 1]; + + return { + activeIds, + deferredIds, + discoveredDropsById, + nextPage: lastPage?.nextPage ?? null, + serverCount: lastPage?.pageCount ?? null, + }; +}; + +export const removeMemesQuickVoteDropId = ({ + dropId, + state, +}: { + readonly dropId: string; + readonly state: MemesQuickVoteDiscoveryState; +}): MemesQuickVoteDiscoveryState => { + if (hasId(state.removedIds, dropId) && !hasId(state.deferredIds, dropId)) { + return state; + } + + return { + deferredIds: state.deferredIds.filter((value) => value !== dropId), + removedIds: hasId(state.removedIds, dropId) + ? state.removedIds + : [...state.removedIds, dropId], + }; +}; + +export const deferMemesQuickVoteDropId = ({ + dropId, + state, +}: { + readonly dropId: string; + readonly state: MemesQuickVoteDiscoveryState; +}): MemesQuickVoteDiscoveryState => { + const nextDeferredIds = [ + ...state.deferredIds.filter((value) => value !== dropId), + dropId, + ]; + + if ( + nextDeferredIds.length === state.deferredIds.length && + nextDeferredIds.every((value, index) => value === state.deferredIds[index]) + ) { + return state; + } + + return { + deferredIds: nextDeferredIds, + removedIds: state.removedIds.filter((value) => value !== dropId), + }; +}; + +export const getMemesQuickVoteActiveCandidateId = ( + state: MemesQuickVoteDiscoverySnapshot +): string | null => { + const activeCandidateId = state.activeIds[0]; + + if (activeCandidateId) { + return activeCandidateId; + } + + if (state.nextPage !== null) { + return null; + } + + return state.deferredIds[0] ?? null; +}; + +export const getMemesQuickVoteNextCandidateId = ( + state: MemesQuickVoteDiscoverySnapshot +): string | null => { + if (state.activeIds.length > 1) { + return state.activeIds[1] ?? null; + } + + if (state.activeIds.length === 1) { + return state.nextPage === null ? (state.deferredIds[0] ?? null) : null; + } + + if (state.nextPage !== null) { + return null; + } + + return state.deferredIds[1] ?? null; +}; + +export const getMemesQuickVoteDiscoveredQueue = ( + state: MemesQuickVoteDiscoverySnapshot +): ApiDrop[] => + [...state.activeIds, ...state.deferredIds].flatMap((dropId) => { + const drop = state.discoveredDropsById[dropId]; + return drop ? [drop] : []; + }); + +export const shouldFetchMemesQuickVotePage = ({ + replenishThreshold, + state, +}: { + readonly replenishThreshold: number; + readonly state: MemesQuickVoteDiscoverySnapshot; +}): boolean => + state.nextPage !== null && state.activeIds.length <= replenishThreshold; + +export const isMemesQuickVoteExhausted = ( + state: MemesQuickVoteDiscoverySnapshot +): boolean => + state.nextPage === null && + state.activeIds.length === 0 && + state.deferredIds.length === 0; diff --git a/hooks/memesQuickVote.storageStore.ts b/hooks/memesQuickVote.storageStore.ts new file mode 100644 index 0000000000..93d2a71267 --- /dev/null +++ b/hooks/memesQuickVote.storageStore.ts @@ -0,0 +1,209 @@ +"use client"; + +import { safeLocalStorage } from "@/helpers/safeLocalStorage"; +import { useCallback, useSyncExternalStore } from "react"; + +const STORAGE_EVENT = "memesQuickVoteStorageChange"; + +type StoredScalar = number | string; +type ArraySanitizer = (value: unknown) => T[]; +type StorageChangeDetail = { + readonly key: string; +}; + +type CacheEntry = { + raw: string | null; + value: StoredScalar[]; +}; + +const EMPTY_ARRAY: StoredScalar[] = []; +const cachedByKey = new Map(); + +const areStoredArraysEqual = ( + left: readonly StoredScalar[], + right: readonly StoredScalar[] +): boolean => + left.length === right.length && + left.every((value, index) => value === right[index]); + +const getCachedEntry = (key: string): CacheEntry => { + const existing = cachedByKey.get(key); + + if (existing) { + return existing; + } + + const created: CacheEntry = { + raw: null, + value: EMPTY_ARRAY, + }; + cachedByKey.set(key, created); + return created; +}; + +const cacheSnapshot = ( + key: string, + raw: string | null, + next: T[] +): T[] => { + const entry = getCachedEntry(key); + const normalizedNext: T[] = next.length === 0 ? (EMPTY_ARRAY as T[]) : next; + + if (entry.raw === raw) { + return entry.value as T[]; + } + + entry.raw = raw; + + if (areStoredArraysEqual(entry.value, normalizedNext)) { + return entry.value as T[]; + } + + entry.value = normalizedNext; + return normalizedNext; +}; + +const readStoredArray = ( + key: string | null, + sanitize: ArraySanitizer +): T[] => { + if (!key) { + return EMPTY_ARRAY as T[]; + } + + const stored = safeLocalStorage.getItem(key); + const entry = getCachedEntry(key); + + if (entry.raw === stored) { + return entry.value as T[]; + } + + if (!stored) { + return cacheSnapshot(key, null, []); + } + + try { + return cacheSnapshot(key, stored, sanitize(JSON.parse(stored))); + } catch { + return cacheSnapshot(key, stored, []); + } +}; + +const writeStoredArray = ( + key: string | null, + values: readonly StoredScalar[] +): void => { + if (!key) { + return; + } + + const next = [...values]; + const serialized = next.length === 0 ? null : JSON.stringify(next); + + if (serialized === null) { + safeLocalStorage.removeItem(key); + } else { + safeLocalStorage.setItem(key, serialized); + } + + cacheSnapshot(key, serialized, next); + + if (typeof window === "undefined") { + return; + } + + window.dispatchEvent( + new CustomEvent(STORAGE_EVENT, { + detail: { key }, + }) + ); +}; + +const subscribeToStoredArray = ( + key: string | null, + onStoreChange: () => void +): (() => void) => { + if (!key || typeof window === "undefined") { + return () => {}; + } + + const handleStorage = (event: StorageEvent) => { + if (event.key !== null && event.key !== key) { + return; + } + + onStoreChange(); + }; + + const handleCustomChange = (event: Event) => { + if (!(event instanceof CustomEvent)) { + return; + } + + const detail = event.detail as StorageChangeDetail | null; + + if (detail?.key !== key) { + return; + } + + onStoreChange(); + }; + + window.addEventListener("storage", handleStorage); + window.addEventListener(STORAGE_EVENT, handleCustomChange); + + return () => { + window.removeEventListener("storage", handleStorage); + window.removeEventListener(STORAGE_EVENT, handleCustomChange); + }; +}; + +const useStoredArray = ( + key: string | null, + sanitize: ArraySanitizer +): T[] => { + const subscribe = useCallback( + (onStoreChange: () => void) => subscribeToStoredArray(key, onStoreChange), + [key] + ); + const getSnapshot = useCallback( + () => readStoredArray(key, sanitize), + [key, sanitize] + ); + + return useSyncExternalStore(subscribe, getSnapshot, () => EMPTY_ARRAY as T[]); +}; + +export const readStoredNumberArray = ( + key: string | null, + sanitize: ArraySanitizer +): number[] => readStoredArray(key, sanitize); + +export const writeStoredNumberArray = ( + key: string | null, + values: readonly number[] +): void => { + writeStoredArray(key, values); +}; + +export const useStoredNumberArray = ( + key: string | null, + sanitize: ArraySanitizer +): number[] => useStoredArray(key, sanitize); + +export const readStoredStringArray = ( + key: string | null, + sanitize: ArraySanitizer +): string[] => readStoredArray(key, sanitize); + +export const writeStoredStringArray = ( + key: string | null, + values: readonly string[] +): void => { + writeStoredArray(key, values); +}; + +export const useStoredStringArray = ( + key: string | null, + sanitize: ArraySanitizer +): string[] => useStoredArray(key, sanitize); diff --git a/hooks/useMemesQuickVoteActiveDrop.ts b/hooks/useMemesQuickVoteActiveDrop.ts new file mode 100644 index 0000000000..ae28f8e37e --- /dev/null +++ b/hooks/useMemesQuickVoteActiveDrop.ts @@ -0,0 +1,145 @@ +"use client"; + +import { QueryKey } from "@/components/react-query-wrapper/ReactQueryWrapper"; +import { getDefaultQueryRetry } from "@/components/react-query-wrapper/utils/query-utils"; +import type { ApiDrop } from "@/generated/models/ApiDrop"; +import { ApiDropType } from "@/generated/models/ApiDropType"; +import { convertApiDropToExtendedDrop } from "@/helpers/waves/drop.helpers"; +import { + fetchMemesQuickVoteDrop, + getMemesQuickVoteDropQueryKey, +} from "@/hooks/memesQuickVote.query"; +import { Time } from "@/helpers/time"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useEffect, useMemo } from "react"; + +const shouldInvalidateActiveDrop = ( + drop: ApiDrop, + now = Time.currentMillis() +): boolean => { + const profileContext = drop.context_profile_context; + + if (drop.drop_type !== ApiDropType.Participatory) { + return true; + } + + if ( + profileContext?.rating !== 0 || + typeof profileContext.max_rating !== "number" + ) { + return true; + } + + // Preserve zero-power cards so the queue can show an exhausted state + // instead of discarding otherwise valid candidates for the session. + + if ( + !drop.wave.authenticated_user_eligible_to_vote || + drop.id.startsWith("temp-") + ) { + return true; + } + + const votingPeriodStart = drop.wave.voting_period_start; + + if (votingPeriodStart !== null && now < votingPeriodStart) { + return true; + } + + const votingPeriodEnd = drop.wave.voting_period_end; + + return votingPeriodEnd !== null && now > votingPeriodEnd; +}; + +export const useMemesQuickVoteActiveDrop = ({ + activeCandidateId, + discoveredDropsById, + enabled, + nextCandidateId, + onInvalidatedDrop, +}: { + readonly activeCandidateId: string | null; + readonly discoveredDropsById: Record; + readonly enabled: boolean; + readonly nextCandidateId: string | null; + readonly onInvalidatedDrop: (dropId: string) => void; +}) => { + const queryClient = useQueryClient(); + const activeInitialDrop = activeCandidateId + ? discoveredDropsById[activeCandidateId] + : undefined; + + const activeQuery = useQuery({ + queryKey: activeCandidateId + ? getMemesQuickVoteDropQueryKey(activeCandidateId) + : [QueryKey.DROP, { context: "memes-quick-vote", drop_id: null }], + queryFn: () => fetchMemesQuickVoteDrop(activeCandidateId!), + enabled: enabled && !!activeCandidateId, + initialData: activeInitialDrop, + refetchOnReconnect: false, + refetchOnWindowFocus: false, + staleTime: 0, + ...getDefaultQueryRetry(), + }); + + const isUsingInitialData = + !!activeInitialDrop && + activeQuery.data === activeInitialDrop && + !activeQuery.isFetchedAfterMount; + const hasFreshData = !!activeQuery.data && !isUsingInitialData; + + useEffect(() => { + if (!enabled || !nextCandidateId) { + return; + } + + void queryClient.prefetchQuery({ + queryKey: getMemesQuickVoteDropQueryKey(nextCandidateId), + queryFn: () => fetchMemesQuickVoteDrop(nextCandidateId), + staleTime: 0, + ...getDefaultQueryRetry(), + }); + }, [enabled, nextCandidateId, queryClient]); + + useEffect(() => { + if (!activeCandidateId || !activeQuery.data || !hasFreshData) { + return; + } + + if (!shouldInvalidateActiveDrop(activeQuery.data)) { + return; + } + + onInvalidatedDrop(activeCandidateId); + }, [activeCandidateId, activeQuery.data, hasFreshData, onInvalidatedDrop]); + + useEffect(() => { + if (!activeCandidateId || activeQuery.isFetching || !activeQuery.error) { + return; + } + + onInvalidatedDrop(activeCandidateId); + }, [ + activeCandidateId, + activeQuery.error, + activeQuery.isFetching, + onInvalidatedDrop, + ]); + + const activeDrop = useMemo(() => { + if (!activeQuery.data) { + return null; + } + + return convertApiDropToExtendedDrop(activeQuery.data); + }, [activeQuery.data]); + + return { + activeDrop, + hasFreshData, + isLoading: + !!activeCandidateId && + !activeQuery.data && + (activeQuery.isLoading || activeQuery.isFetching), + }; +}; diff --git a/hooks/useMemesQuickVoteContext.ts b/hooks/useMemesQuickVoteContext.ts new file mode 100644 index 0000000000..ed97e93d6c --- /dev/null +++ b/hooks/useMemesQuickVoteContext.ts @@ -0,0 +1,32 @@ +"use client"; + +import { useAuth } from "@/components/auth/Auth"; +import { useSeizeSettings } from "@/contexts/SeizeSettingsContext"; +import { useMemo } from "react"; + +export const useMemesQuickVoteContext = () => { + const { connectedProfile, activeProfileProxy } = useAuth(); + const { seizeSettings, isLoaded } = useSeizeSettings(); + + const contextProfile = useMemo(() => { + const normalizedHandle = connectedProfile?.handle?.trim(); + return normalizedHandle ?? null; + }, [connectedProfile?.handle]); + + const memesWaveId = seizeSettings.memes_wave_id ?? null; + const isEnabled = + isLoaded && + typeof memesWaveId === "string" && + memesWaveId.length > 0 && + typeof contextProfile === "string" && + contextProfile.length > 0 && + activeProfileProxy === null; + + return { + contextProfile, + isEnabled, + isLoaded, + memesWaveId, + proxyId: activeProfileProxy?.id ?? null, + }; +}; diff --git a/hooks/useMemesQuickVoteDialogController.ts b/hooks/useMemesQuickVoteDialogController.ts new file mode 100644 index 0000000000..8c9edd296c --- /dev/null +++ b/hooks/useMemesQuickVoteDialogController.ts @@ -0,0 +1,65 @@ +"use client"; + +import { usePrefetchMemesQuickVote } from "@/hooks/usePrefetchMemesQuickVote"; +import { useCallback, useRef, useState } from "react"; + +type UseMemesQuickVoteDialogControllerResult = { + readonly closeQuickVote: () => void; + readonly isQuickVoteOpen: boolean; + readonly openQuickVote: () => void; + readonly prefetchQuickVote: () => void; + readonly quickVoteSessionId: number; +}; + +export const useMemesQuickVoteDialogController = + (): UseMemesQuickVoteDialogControllerResult => { + const prefetchMemesQuickVote = usePrefetchMemesQuickVote(); + const [isQuickVoteOpen, setIsQuickVoteOpen] = useState(false); + const [quickVoteSessionId, setQuickVoteSessionId] = useState(0); + const lastIssuedSessionIdRef = useRef(0); + const reservedSessionIdRef = useRef(null); + const prefetchedSessionIdsRef = useRef(new Set()); + + const reserveSessionId = useCallback(() => { + if (reservedSessionIdRef.current !== null) { + return reservedSessionIdRef.current; + } + + const nextSessionId = lastIssuedSessionIdRef.current + 1; + reservedSessionIdRef.current = nextSessionId; + return nextSessionId; + }, []); + + const prefetchQuickVote = useCallback(() => { + const sessionId = reserveSessionId(); + + if (prefetchedSessionIdsRef.current.has(sessionId)) { + return; + } + + prefetchedSessionIdsRef.current.add(sessionId); + void prefetchMemesQuickVote(sessionId); + }, [prefetchMemesQuickVote, reserveSessionId]); + + const openQuickVote = useCallback(() => { + const sessionId = + reservedSessionIdRef.current ?? lastIssuedSessionIdRef.current + 1; + + lastIssuedSessionIdRef.current = sessionId; + reservedSessionIdRef.current = null; + setQuickVoteSessionId(sessionId); + setIsQuickVoteOpen(true); + }, []); + + const closeQuickVote = useCallback(() => { + setIsQuickVoteOpen(false); + }, []); + + return { + closeQuickVote, + isQuickVoteOpen, + openQuickVote, + prefetchQuickVote, + quickVoteSessionId, + }; + }; diff --git a/hooks/useMemesQuickVoteDiscovery.ts b/hooks/useMemesQuickVoteDiscovery.ts new file mode 100644 index 0000000000..e0ec2e084c --- /dev/null +++ b/hooks/useMemesQuickVoteDiscovery.ts @@ -0,0 +1,280 @@ +"use client"; + +import { + appendSkippedDropId, + MEMES_QUICK_VOTE_REPLENISH_THRESHOLD, +} from "@/hooks/memesQuickVote.helpers"; +import { + createInitialMemesQuickVoteDiscoveryState, + deferMemesQuickVoteDropId, + deriveMemesQuickVoteDiscoverySnapshot, + getMemesQuickVoteActiveCandidateId, + getMemesQuickVoteDiscoveredQueue, + getMemesQuickVoteNextCandidateId, + isMemesQuickVoteExhausted, + type MemesQuickVoteDiscoveryPage, + type MemesQuickVoteDiscoveryState, + removeMemesQuickVoteDropId, + shouldFetchMemesQuickVotePage, +} from "@/hooks/memesQuickVote.queue.helpers"; +import { + fetchMemesQuickVoteDiscoveryBatch, + getMemesQuickVoteDiscoveryQueryKey, + getMemesQuickVoteDiscoveryStateKey, + type MemesQuickVoteDiscoveryQueryData, +} from "@/hooks/memesQuickVote.query"; +import { useMemesQuickVoteContext } from "@/hooks/useMemesQuickVoteContext"; +import { useQuery } from "@tanstack/react-query"; +import { useCallback, useState } from "react"; + +const EMPTY_DISCOVERY_PAGES: readonly MemesQuickVoteDiscoveryPage[] = []; + +type KeyedMemesQuickVoteDiscoveryState = MemesQuickVoteDiscoveryState & { + readonly fetchVersion: number; + readonly key: string; +}; + +const createKeyedMemesQuickVoteDiscoveryState = ( + key: string +): KeyedMemesQuickVoteDiscoveryState => ({ + ...createInitialMemesQuickVoteDiscoveryState(), + fetchVersion: 0, + key, +}); + +const getCurrentMemesQuickVoteDiscoveryState = ({ + key, + state, +}: { + readonly key: string; + readonly state: KeyedMemesQuickVoteDiscoveryState; +}): KeyedMemesQuickVoteDiscoveryState => + state.key === key ? state : createKeyedMemesQuickVoteDiscoveryState(key); + +const getMemesQuickVoteDiscoveryQueryKeyIdentity = ( + candidate: unknown +): string | null => { + if (candidate === undefined || candidate === null) { + return null; + } + + if (typeof candidate !== "object" || !("identity" in candidate)) { + return null; + } + + const identity = candidate.identity; + + return typeof identity === "string" ? identity : null; +}; + +const shouldReuseDiscoveryPlaceholderData = ({ + identity, + previousQuery, +}: { + readonly identity: string; + readonly previousQuery: + | { + readonly queryKey: readonly unknown[]; + } + | undefined; +}): boolean => { + const previousQueryIdentity = getMemesQuickVoteDiscoveryQueryKeyIdentity( + previousQuery?.queryKey[1] + ); + + return previousQueryIdentity === identity; +}; + +const applyMemesQuickVoteDiscoveryStateUpdate = ({ + current, + discoveryPages, + discoveryStateKey, + isDiscoveryEnabled, + nextSkippedDropIds, + transformState, +}: { + readonly current: KeyedMemesQuickVoteDiscoveryState; + readonly discoveryPages: readonly MemesQuickVoteDiscoveryPage[]; + readonly discoveryStateKey: string; + readonly isDiscoveryEnabled: boolean; + readonly nextSkippedDropIds: readonly string[]; + readonly transformState: ( + state: KeyedMemesQuickVoteDiscoveryState + ) => MemesQuickVoteDiscoveryState; +}): KeyedMemesQuickVoteDiscoveryState => { + const baseState = getCurrentMemesQuickVoteDiscoveryState({ + key: discoveryStateKey, + state: current, + }); + const nextState = transformState(baseState); + + if (nextState === baseState) { + return baseState; + } + + const nextSnapshot = deriveMemesQuickVoteDiscoverySnapshot({ + enabled: isDiscoveryEnabled, + pages: discoveryPages, + skippedDropIds: nextSkippedDropIds, + state: nextState, + }); + + return { + ...nextState, + fetchVersion: shouldFetchMemesQuickVotePage({ + replenishThreshold: MEMES_QUICK_VOTE_REPLENISH_THRESHOLD, + state: nextSnapshot, + }) + ? baseState.fetchVersion + 1 + : baseState.fetchVersion, + key: discoveryStateKey, + }; +}; + +export const useMemesQuickVoteDiscovery = ({ + enabled, + sessionId, + skippedDropIds, +}: { + readonly enabled: boolean; + readonly sessionId: number; + readonly skippedDropIds: readonly string[]; +}) => { + const { contextProfile, isEnabled, memesWaveId } = useMemesQuickVoteContext(); + const waveId = + typeof memesWaveId === "string" && memesWaveId.length > 0 + ? memesWaveId + : null; + const isDiscoveryEnabled = enabled && isEnabled; + const discoveryStateKey = getMemesQuickVoteDiscoveryStateKey({ + contextProfile, + enabled: isDiscoveryEnabled, + memesWaveId: waveId, + sessionId, + }); + const [storedState, setStoredState] = + useState(() => + createKeyedMemesQuickVoteDiscoveryState(discoveryStateKey) + ); + const discoveryState = getCurrentMemesQuickVoteDiscoveryState({ + key: discoveryStateKey, + state: storedState, + }); + const queryKey = getMemesQuickVoteDiscoveryQueryKey({ + discoveryStateKey, + fetchVersion: discoveryState.fetchVersion, + waveId, + }); + const fetchDiscoveryBatch = useCallback( + async (): Promise => + await fetchMemesQuickVoteDiscoveryBatch({ + discoveryState, + skippedDropIds, + waveId, + }), + [discoveryState, skippedDropIds, waveId] + ); + + const { + data: discoveryData, + isError: hasPageFetchError, + isFetching: isFetchingPage, + refetch: refetchDiscovery, + } = useQuery({ + queryKey, + enabled: isDiscoveryEnabled && waveId !== null, + queryFn: fetchDiscoveryBatch, + placeholderData: (previousData, previousQuery) => + shouldReuseDiscoveryPlaceholderData({ + identity: discoveryStateKey, + previousQuery, + }) + ? previousData + : undefined, + refetchOnReconnect: false, + refetchOnWindowFocus: false, + retry: false, + }); + + const discoveryPages = discoveryData?.pages ?? EMPTY_DISCOVERY_PAGES; + const discoverySnapshot = deriveMemesQuickVoteDiscoverySnapshot({ + enabled: isDiscoveryEnabled, + pages: discoveryPages, + skippedDropIds, + state: discoveryState, + }); + const activeCandidateId = + getMemesQuickVoteActiveCandidateId(discoverySnapshot); + const nextCandidateId = getMemesQuickVoteNextCandidateId(discoverySnapshot); + const queue = getMemesQuickVoteDiscoveredQueue(discoverySnapshot); + + const removeDropId = useCallback( + (dropId: string) => { + setStoredState((current) => + applyMemesQuickVoteDiscoveryStateUpdate({ + current, + discoveryPages, + discoveryStateKey, + isDiscoveryEnabled, + nextSkippedDropIds: skippedDropIds.filter( + (value) => value !== dropId + ), + transformState: (state) => + removeMemesQuickVoteDropId({ dropId, state }), + }) + ); + }, + [discoveryPages, discoveryStateKey, isDiscoveryEnabled, skippedDropIds] + ); + + const deferDropId = useCallback( + (dropId: string) => { + setStoredState((current) => + applyMemesQuickVoteDiscoveryStateUpdate({ + current, + discoveryPages, + discoveryStateKey, + isDiscoveryEnabled, + nextSkippedDropIds: appendSkippedDropId(skippedDropIds, dropId), + transformState: (state) => + deferMemesQuickVoteDropId({ dropId, state }), + }) + ); + }, + [discoveryPages, discoveryStateKey, isDiscoveryEnabled, skippedDropIds] + ); + + const retryDiscovery = useCallback(() => { + void refetchDiscovery(); + }, [refetchDiscovery]); + + const resyncDiscovery = useCallback(() => { + setStoredState((current) => { + const baseState = getCurrentMemesQuickVoteDiscoveryState({ + key: discoveryStateKey, + state: current, + }); + + return { + ...createInitialMemesQuickVoteDiscoveryState(), + fetchVersion: baseState.fetchVersion + 1, + key: discoveryStateKey, + }; + }); + }, [discoveryStateKey]); + + return { + activeCandidateId, + deferDropId, + discoveredDropsById: discoverySnapshot.discoveredDropsById, + hasPageFetchError, + isExhausted: isMemesQuickVoteExhausted(discoverySnapshot), + isFetchingPage, + nextCandidateId, + queue, + removeDropId, + resyncDiscovery, + retryDiscovery, + serverCount: discoverySnapshot.serverCount, + }; +}; diff --git a/hooks/useMemesQuickVoteQueue.ts b/hooks/useMemesQuickVoteQueue.ts new file mode 100644 index 0000000000..32d61d3d83 --- /dev/null +++ b/hooks/useMemesQuickVoteQueue.ts @@ -0,0 +1,588 @@ +"use client"; + +import { AuthContext } from "@/components/auth/Auth"; +import { ReactQueryWrapperContext } from "@/components/react-query-wrapper/ReactQueryWrapper"; +import type { ApiDrop } from "@/generated/models/ApiDrop"; +import type { ExtendedDrop } from "@/helpers/waves/drop.helpers"; +import { + appendSkippedDropId, + getDisplayQuickVoteAmounts, +} from "@/hooks/memesQuickVote.helpers"; +import { useMemesQuickVoteActiveDrop } from "@/hooks/useMemesQuickVoteActiveDrop"; +import { useMemesQuickVoteContext } from "@/hooks/useMemesQuickVoteContext"; +import { useMemesQuickVoteDiscovery } from "@/hooks/useMemesQuickVoteDiscovery"; +import { useMemesQuickVoteStorage } from "@/hooks/useMemesQuickVoteStorage"; +import { useMemesQuickVoteSubmit } from "@/hooks/useMemesQuickVoteSubmit"; +import { useMemesQuickVoteSummary } from "@/hooks/useMemesQuickVoteSummary"; +import { convertApiDropToExtendedDrop } from "@/helpers/waves/drop.helpers"; +import { WAVE_VOTING_LABELS } from "@/helpers/waves/waves.constants"; +import { useCallback, useContext, useMemo, useState } from "react"; + +type UseMemesQuickVoteQueueOptions = { + readonly enabled?: boolean | undefined; + readonly sessionId: number; +}; + +type UseMemesQuickVoteQueueResult = { + readonly activeDrop: ExtendedDrop | null; + readonly hasDiscoveryError: boolean; + readonly isExhausted: boolean; + readonly isLoading: boolean; + readonly isReady: boolean; + readonly isVoting: boolean; + readonly latestUsedAmount: number | null; + readonly queue: ExtendedDrop[]; + readonly recentAmounts: number[]; + readonly remainingCount: number; + readonly retryDiscovery: () => void; + readonly submitVote: ( + drop: ExtendedDrop, + amount: number | string + ) => Promise; + readonly skipDrop: (drop: ExtendedDrop) => void; + readonly uncastPower: number | null; + readonly votingLabel: string | null; +}; + +type OptimisticRemainingPowerState = { + readonly key: string; + readonly pendingVotes: { + readonly amount: number; + readonly dropId: string; + }[]; + readonly optimisticVoteCount: number; + readonly settledRemainingPower: number | null; + readonly summaryBaselineCount: number | null; +}; + +type UseMemesQuickVoteQueueDerivedState = Pick< + UseMemesQuickVoteQueueResult, + | "activeDrop" + | "hasDiscoveryError" + | "isExhausted" + | "isLoading" + | "isReady" + | "latestUsedAmount" + | "queue" + | "recentAmounts" + | "remainingCount" + | "uncastPower" + | "votingLabel" +>; + +type UseDerivedMemesQuickVoteQueueStateOptions = { + readonly activeDropCandidate: ExtendedDrop | null; + readonly activeIsLoading: boolean; + readonly discoveredQueue: readonly ApiDrop[]; + readonly effectiveOptimisticRemainingPower: number | null; + readonly enabled: boolean; + readonly hasPageFetchError: boolean; + readonly isDiscoveryExhausted: boolean; + readonly isFetchingPage: boolean; + readonly isSettingsLoaded: boolean; + readonly isSummaryPending: boolean; + readonly isSummaryQuickVoteEnabled: boolean; + readonly isSummarySuccess: boolean; + readonly recentAmountsByRecency: readonly number[]; + readonly serverCount: number | null; + readonly summaryCount: number; + readonly unreflectedVoteCount: number; + readonly summaryVotingLabel: string | null; +}; + +const getOptimisticRemainingPowerKey = ({ + contextProfile, + memesWaveId, + sessionId, +}: { + readonly contextProfile: string | null | undefined; + readonly memesWaveId: string | null | undefined; + readonly sessionId: number; +}): string => `${sessionId}:${memesWaveId ?? ""}:${contextProfile ?? ""}`; + +const createInitialOptimisticRemainingPowerState = ( + key: string +): OptimisticRemainingPowerState => ({ + key, + pendingVotes: [], + optimisticVoteCount: 0, + settledRemainingPower: null, + summaryBaselineCount: null, +}); + +const getCurrentOptimisticRemainingPowerState = ({ + key, + state, +}: { + readonly key: string; + readonly state: OptimisticRemainingPowerState; +}): OptimisticRemainingPowerState => + state.key === key ? state : createInitialOptimisticRemainingPowerState(key); + +const deriveSummaryOptimismState = ({ + hasPendingVotes, + isSummarySuccess, + optimisticVoteCount, + summaryBaselineCount, + summaryCount, +}: { + readonly hasPendingVotes: boolean; + readonly isSummarySuccess: boolean; + readonly optimisticVoteCount: number; + readonly summaryBaselineCount: number | null; + readonly summaryCount: number; +}) => { + if (summaryBaselineCount === null || optimisticVoteCount === 0) { + return { + isSettled: true, + unreflectedVoteCount: 0, + }; + } + + const reflectedVoteCount = isSummarySuccess + ? Math.max(0, summaryBaselineCount - summaryCount) + : 0; + const isSettled = + !hasPendingVotes && + isSummarySuccess && + reflectedVoteCount >= optimisticVoteCount; + + return { + isSettled, + unreflectedVoteCount: isSettled + ? 0 + : Math.max(0, optimisticVoteCount - reflectedVoteCount), + }; +}; + +const clampDropMaxRating = ( + drop: ExtendedDrop, + optimisticRemainingPower: number | null +): ExtendedDrop => { + const profileContext = drop.context_profile_context; + const maxRating = profileContext?.max_rating; + + if ( + optimisticRemainingPower === null || + profileContext?.rating !== 0 || + typeof maxRating !== "number" + ) { + return drop; + } + + const nextMaxRating = Math.max( + 0, + Math.min(maxRating, optimisticRemainingPower) + ); + + if (nextMaxRating === maxRating) { + return drop; + } + + return { + ...drop, + context_profile_context: { + ...profileContext, + max_rating: nextMaxRating, + }, + }; +}; + +const isVoteableQuickVoteDrop = (drop: ExtendedDrop | null): boolean => { + const profileContext = drop?.context_profile_context; + + return ( + profileContext?.rating === 0 && + typeof profileContext.max_rating === "number" && + profileContext.max_rating > 0 + ); +}; + +const useDerivedMemesQuickVoteQueueState = ({ + activeDropCandidate, + activeIsLoading, + discoveredQueue, + effectiveOptimisticRemainingPower, + enabled, + hasPageFetchError, + isDiscoveryExhausted, + isFetchingPage, + isSettingsLoaded, + isSummaryPending, + isSummaryQuickVoteEnabled, + isSummarySuccess, + recentAmountsByRecency, + serverCount, + summaryCount, + unreflectedVoteCount, + summaryVotingLabel, +}: UseDerivedMemesQuickVoteQueueStateOptions): UseMemesQuickVoteQueueDerivedState => { + const clampedActiveDrop = useMemo( + () => + activeDropCandidate + ? clampDropMaxRating( + activeDropCandidate, + effectiveOptimisticRemainingPower + ) + : null, + [activeDropCandidate, effectiveOptimisticRemainingPower] + ); + const activeDrop = isVoteableQuickVoteDrop(clampedActiveDrop) + ? clampedActiveDrop + : null; + + const queue = useMemo(() => { + const clampedQueue = discoveredQueue + .map(convertApiDropToExtendedDrop) + .map((drop) => + clampDropMaxRating(drop, effectiveOptimisticRemainingPower) + ); + + if (!clampedActiveDrop) { + return clampedQueue; + } + + return clampedQueue.map((drop) => + drop.id === clampedActiveDrop.id ? clampedActiveDrop : drop + ); + }, [clampedActiveDrop, discoveredQueue, effectiveOptimisticRemainingPower]); + + const recentAmounts = useMemo( + () => getDisplayQuickVoteAmounts(recentAmountsByRecency), + [recentAmountsByRecency] + ); + const latestUsedAmount = recentAmountsByRecency.at(-1) ?? null; + const uncastPower = activeDrop?.context_profile_context?.max_rating ?? null; + const votingLabel = activeDrop + ? WAVE_VOTING_LABELS[activeDrop.wave.voting_credit_type] + : summaryVotingLabel; + const remainingCount = isSummarySuccess + ? Math.max(queue.length, Math.max(0, summaryCount - unreflectedVoteCount)) + : Math.max(serverCount ?? 0, queue.length); + const hasDiscoveryError = enabled && hasPageFetchError; + const isQuickVoteUnavailable = + enabled && isSettingsLoaded && !isSummaryQuickVoteEnabled; + const activeProfileContext = clampedActiveDrop?.context_profile_context; + const isPowerExhausted = + enabled && + !hasDiscoveryError && + activeProfileContext?.rating === 0 && + typeof activeProfileContext.max_rating === "number" && + activeProfileContext.max_rating <= 0; + const isExhausted = + enabled && + (isQuickVoteUnavailable || isDiscoveryExhausted || isPowerExhausted); + const isLoading = + enabled && + !hasDiscoveryError && + !isExhausted && + !activeDropCandidate && + (!isSettingsLoaded || + (isSummaryQuickVoteEnabled && isSummaryPending) || + isFetchingPage || + activeIsLoading); + + return { + activeDrop, + hasDiscoveryError, + isExhausted, + isLoading, + isReady: activeDrop !== null, + latestUsedAmount, + queue, + recentAmounts, + remainingCount, + uncastPower, + votingLabel, + }; +}; + +export const useMemesQuickVoteQueue = ({ + enabled = true, + sessionId, +}: UseMemesQuickVoteQueueOptions): UseMemesQuickVoteQueueResult => { + const { requestAuth, setToast } = useContext(AuthContext); + const { invalidateDrops } = useContext(ReactQueryWrapperContext); + const { + contextProfile, + isLoaded: isSettingsLoaded, + isEnabled: isQuickVoteEnabled, + memesWaveId, + } = useMemesQuickVoteContext(); + const optimisticRemainingPowerKey = getOptimisticRemainingPowerKey({ + contextProfile, + memesWaveId, + sessionId, + }); + const [optimisticRemainingPowerState, setOptimisticRemainingPowerState] = + useState(() => + createInitialOptimisticRemainingPowerState(optimisticRemainingPowerKey) + ); + const { + recentAmountsByRecency, + setAndPersistRecentAmounts, + setAndPersistSkippedDropIds, + skippedDropIds, + } = useMemesQuickVoteStorage({ + contextProfile, + memesWaveId, + }); + const summary = useMemesQuickVoteSummary({ enabled }); + const discovery = useMemesQuickVoteDiscovery({ + enabled, + sessionId, + skippedDropIds, + }); + const { + activeCandidateId, + deferDropId, + discoveredDropsById, + hasPageFetchError, + isExhausted: isDiscoveryExhausted, + isFetchingPage, + nextCandidateId, + queue: discoveredQueue, + removeDropId, + resyncDiscovery, + retryDiscovery, + serverCount, + } = discovery; + const { + count: summaryCount, + isPending: isSummaryPending, + isQuickVoteEnabled: isSummaryQuickVoteEnabled, + isSuccess: isSummarySuccess, + refetch: refetchSummary, + stats: summaryStats, + } = summary; + const currentOptimisticRemainingPowerState = + getCurrentOptimisticRemainingPowerState({ + key: optimisticRemainingPowerKey, + state: optimisticRemainingPowerState, + }); + const hasPendingOptimisticVotes = + currentOptimisticRemainingPowerState.pendingVotes.length > 0; + const optimisticRemainingPower = useMemo(() => { + const pendingSpent = + currentOptimisticRemainingPowerState.pendingVotes.reduce( + (sum, vote) => sum + vote.amount, + 0 + ); + const settledRemainingPower = + currentOptimisticRemainingPowerState.settledRemainingPower; + + if (settledRemainingPower === null) { + return null; + } + + return Math.max(0, settledRemainingPower - pendingSpent); + }, [currentOptimisticRemainingPowerState]); + const summaryOptimismState = useMemo( + () => + deriveSummaryOptimismState({ + hasPendingVotes: hasPendingOptimisticVotes, + isSummarySuccess, + optimisticVoteCount: + currentOptimisticRemainingPowerState.optimisticVoteCount, + summaryBaselineCount: + currentOptimisticRemainingPowerState.summaryBaselineCount, + summaryCount, + }), + [ + currentOptimisticRemainingPowerState.optimisticVoteCount, + currentOptimisticRemainingPowerState.summaryBaselineCount, + hasPendingOptimisticVotes, + isSummarySuccess, + summaryCount, + ] + ); + + const handleInvalidatedDrop = useCallback( + (dropId: string) => { + removeDropId(dropId); + setAndPersistSkippedDropIds((current) => + current.filter((value) => value !== dropId) + ); + // Refresh the shared footer summary without blocking queue advancement. + void refetchSummary(); + }, + [removeDropId, refetchSummary, setAndPersistSkippedDropIds] + ); + + const active = useMemesQuickVoteActiveDrop({ + activeCandidateId, + discoveredDropsById, + enabled: enabled && isQuickVoteEnabled, + nextCandidateId, + onInvalidatedDrop: handleInvalidatedDrop, + }); + const effectiveOptimisticRemainingPower = + active.hasFreshData && !hasPendingOptimisticVotes + ? null + : optimisticRemainingPower; + + const handleVoteQueued = useCallback( + (drop: ExtendedDrop, amount: number) => { + removeDropId(drop.id); + setOptimisticRemainingPowerState((current) => { + const baseState = getCurrentOptimisticRemainingPowerState({ + key: optimisticRemainingPowerKey, + state: current, + }); + const reflectedVoteCount = + baseState.summaryBaselineCount === null || !isSummarySuccess + ? 0 + : Math.max(0, baseState.summaryBaselineCount - summaryCount); + const isSummaryCycleSettled = + baseState.summaryBaselineCount !== null && + baseState.optimisticVoteCount > 0 && + baseState.pendingVotes.length === 0 && + isSummarySuccess && + reflectedVoteCount >= baseState.optimisticVoteCount; + const shouldResetSummaryCycle = + baseState.summaryBaselineCount === null || + baseState.optimisticVoteCount === 0 || + isSummaryCycleSettled; + + return { + ...baseState, + pendingVotes: [ + ...baseState.pendingVotes, + { + amount, + dropId: drop.id, + }, + ], + settledRemainingPower: + baseState.pendingVotes.length === 0 + ? (drop.context_profile_context?.max_rating ?? null) + : (baseState.settledRemainingPower ?? + drop.context_profile_context?.max_rating ?? + null), + optimisticVoteCount: + shouldResetSummaryCycle || !isSummarySuccess + ? 1 + : baseState.optimisticVoteCount + 1, + summaryBaselineCount: + isSummarySuccess && shouldResetSummaryCycle + ? summaryCount + : baseState.summaryBaselineCount, + }; + }); + }, + [isSummarySuccess, optimisticRemainingPowerKey, removeDropId, summaryCount] + ); + + const handleVoteSuccess = useCallback( + (_drop: ExtendedDrop, _amount: number, nextRemainingPower: number) => { + setOptimisticRemainingPowerState((current) => { + const baseState = getCurrentOptimisticRemainingPowerState({ + key: optimisticRemainingPowerKey, + state: current, + }); + const currentVote = baseState.pendingVotes[0]; + + if (!currentVote) { + return { + ...baseState, + settledRemainingPower: nextRemainingPower, + }; + } + + return { + ...baseState, + pendingVotes: baseState.pendingVotes.slice(1), + settledRemainingPower: nextRemainingPower, + }; + }); + }, + [optimisticRemainingPowerKey] + ); + + const handleVoteFailure = useCallback( + (_drop: ExtendedDrop, _amount: number) => { + setOptimisticRemainingPowerState((current) => { + const baseState = getCurrentOptimisticRemainingPowerState({ + key: optimisticRemainingPowerKey, + state: current, + }); + + if (baseState.pendingVotes.length === 0) { + return baseState; + } + + const nextPendingVotes = baseState.pendingVotes.slice(1); + const nextOptimisticVoteCount = Math.max( + 0, + baseState.optimisticVoteCount - 1 + ); + + return { + ...baseState, + pendingVotes: nextPendingVotes, + optimisticVoteCount: nextOptimisticVoteCount, + settledRemainingPower: + nextPendingVotes.length === 0 + ? null + : baseState.settledRemainingPower, + summaryBaselineCount: + nextOptimisticVoteCount === 0 + ? null + : baseState.summaryBaselineCount, + }; + }); + resyncDiscovery(); + void refetchSummary(); + }, + [optimisticRemainingPowerKey, refetchSummary, resyncDiscovery] + ); + + const { isVoting, submitVote } = useMemesQuickVoteSubmit({ + requestAuth, + setToast, + invalidateDrops, + onVoteFailure: handleVoteFailure, + onVoteQueued: handleVoteQueued, + onVoteSuccess: handleVoteSuccess, + setAndPersistRecentAmounts, + setAndPersistSkippedDropIds, + }); + + const skipDrop = useCallback( + (drop: ExtendedDrop) => { + deferDropId(drop.id); + setAndPersistSkippedDropIds((current) => + appendSkippedDropId(current, drop.id) + ); + }, + [deferDropId, setAndPersistSkippedDropIds] + ); + + const derivedState = useDerivedMemesQuickVoteQueueState({ + activeDropCandidate: active.activeDrop, + activeIsLoading: active.isLoading, + discoveredQueue, + effectiveOptimisticRemainingPower, + enabled, + hasPageFetchError, + isDiscoveryExhausted, + isFetchingPage, + isSettingsLoaded, + isSummaryPending, + isSummaryQuickVoteEnabled, + isSummarySuccess, + recentAmountsByRecency, + serverCount, + summaryCount, + unreflectedVoteCount: summaryOptimismState.unreflectedVoteCount, + summaryVotingLabel: summaryStats.votingLabel, + }); + + return { + ...derivedState, + isVoting, + retryDiscovery, + submitVote, + skipDrop, + }; +}; diff --git a/hooks/useMemesQuickVoteStorage.ts b/hooks/useMemesQuickVoteStorage.ts new file mode 100644 index 0000000000..3deb8a49ce --- /dev/null +++ b/hooks/useMemesQuickVoteStorage.ts @@ -0,0 +1,126 @@ +"use client"; + +import { + sanitizeStoredAmounts, + sanitizeStoredDropIds, +} from "@/hooks/memesQuickVote.helpers"; +import { + readStoredNumberArray, + readStoredStringArray, + useStoredNumberArray, + useStoredStringArray, + writeStoredNumberArray, + writeStoredStringArray, +} from "@/hooks/memesQuickVote.storageStore"; +import { useCallback } from "react"; + +const SKIPPED_STORAGE_PREFIX = "memesQuickVoteSkipped"; +const AMOUNTS_STORAGE_PREFIX = "memesQuickVoteAmounts"; + +type UseMemesQuickVoteStorageOptions = { + readonly contextProfile: string | null | undefined; + readonly memesWaveId: string | null | undefined; +}; + +type UseMemesQuickVoteStorageResult = { + readonly skippedDropIds: string[]; + readonly recentAmountsByRecency: number[]; + readonly setAndPersistRecentAmounts: ( + updater: (current: number[]) => number[] + ) => void; + readonly setAndPersistSkippedDropIds: ( + updater: (current: string[]) => string[] + ) => void; +}; + +const getStorageKey = ( + prefix: string, + memesWaveId: string | null | undefined, + contextProfile: string | null | undefined +): string | null => { + if (!memesWaveId || !contextProfile) { + return null; + } + + return `${prefix}:${memesWaveId}:${contextProfile}`; +}; + +const areNumberArraysEqual = ( + left: readonly number[], + right: readonly number[] +): boolean => + left.length === right.length && + left.every((value, index) => value === right[index]); + +const areStringArraysEqual = ( + left: readonly string[], + right: readonly string[] +): boolean => + left.length === right.length && + left.every((value, index) => value === right[index]); + +export const useMemesQuickVoteStorage = ({ + contextProfile, + memesWaveId, +}: UseMemesQuickVoteStorageOptions): UseMemesQuickVoteStorageResult => { + const skippedStorageKey = getStorageKey( + SKIPPED_STORAGE_PREFIX, + memesWaveId, + contextProfile + ); + const amountsStorageKey = getStorageKey( + AMOUNTS_STORAGE_PREFIX, + memesWaveId, + contextProfile + ); + + const skippedDropIds = useStoredStringArray( + skippedStorageKey, + sanitizeStoredDropIds + ); + const recentAmountsByRecency = useStoredNumberArray( + amountsStorageKey, + sanitizeStoredAmounts + ); + + const setAndPersistRecentAmounts = useCallback( + (updater: (current: number[]) => number[]) => { + const current = readStoredNumberArray( + amountsStorageKey, + sanitizeStoredAmounts + ); + const next = sanitizeStoredAmounts(updater([...current])); + + if (areNumberArraysEqual(current, next)) { + return; + } + + writeStoredNumberArray(amountsStorageKey, next); + }, + [amountsStorageKey] + ); + + const setAndPersistSkippedDropIds = useCallback( + (updater: (current: string[]) => string[]) => { + const current = readStoredStringArray( + skippedStorageKey, + sanitizeStoredDropIds + ); + const next = sanitizeStoredDropIds(updater([...current])); + + if (areStringArraysEqual(current, next)) { + return; + } + + writeStoredStringArray(skippedStorageKey, next); + }, + [skippedStorageKey] + ); + + return { + skippedDropIds, + recentAmountsByRecency, + setAndPersistRecentAmounts, + setAndPersistSkippedDropIds, + }; +}; diff --git a/hooks/useMemesQuickVoteSubmit.ts b/hooks/useMemesQuickVoteSubmit.ts new file mode 100644 index 0000000000..6cf490e626 --- /dev/null +++ b/hooks/useMemesQuickVoteSubmit.ts @@ -0,0 +1,195 @@ +"use client"; + +import type { DropRateChangeRequest } from "@/entities/IDrop"; +import type { ApiDrop } from "@/generated/models/ApiDrop"; +import type { ExtendedDrop } from "@/helpers/waves/drop.helpers"; +import { + addRecentQuickVoteAmount, + normalizeQuickVoteAmount, +} from "@/hooks/memesQuickVote.helpers"; +import { commonApiPost } from "@/services/api/common-api"; +import { useMutation } from "@tanstack/react-query"; +import type { ReactNode } from "react"; +import { useCallback, useRef, useState } from "react"; +import type { TypeOptions } from "react-toastify"; + +const DEFAULT_DROP_RATE_CATEGORY = "Rep"; + +type SetToast = (options: { + readonly message: string | ReactNode; + readonly type: TypeOptions; +}) => void; + +type PersistNumberArray = (updater: (current: number[]) => number[]) => void; +type PersistStringArray = (updater: (current: string[]) => string[]) => void; + +type QueuedVote = { + readonly amount: number; + readonly currentRating: number; + readonly drop: ExtendedDrop; + readonly maxRating: number; +}; + +type UseMemesQuickVoteSubmitOptions = { + readonly requestAuth: () => Promise<{ success: boolean }>; + readonly setToast: SetToast; + readonly invalidateDrops: () => void; + readonly setAndPersistRecentAmounts: PersistNumberArray; + readonly setAndPersistSkippedDropIds: PersistStringArray; + readonly onVoteFailure: (drop: ExtendedDrop, amount: number) => void; + readonly onVoteQueued: (drop: ExtendedDrop, amount: number) => void; + readonly onVoteSuccess: ( + drop: ExtendedDrop, + amount: number, + nextRemainingPower: number + ) => void; +}; + +type UseMemesQuickVoteSubmitResult = { + readonly isVoting: boolean; + readonly submitVote: ( + drop: ExtendedDrop, + amount: number | string + ) => Promise; +}; + +export const useMemesQuickVoteSubmit = ({ + requestAuth, + setToast, + invalidateDrops, + setAndPersistRecentAmounts, + setAndPersistSkippedDropIds, + onVoteFailure, + onVoteQueued, + onVoteSuccess, +}: UseMemesQuickVoteSubmitOptions): UseMemesQuickVoteSubmitResult => { + const voteQueueRef = useRef([]); + const queuedDropIdsRef = useRef>(new Set()); + const isFlushingRef = useRef(false); + const [pendingVoteCount, setPendingVoteCount] = useState(0); + + const voteMutation = useMutation({ + mutationFn: ({ + dropId, + amount, + }: { + readonly dropId: string; + readonly amount: number; + }) => + commonApiPost({ + endpoint: `drops/${dropId}/ratings`, + body: { + rating: amount, + category: DEFAULT_DROP_RATE_CATEGORY, + }, + }), + }); + + const flushQueuedVotes = useCallback(async () => { + if (isFlushingRef.current) { + return; + } + + isFlushingRef.current = true; + + try { + while (voteQueueRef.current.length > 0) { + const queuedVote = voteQueueRef.current[0]; + if (!queuedVote) { + break; + } + + try { + const { success } = await requestAuth(); + + if (!success) { + throw new Error("Quick vote couldn't verify your session."); + } + + const response = await voteMutation.mutateAsync({ + dropId: queuedVote.drop.id, + amount: queuedVote.amount, + }); + const nextRating = response.context_profile_context?.rating; + const appliedAmount = + typeof nextRating === "number" + ? Math.max(0, nextRating - queuedVote.currentRating) + : queuedVote.amount; + const nextRemainingPower = Math.max( + 0, + queuedVote.maxRating - appliedAmount + ); + + onVoteSuccess(queuedVote.drop, queuedVote.amount, nextRemainingPower); + invalidateDrops(); + } catch (error) { + setToast({ + message: + error instanceof Error + ? error.message + : "Quick vote couldn't submit that vote.", + type: "error", + }); + onVoteFailure(queuedVote.drop, queuedVote.amount); + } finally { + voteQueueRef.current.shift(); + queuedDropIdsRef.current.delete(queuedVote.drop.id); + setPendingVoteCount(voteQueueRef.current.length); + } + } + } finally { + isFlushingRef.current = false; + } + }, [ + invalidateDrops, + onVoteFailure, + onVoteSuccess, + requestAuth, + setToast, + voteMutation, + ]); + + const submitVote = useCallback( + async (drop: ExtendedDrop, amount: number | string) => { + const currentRating = drop.context_profile_context?.rating ?? 0; + const maxRating = drop.context_profile_context?.max_rating ?? 0; + const normalizedAmount = normalizeQuickVoteAmount(amount, maxRating); + + if (normalizedAmount === null || queuedDropIdsRef.current.has(drop.id)) { + return false; + } + + queuedDropIdsRef.current.add(drop.id); + voteQueueRef.current.push({ + amount: normalizedAmount, + currentRating, + drop, + maxRating, + }); + setPendingVoteCount(voteQueueRef.current.length); + + onVoteQueued(drop, normalizedAmount); + setAndPersistRecentAmounts((current) => + addRecentQuickVoteAmount(current, normalizedAmount) + ); + setAndPersistSkippedDropIds((current) => + current.filter((dropId) => dropId !== drop.id) + ); + + void flushQueuedVotes(); + + return true; + }, + [ + flushQueuedVotes, + onVoteQueued, + setAndPersistRecentAmounts, + setAndPersistSkippedDropIds, + ] + ); + + return { + isVoting: pendingVoteCount > 0 || voteMutation.isPending, + submitVote, + }; +}; diff --git a/hooks/useMemesQuickVoteSummary.ts b/hooks/useMemesQuickVoteSummary.ts new file mode 100644 index 0000000000..c942c4cf97 --- /dev/null +++ b/hooks/useMemesQuickVoteSummary.ts @@ -0,0 +1,72 @@ +"use client"; + +import { getDefaultQueryRetry } from "@/components/react-query-wrapper/utils/query-utils"; +import { + buildMemesQuickVoteApiDrop, + deriveMemesQuickVoteStatsFromDrop, +} from "@/hooks/memesQuickVote.helpers"; +import { useMemesQuickVoteContext } from "@/hooks/useMemesQuickVoteContext"; +import { + fetchMemesQuickVoteSummary, + getMemesQuickVoteSummaryQueryKey, +} from "@/hooks/memesQuickVote.query"; +import { useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; + +export const useMemesQuickVoteSummary = ({ + enabled = true, +}: { + readonly enabled?: boolean | undefined; +} = {}) => { + const { contextProfile, isEnabled, memesWaveId, proxyId } = + useMemesQuickVoteContext(); + const waveId = + typeof memesWaveId === "string" && memesWaveId.length > 0 + ? memesWaveId + : null; + + const queryKey = useMemo( + () => + getMemesQuickVoteSummaryQueryKey({ + contextProfile, + proxyId, + waveId, + }), + [contextProfile, proxyId, waveId] + ); + + const query = useQuery({ + queryKey, + enabled: enabled && isEnabled && waveId !== null, + queryFn: () => fetchMemesQuickVoteSummary(waveId), + staleTime: 60_000, + refetchOnReconnect: false, + refetchOnWindowFocus: false, + ...getDefaultQueryRetry(), + }); + + const firstDrop = useMemo(() => { + const drop = query.data?.drops[0]; + const wave = query.data?.wave; + + if (!drop || !wave) { + return null; + } + + return buildMemesQuickVoteApiDrop(drop, wave); + }, [query.data?.drops, query.data?.wave]); + + const count = query.data?.count ?? 0; + const stats = useMemo( + () => deriveMemesQuickVoteStatsFromDrop({ count, drop: firstDrop }), + [count, firstDrop] + ); + + return { + ...query, + count, + firstDrop, + isQuickVoteEnabled: isEnabled, + stats, + }; +}; diff --git a/hooks/useMemesWaveFooterStats.ts b/hooks/useMemesWaveFooterStats.ts new file mode 100644 index 0000000000..adf97733d2 --- /dev/null +++ b/hooks/useMemesWaveFooterStats.ts @@ -0,0 +1,28 @@ +"use client"; + +import type { MemesQuickVoteStats } from "@/hooks/memesQuickVote.helpers"; +import { useMemesQuickVoteSummary } from "@/hooks/useMemesQuickVoteSummary"; + +type MemesWaveFooterStats = MemesQuickVoteStats & { + readonly isReady: boolean; +}; + +const EMPTY_STATS: MemesWaveFooterStats = { + uncastPower: null, + unratedCount: 0, + votingLabel: null, + isReady: false, +}; + +export const useMemesWaveFooterStats = (): MemesWaveFooterStats => { + const { stats } = useMemesQuickVoteSummary(); + + if (typeof stats.uncastPower !== "number" || stats.unratedCount <= 0) { + return EMPTY_STATS; + } + + return { + ...stats, + isReady: true, + }; +}; diff --git a/hooks/usePrefetchMemesQuickVote.ts b/hooks/usePrefetchMemesQuickVote.ts new file mode 100644 index 0000000000..633aa56ab3 --- /dev/null +++ b/hooks/usePrefetchMemesQuickVote.ts @@ -0,0 +1,106 @@ +"use client"; + +import { getDefaultQueryRetry } from "@/components/react-query-wrapper/utils/query-utils"; +import { + createInitialMemesQuickVoteDiscoveryState, + deriveMemesQuickVoteDiscoverySnapshot, + getMemesQuickVoteActiveCandidateId, + getMemesQuickVoteNextCandidateId, +} from "@/hooks/memesQuickVote.queue.helpers"; +import { + fetchMemesQuickVoteDiscoveryBatch, + fetchMemesQuickVoteDrop, + fetchMemesQuickVoteSummary, + getMemesQuickVoteDiscoveryQueryKey, + getMemesQuickVoteDiscoveryStateKey, + getMemesQuickVoteDropQueryKey, + getMemesQuickVoteSummaryQueryKey, +} from "@/hooks/memesQuickVote.query"; +import { useMemesQuickVoteContext } from "@/hooks/useMemesQuickVoteContext"; +import { useMemesQuickVoteStorage } from "@/hooks/useMemesQuickVoteStorage"; +import { useQueryClient } from "@tanstack/react-query"; +import { useCallback } from "react"; + +export const usePrefetchMemesQuickVote = () => { + const queryClient = useQueryClient(); + const { contextProfile, isEnabled, memesWaveId, proxyId } = + useMemesQuickVoteContext(); + const { skippedDropIds } = useMemesQuickVoteStorage({ + contextProfile, + memesWaveId, + }); + + return useCallback( + async (sessionId: number) => { + const waveId = + typeof memesWaveId === "string" && memesWaveId.length > 0 + ? memesWaveId + : null; + + if (!isEnabled || !contextProfile || !waveId) { + return; + } + + const discoveryState = createInitialMemesQuickVoteDiscoveryState(); + const discoveryStateKey = getMemesQuickVoteDiscoveryStateKey({ + contextProfile, + enabled: true, + memesWaveId: waveId, + sessionId, + }); + const summaryPromise = queryClient.prefetchQuery({ + queryKey: getMemesQuickVoteSummaryQueryKey({ + contextProfile, + proxyId, + waveId, + }), + queryFn: () => fetchMemesQuickVoteSummary(waveId), + staleTime: 60_000, + ...getDefaultQueryRetry(), + }); + const discoveryData = await queryClient.fetchQuery({ + queryKey: getMemesQuickVoteDiscoveryQueryKey({ + discoveryStateKey, + fetchVersion: 0, + waveId, + }), + queryFn: () => + fetchMemesQuickVoteDiscoveryBatch({ + discoveryState, + skippedDropIds, + waveId, + }), + retry: false, + }); + const discoverySnapshot = deriveMemesQuickVoteDiscoverySnapshot({ + enabled: true, + pages: discoveryData.pages, + skippedDropIds, + state: discoveryState, + }); + const activeCandidateId = + getMemesQuickVoteActiveCandidateId(discoverySnapshot); + const nextCandidateId = + getMemesQuickVoteNextCandidateId(discoverySnapshot); + const dropPrefetches = [activeCandidateId, nextCandidateId] + .filter((dropId): dropId is string => !!dropId) + .map((dropId) => + queryClient.prefetchQuery({ + queryKey: getMemesQuickVoteDropQueryKey(dropId), + queryFn: () => fetchMemesQuickVoteDrop(dropId), + ...getDefaultQueryRetry(), + }) + ); + + await Promise.all([summaryPromise, ...dropPrefetches]); + }, + [ + contextProfile, + isEnabled, + memesWaveId, + proxyId, + queryClient, + skippedDropIds, + ] + ); +}; diff --git a/openapi.yaml b/openapi.yaml index 5d871fc5ab..3ddcb1bb66 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -5840,6 +5840,15 @@ paths: required: false schema: type: string + - name: unvoted_by_me + in: query + description: >- + When true, only returns drops the authenticated user has not voted + on or currently has a 0 vote on + required: false + schema: + type: boolean + default: false responses: "200": description: successful operation diff --git a/services/auth/auth.utils.ts b/services/auth/auth.utils.ts index 93bab95901..0cf1fc432c 100644 --- a/services/auth/auth.utils.ts +++ b/services/auth/auth.utils.ts @@ -41,6 +41,42 @@ const getAddressRoleStorageKey = (address: string): string => { const normalizeAddress = (address: string): string => address.toLowerCase(); +export const isAuthAddressAuthorized = ({ + address, + connectedAccounts, +}: { + readonly address: string | null | undefined; + readonly connectedAccounts: readonly { readonly address: string }[]; +}): boolean => { + if (!address) { + return false; + } + + const normalizedAddress = normalizeAddress(address); + const isStoredAddressAuthorized = connectedAccounts.some( + (account) => normalizeAddress(account.address) === normalizedAddress + ); + + if (isStoredAddressAuthorized) { + return true; + } + + if (publicEnv.USE_DEV_AUTH !== "true") { + return false; + } + + const devWalletAddress = getWalletAddress(); + const devAuthJwt = getAuthJwt(); + + return ( + typeof devWalletAddress === "string" && + devWalletAddress.length > 0 && + typeof devAuthJwt === "string" && + devAuthJwt.length > 0 && + normalizeAddress(devWalletAddress) === normalizedAddress + ); +}; + const emitWalletAccountsUpdated = (): void => { if (globalThis.window !== undefined) { globalThis.dispatchEvent(new CustomEvent(WALLET_ACCOUNTS_UPDATED_EVENT));