diff --git a/__tests__/components/brain/BrainMobile.test.tsx b/__tests__/components/brain/BrainMobile.test.tsx index 8a2c50d25a..22ae582b38 100644 --- a/__tests__/components/brain/BrainMobile.test.tsx +++ b/__tests__/components/brain/BrainMobile.test.tsx @@ -88,6 +88,12 @@ jest.mock("@/hooks/useWaveTimers", () => ({ }), })); +jest.mock("@/components/auth/SeizeConnectContext", () => ({ + useSeizeConnectContext: jest.fn(() => ({ + isConnected: true, + })), +})); + jest.mock("@/components/brain/BrainDesktopDrop", () => ({ __esModule: true, default: (props: any) => ( diff --git a/__tests__/components/brain/ContentTabContext.test.tsx b/__tests__/components/brain/ContentTabContext.test.tsx index 12c3deea26..7dcb602656 100644 --- a/__tests__/components/brain/ContentTabContext.test.tsx +++ b/__tests__/components/brain/ContentTabContext.test.tsx @@ -28,6 +28,7 @@ describe("ContentTabContext", () => { result.current.updateAvailableTabs({ waveId: "chat-wave", isChatWave: true, + isConnected: true, isMemesWave: false, isCurationWave: false, votingState: WaveVotingState.NOT_STARTED, @@ -44,6 +45,7 @@ describe("ContentTabContext", () => { result.current.updateAvailableTabs({ waveId: "meme-wave", isChatWave: false, + isConnected: true, isMemesWave: true, isCurationWave: false, votingState: WaveVotingState.NOT_STARTED, @@ -59,12 +61,34 @@ describe("ContentTabContext", () => { ]); }); + it("omits My Votes for disconnected memes waves", () => { + const { result } = setup(); + act(() => + result.current.updateAvailableTabs({ + waveId: "meme-wave", + isChatWave: false, + isConnected: false, + isMemesWave: true, + isCurationWave: false, + votingState: WaveVotingState.NOT_STARTED, + hasFirstDecisionPassed: false, + }) + ); + expect(result.current.availableTabs).toEqual([ + MyStreamWaveTab.LEADERBOARD, + MyStreamWaveTab.CHAT, + MyStreamWaveTab.OUTCOME, + MyStreamWaveTab.FAQ, + ]); + }); + it("defaults to LEADERBOARD for memes waves", () => { const { result } = setup(); act(() => result.current.updateAvailableTabs({ waveId: "meme-wave", isChatWave: false, + isConnected: true, isMemesWave: true, isCurationWave: false, votingState: WaveVotingState.NOT_STARTED, @@ -80,6 +104,7 @@ describe("ContentTabContext", () => { result.current.updateAvailableTabs({ waveId: "default-wave", isChatWave: false, + isConnected: true, isMemesWave: false, isCurationWave: false, votingState: WaveVotingState.NOT_STARTED, @@ -95,6 +120,7 @@ describe("ContentTabContext", () => { result.current.updateAvailableTabs({ waveId: "curation-wave", isChatWave: false, + isConnected: true, isMemesWave: false, isCurationWave: true, votingState: WaveVotingState.NOT_STARTED, @@ -116,6 +142,7 @@ describe("ContentTabContext", () => { result.current.updateAvailableTabs({ waveId: "meme-wave", isChatWave: false, + isConnected: true, isMemesWave: true, isCurationWave: false, votingState: WaveVotingState.NOT_STARTED, @@ -127,6 +154,7 @@ describe("ContentTabContext", () => { result.current.updateAvailableTabs({ waveId: "default-wave", isChatWave: false, + isConnected: true, isMemesWave: false, isCurationWave: false, votingState: WaveVotingState.NOT_STARTED, @@ -137,6 +165,7 @@ describe("ContentTabContext", () => { result.current.updateAvailableTabs({ waveId: "meme-wave", isChatWave: false, + isConnected: true, isMemesWave: true, isCurationWave: false, votingState: WaveVotingState.NOT_STARTED, @@ -152,6 +181,7 @@ describe("ContentTabContext", () => { result.current.updateAvailableTabs({ waveId: "default-wave", isChatWave: false, + isConnected: true, isMemesWave: false, isCurationWave: false, votingState: WaveVotingState.NOT_STARTED, @@ -163,6 +193,7 @@ describe("ContentTabContext", () => { result.current.updateAvailableTabs({ waveId: "default-wave", isChatWave: false, + isConnected: true, isMemesWave: false, isCurationWave: false, votingState: WaveVotingState.NOT_STARTED, @@ -179,6 +210,7 @@ describe("ContentTabContext", () => { result.current.updateAvailableTabs({ waveId: "wave-1", isChatWave: false, + isConnected: true, isMemesWave: false, isCurationWave: false, votingState: WaveVotingState.NOT_STARTED, @@ -191,6 +223,7 @@ describe("ContentTabContext", () => { result.current.updateAvailableTabs({ waveId: "wave-1", isChatWave: false, + isConnected: true, isMemesWave: false, isCurationWave: true, votingState: WaveVotingState.NOT_STARTED, diff --git a/__tests__/components/brain/mobile/BrainMobileTabs.test.tsx b/__tests__/components/brain/mobile/BrainMobileTabs.test.tsx index 003ec1d17a..c6c0d1a83e 100644 --- a/__tests__/components/brain/mobile/BrainMobileTabs.test.tsx +++ b/__tests__/components/brain/mobile/BrainMobileTabs.test.tsx @@ -52,6 +52,10 @@ jest.mock("@/components/auth/Auth", () => ({ useAuth: () => ({ connectedProfile: { handle: "alice" } }), })); +jest.mock("@/components/auth/SeizeConnectContext", () => ({ + useSeizeConnectContext: jest.fn(), +})); + const leaderboardMock = jest.fn(); jest.mock("@/components/brain/my-stream/MyStreamWaveTabsLeaderboard", () => ({ __esModule: true, @@ -71,6 +75,9 @@ jest.mock("@/components/brain/my-stream/MyStreamWaveTabsLeaderboard", () => ({ const { useWave } = require("@/hooks/useWave"); const { useUnreadIndicator } = require("@/hooks/useUnreadIndicator"); const { useUnreadNotifications } = require("@/hooks/useUnreadNotifications"); +const { + useSeizeConnectContext, +} = require("@/components/auth/SeizeConnectContext"); describe("BrainMobileTabs", () => { const onViewChange = jest.fn(); @@ -85,6 +92,9 @@ describe("BrainMobileTabs", () => { (useUnreadNotifications as jest.Mock).mockReturnValue({ haveUnreadNotifications: false, }); + (useSeizeConnectContext as jest.Mock).mockReturnValue({ + isConnected: true, + }); }); it("renders back button and navigates to My Stream", async () => { @@ -203,6 +213,34 @@ describe("BrainMobileTabs", () => { expect(screen.queryByText("FAQ")).toBeNull(); }); + it("hides My Votes for disconnected memes rank wave", () => { + (useWave as jest.Mock).mockReturnValue({ + isMemesWave: true, + isCurationWave: false, + isRankWave: true, + }); + (useSeizeConnectContext as jest.Mock).mockReturnValue({ + isConnected: false, + }); + + render( + + ); + + expect(screen.getByTestId("leaderboard")).toBeInTheDocument(); + expect(screen.queryByText("My Votes")).toBeNull(); + expect(screen.getByText("Outcome")).toBeInTheDocument(); + expect(screen.getByText("FAQ")).toBeInTheDocument(); + }); + it("renders Sales for non-rank curation waves", () => { (useWave as jest.Mock).mockReturnValue({ isMemesWave: false, diff --git a/__tests__/components/brain/my-stream/MyStreamWaveChat.test.tsx b/__tests__/components/brain/my-stream/MyStreamWaveChat.test.tsx index 8a4484b30f..b87294f6ba 100644 --- a/__tests__/components/brain/my-stream/MyStreamWaveChat.test.tsx +++ b/__tests__/components/brain/my-stream/MyStreamWaveChat.test.tsx @@ -17,6 +17,7 @@ const mockRemoveAllDeliveredNotifications = jest .fn() .mockResolvedValue(undefined); const invalidateNotificationsMock = jest.fn(); +const mockUseAuth = jest.fn(); jest.mock("next/navigation", () => ({ useRouter: () => ({ replace: replaceMock }), @@ -91,6 +92,10 @@ jest.mock("@/components/notifications/NotificationsContext", () => ({ }), })); +jest.mock("@/components/auth/Auth", () => ({ + useAuth: () => mockUseAuth(), +})); + jest.mock("@/services/api/common-api", () => ({ commonApiPostWithoutBodyAndResponse: jest.fn().mockResolvedValue(undefined), })); @@ -114,6 +119,9 @@ describe("MyStreamWaveChat", () => { mockRemoveWaveDeliveredNotifications.mockClear(); mockRemoveAllDeliveredNotifications.mockClear(); invalidateNotificationsMock.mockClear(); + mockUseAuth.mockReturnValue({ + connectedProfile: { handle: "tester" }, + }); ( commonApiPostWithoutBodyAndResponse as jest.MockedFunction< typeof commonApiPostWithoutBodyAndResponse @@ -196,4 +204,27 @@ describe("MyStreamWaveChat", () => { expect(invalidateNotificationsMock).toHaveBeenCalled(); }); }); + + it("skips notification cleanup on unmount for anonymous viewers", async () => { + mockUseAuth.mockReturnValue({ + connectedProfile: null, + }); + + const { unmount } = renderWithProvider( + + ); + + await act(async () => { + unmount(); + }); + + expect(mockRemoveWaveDeliveredNotifications).not.toHaveBeenCalled(); + expect(commonApiPostWithoutBodyAndResponse).not.toHaveBeenCalled(); + expect(invalidateNotificationsMock).not.toHaveBeenCalled(); + }); }); diff --git a/__tests__/components/brain/my-stream/MyStreamWaveDesktopTabs.test.tsx b/__tests__/components/brain/my-stream/MyStreamWaveDesktopTabs.test.tsx index 3388d36569..0c50a4595e 100644 --- a/__tests__/components/brain/my-stream/MyStreamWaveDesktopTabs.test.tsx +++ b/__tests__/components/brain/my-stream/MyStreamWaveDesktopTabs.test.tsx @@ -60,6 +60,10 @@ jest.mock("@/components/waves/leaderboard/time/CompactTimeCountdown", () => ({ ), })); +jest.mock("@/components/auth/SeizeConnectContext", () => ({ + useSeizeConnectContext: jest.fn(), +})); + import MyStreamWaveDesktopTabs from "@/components/brain/my-stream/MyStreamWaveDesktopTabs"; let mockAvailableTabs: MyStreamWaveTab[] = []; @@ -71,6 +75,9 @@ let mockWaveInfo: any = { }; let mockVoting = { isUpcoming: false, isCompleted: false, isInProgress: true }; let mockDecisions: { timestamp: number }[] = []; +const { + useSeizeConnectContext, +} = require("@/components/auth/SeizeConnectContext"); function renderComponent(activeTab: MyStreamWaveTab = MyStreamWaveTab.CHAT) { return render( @@ -93,6 +100,9 @@ beforeEach(() => { }; mockVoting = { isUpcoming: false, isCompleted: false, isInProgress: true }; mockDecisions = []; + (useSeizeConnectContext as jest.Mock).mockReturnValue({ + isConnected: true, + }); }); describe("MyStreamWaveDesktopTabs", () => { @@ -146,6 +156,32 @@ describe("MyStreamWaveDesktopTabs", () => { expect(setActiveTab).not.toHaveBeenCalled(); }); + it("hides My Votes for disconnected memes waves", () => { + mockWaveInfo = { + isChatWave: false, + isMemesWave: true, + isCurationWave: false, + isRankWave: false, + }; + mockAvailableTabs = [ + MyStreamWaveTab.LEADERBOARD, + MyStreamWaveTab.CHAT, + MyStreamWaveTab.MY_VOTES, + MyStreamWaveTab.OUTCOME, + MyStreamWaveTab.FAQ, + ]; + (useSeizeConnectContext as jest.Mock).mockReturnValue({ + isConnected: false, + }); + + renderComponent(MyStreamWaveTab.MY_VOTES); + + expect(screen.getByText("Leaderboard")).toBeInTheDocument(); + expect(screen.getByText("Chat")).toBeInTheDocument(); + expect(screen.queryByText("My Votes")).toBeNull(); + expect(setActiveTab).toHaveBeenCalledWith(MyStreamWaveTab.LEADERBOARD); + }); + it("keeps Sales hidden outside curation waves", () => { mockWaveInfo = { isChatWave: false, diff --git a/__tests__/components/drops/view/DropsList.test.tsx b/__tests__/components/drops/view/DropsList.test.tsx index 436626942b..3392f73d0e 100644 --- a/__tests__/components/drops/view/DropsList.test.tsx +++ b/__tests__/components/drops/view/DropsList.test.tsx @@ -235,6 +235,20 @@ describe("DropsList", () => { rank: 1, variant: "chat", }); + + const boostedCard = screen.getByTestId("boosted-card"); + + expect(boostedCard.parentElement).toHaveClass( + "tw-rounded-2xl", + "tw-bg-iron-900/50", + "tw-p-2", + "sm:tw-p-3" + ); + expect(boostedCard.parentElement?.parentElement).toHaveClass( + "tw-px-3", + "tw-py-4", + "sm:tw-px-4" + ); }); it("clicking an inline boosted card calls onBoostedDropClick with the drop serial", () => { diff --git a/__tests__/components/home/boosted/BoostedDropCardHome.test.tsx b/__tests__/components/home/boosted/BoostedDropCardHome.test.tsx index 6eeed46155..a9aca8c1b5 100644 --- a/__tests__/components/home/boosted/BoostedDropCardHome.test.tsx +++ b/__tests__/components/home/boosted/BoostedDropCardHome.test.tsx @@ -446,6 +446,35 @@ describe("BoostedDropCardHome", () => { variant: "chat", }) ); + + expect(screen.getByTestId("link-preview").parentElement).toHaveClass( + "tw-px-3", + "sm:tw-px-4" + ); + }); + + it("adds bottom spacing to preview-only chat cards", () => { + renderWithAuth( + + ); + + expect(screen.getByTestId("link-preview").parentElement).toHaveClass( + "tw-px-3", + "sm:tw-px-4", + "tw-pb-4" + ); }); it("keeps caption content for chat cards with lead media", () => { @@ -587,6 +616,9 @@ describe("BoostedDropCardHome", () => { /> ); + expect( + screen.getByTestId("boosted-drop-media-frame").parentElement + ).toHaveClass("tw-px-3", "sm:tw-px-4", "tw-pb-4"); expect(screen.getByTestId("boosted-drop-media-frame")).toHaveStyle({ aspectRatio: "8 / 5", }); diff --git a/__tests__/components/user/utils/profile/UserProfileTooltip.test.tsx b/__tests__/components/user/utils/profile/UserProfileTooltip.test.tsx index 95ffaf2556..474ebf8359 100644 --- a/__tests__/components/user/utils/profile/UserProfileTooltip.test.tsx +++ b/__tests__/components/user/utils/profile/UserProfileTooltip.test.tsx @@ -5,6 +5,7 @@ import { useRouter } from "next/navigation"; import type { ComponentProps, ContextType } from "react"; import { AuthContext } from "@/components/auth/Auth"; +import { useSeizeConnectContext } from "@/components/auth/SeizeConnectContext"; import UserProfileTooltip from "@/components/user/utils/profile/UserProfileTooltip"; import type { CicStatement } from "@/entities/IProfile"; import { STATEMENT_GROUP, STATEMENT_TYPE } from "@/helpers/Types"; @@ -122,6 +123,10 @@ jest.mock("@/hooks/useDeviceInfo", () => ({ default: jest.fn(() => ({ isApp: false })), })); +jest.mock("@/components/auth/SeizeConnectContext", () => ({ + useSeizeConnectContext: jest.fn(), +})); + jest.mock("@/services/api/common-api", () => ({ commonApiFetch: jest.fn(), })); @@ -139,6 +144,7 @@ jest.mock("next/navigation", () => ({ })); const useIdentityMock = useIdentity as jest.Mock; +const useSeizeConnectContextMock = useSeizeConnectContext as jest.Mock; const commonApiFetchMock = commonApiFetch as jest.Mock; const createDirectMessageWaveMock = createDirectMessageWave as jest.Mock; const navigateToDirectMessageMock = navigateToDirectMessage as jest.Mock; @@ -220,6 +226,7 @@ describe("UserProfileTooltip", () => { useRouterMock.mockReturnValue(mockRouter); useIdentityMock.mockReturnValue({ profile: mockProfile }); + useSeizeConnectContextMock.mockReturnValue({ isConnected: true }); createDirectMessageWaveMock.mockResolvedValue({ id: "wave-1" }); commonApiFetchMock.mockImplementation( @@ -367,6 +374,21 @@ describe("UserProfileTooltip", () => { ).not.toBeInTheDocument(); }); + it("does not render the follow action when logged out", () => { + useSeizeConnectContextMock.mockReturnValue({ isConnected: false }); + + renderTooltip({ + authOverrides: { + connectedProfile: null, + }, + }); + + expect(screen.queryByTestId("follow-btn")).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Send direct message" }) + ).not.toBeInTheDocument(); + }); + it("hides the DM action when acting through a proxy", () => { renderTooltip({ authOverrides: { diff --git a/__tests__/components/waves/DropPlaceholder.test.tsx b/__tests__/components/waves/DropPlaceholder.test.tsx index 8f8457abe4..9d8f1842af 100644 --- a/__tests__/components/waves/DropPlaceholder.test.tsx +++ b/__tests__/components/waves/DropPlaceholder.test.tsx @@ -1,248 +1,291 @@ -import DropPlaceholder from '@/components/waves/DropPlaceholder'; -import { ChatRestriction, SubmissionRestriction } from '@/hooks/useDropPriviledges'; -import { render, screen } from '@testing-library/react'; +import DropPlaceholder from "@/components/waves/DropPlaceholder"; +import { + ChatRestriction, + SubmissionRestriction, +} from "@/hooks/useDropPriviledges"; +import { render, screen } from "@testing-library/react"; -describe('DropPlaceholder', () => { - describe('chat restrictions', () => { - it('renders not logged in message for chat', () => { +describe("DropPlaceholder", () => { + describe("chat restrictions", () => { + it("renders not logged in message for chat", () => { render( - ); - - expect(screen.getByText('Please log in to participate in chat')).toBeInTheDocument(); + + expect( + screen.getByText("Please log in to participate in chat") + ).toBeInTheDocument(); }); - it('renders proxy user message for chat', () => { + it("renders proxy user message for chat", () => { render( - ); - - expect(screen.getByText('Proxy users cannot participate in chat')).toBeInTheDocument(); + + expect( + screen.getByText("Proxy users cannot participate in chat") + ).toBeInTheDocument(); }); - it('renders no permission message for chat', () => { + it("renders no permission message for chat", () => { render( - ); - - expect(screen.getByText("You don't have permission to chat in this wave")).toBeInTheDocument(); + + expect( + screen.getByText("You don't have permission to chat in this wave") + ).toBeInTheDocument(); }); - it('renders disabled message for chat', () => { + it("renders disabled message for chat", () => { render( - ); - - expect(screen.getByText('Chat is currently disabled for this wave')).toBeInTheDocument(); + + expect( + screen.getByText("Chat is currently disabled for this wave") + ).toBeInTheDocument(); }); - it('applies primary color for not logged in chat restriction', () => { + it("applies primary color for not logged in chat restriction", () => { render( - ); - - const message = screen.getByText('Please log in to participate in chat'); - expect(message).toHaveClass('tw-text-primary-400'); + + const message = screen.getByText("Please log in to participate in chat"); + expect(message).toHaveClass("tw-text-primary-400"); }); }); - describe('submission restrictions', () => { - it('renders not logged in message for submission', () => { + describe("submission restrictions", () => { + it("renders not logged in message for submission", () => { render( - ); - - expect(screen.getByText('Please log in to make submissions')).toBeInTheDocument(); + + expect( + screen.getByText("Please log in to make submissions") + ).toBeInTheDocument(); }); - it('renders proxy user message for submission', () => { + it("renders proxy user message for submission", () => { render( - ); - - expect(screen.getByText('Proxy users cannot make submissions')).toBeInTheDocument(); + + expect( + screen.getByText("Proxy users cannot make submissions") + ).toBeInTheDocument(); }); - it('renders no permission message for submission', () => { + it("renders no permission message for submission", () => { render( - ); - - expect(screen.getByText("You don't have permission to submit in this wave")).toBeInTheDocument(); + + expect( + screen.getByText("You don't have permission to submit in this wave") + ).toBeInTheDocument(); }); - it('renders not started message for submission', () => { + it("renders not started message for submission", () => { render( - ); - - expect(screen.getByText("Submissions haven't started yet")).toBeInTheDocument(); + + expect( + screen.getByText("Submissions haven't started yet") + ).toBeInTheDocument(); }); - it('renders ended message for submission', () => { + it("renders ended message for submission", () => { render( - ); - - expect(screen.getByText('Submission period has ended')).toBeInTheDocument(); + + expect( + screen.getByText("Submission period has ended") + ).toBeInTheDocument(); }); - it('renders max drops reached message for submission', () => { + it("renders max drops reached message for submission", () => { render( - ); - - expect(screen.getByText('You have reached the maximum number of drops allowed')).toBeInTheDocument(); + + expect( + screen.getByText("You have reached the maximum number of drops allowed") + ).toBeInTheDocument(); }); - it('applies correct colors for submission restrictions', () => { + it("applies correct colors for submission restrictions", () => { const { rerender } = render( - ); - - expect(screen.getByText('Please log in to make submissions')).toHaveClass('tw-text-primary-400'); + + expect(screen.getByText("Please log in to make submissions")).toHaveClass( + "tw-text-primary-400" + ); rerender( - ); - - expect(screen.getByText("Submissions haven't started yet")).toHaveClass('tw-text-[#FEDF89]'); + + expect(screen.getByText("Submissions haven't started yet")).toHaveClass( + "tw-text-[#FEDF89]" + ); rerender( - ); - - expect(screen.getByText('Submission period has ended')).toHaveClass('tw-text-red'); + + expect(screen.getByText("Submission period has ended")).toHaveClass( + "tw-text-red" + ); rerender( - ); - - expect(screen.getByText('You have reached the maximum number of drops allowed')).toHaveClass('tw-text-red'); + + expect( + screen.getByText("You have reached the maximum number of drops allowed") + ).toHaveClass("tw-text-red"); }); }); - describe('both type', () => { - it('renders generic message for both type', () => { + describe("both type", () => { + it("renders connect wallet message for logged out users", () => { + render( + + ); + + expect( + screen.getByText("Connect your wallet to participate in this wave") + ).toBeInTheDocument(); + }); + + it("renders generic message for both type", () => { render(); - - expect(screen.getByText('You cannot participate in this wave at the moment')).toBeInTheDocument(); + + expect( + screen.getByText("You cannot participate in this wave at the moment") + ).toBeInTheDocument(); }); }); - describe('default cases', () => { - it('renders default message when no restrictions provided', () => { + describe("default cases", () => { + it("renders default message when no restrictions provided", () => { render(); - - expect(screen.getByText('Action not available')).toBeInTheDocument(); + + expect(screen.getByText("Action not available")).toBeInTheDocument(); }); - it('applies default neutral color', () => { + it("applies default neutral color", () => { render(); - - const message = screen.getByText('Action not available'); - expect(message).toHaveClass('tw-text-iron-400'); + + const message = screen.getByText("Action not available"); + expect(message).toHaveClass("tw-text-iron-400"); }); }); - describe('component structure', () => { - it('renders with correct container classes', () => { + describe("component structure", () => { + it("renders with correct container classes", () => { const { container } = render(); - + const wrapper = container.firstChild; expect(wrapper).toHaveClass( - 'tw-min-h-[48px]', - 'tw-flex', - 'tw-items-center', - 'tw-justify-center', - 'tw-px-4', - 'tw-py-3', - 'tw-bg-iron-900/50', - 'tw-backdrop-blur', - 'tw-rounded-xl', - 'tw-border', - 'tw-border-iron-800/50' + "tw-min-h-[48px]", + "tw-flex", + "tw-items-center", + "tw-justify-center", + "tw-px-4", + "tw-py-3", + "tw-bg-iron-900/50", + "tw-backdrop-blur", + "tw-rounded-xl", + "tw-border", + "tw-border-iron-800/50" ); }); - it('renders message with correct text styling', () => { + it("renders message with correct text styling", () => { render(); - - const message = screen.getByText('Action not available'); - expect(message).toHaveClass( - 'tw-text-sm', - 'tw-font-medium', - 'tw-mb-0' - ); + + const message = screen.getByText("Action not available"); + expect(message).toHaveClass("tw-text-sm", "tw-font-medium", "tw-mb-0"); }); }); - describe('error handling', () => { - it('throws error for unhandled chat restriction', () => { + describe("error handling", () => { + it("throws error for unhandled chat restriction", () => { expect(() => { render( - ); - }).toThrow('Unhandled chat restriction: INVALID_RESTRICTION'); + }).toThrow("Unhandled chat restriction: INVALID_RESTRICTION"); }); - it('throws error for unhandled submission restriction', () => { + it("throws error for unhandled submission restriction", () => { expect(() => { render( - ); - }).toThrow('Unhandled submission restriction: INVALID_RESTRICTION'); + }).toThrow("Unhandled submission restriction: INVALID_RESTRICTION"); }); }); -}); \ No newline at end of file +}); diff --git a/__tests__/components/waves/WavesLayout.test.tsx b/__tests__/components/waves/WavesLayout.test.tsx new file mode 100644 index 0000000000..5dd7f9a342 --- /dev/null +++ b/__tests__/components/waves/WavesLayout.test.tsx @@ -0,0 +1,141 @@ +import WavesLayout from "@/components/waves/layout/WavesLayout"; +import { render, screen } from "@testing-library/react"; +import React from "react"; + +const mockUseAuthenticatedContent = jest.fn(); +const mockUseDeviceInfo = jest.fn(); +const mockGetActiveWaveIdFromUrl = jest.fn(); +const mockUsePathname = jest.fn(); +const mockUseSearchParams = jest.fn(); + +jest.mock("../../../hooks/useAuthenticatedContent", () => ({ + useAuthenticatedContent: () => mockUseAuthenticatedContent(), +})); + +jest.mock("../../../hooks/useDeviceInfo", () => ({ + __esModule: true, + default: () => mockUseDeviceInfo(), +})); + +jest.mock("next/navigation", () => ({ + usePathname: () => mockUsePathname(), + useSearchParams: () => mockUseSearchParams(), +})); + +jest.mock("../../../helpers/navigation.helpers", () => ({ + getActiveWaveIdFromUrl: (...args: unknown[]) => + mockGetActiveWaveIdFromUrl(...args), +})); + +jest.mock("@/components/waves/WavesDesktop", () => ({ + __esModule: true, + default: ({ + children, + showLeftSidebar, + }: { + readonly children: React.ReactNode; + readonly showLeftSidebar?: boolean; + }) => ( +
+ {children} +
+ ), +})); + +jest.mock("@/components/waves/WavesMobile", () => ({ + __esModule: true, + default: ({ children }: { readonly children: React.ReactNode }) => ( +
{children}
+ ), +})); + +jest.mock("@/components/common/ConnectWallet", () => ({ + __esModule: true, + default: () =>
Connect Wallet
, +})); + +jest.mock("@/components/header/user/HeaderUserConnect", () => ({ + __esModule: true, + default: ({ label }: { readonly label?: string }) => ( + + ), +})); + +jest.mock("@/components/user/utils/set-up-profile/UserSetUpProfileCta", () => ({ + __esModule: true, + default: () =>
Set up profile
, +})); + +jest.mock("@/components/waves/WaveScreenMessage", () => ({ + __esModule: true, + default: ({ + action, + description, + title, + }: { + readonly action?: React.ReactNode; + readonly description?: string; + readonly title: string; + }) => ( +
+

{title}

+ {description ?

{description}

: null} + {action} +
+ ), +})); + +describe("WavesLayout", () => { + beforeEach(() => { + mockUseAuthenticatedContent.mockReturnValue({ + contentState: "not-authenticated", + }); + mockUseDeviceInfo.mockReturnValue({ isApp: false, isMobileDevice: false }); + mockUsePathname.mockReturnValue("/waves/test-wave"); + mockUseSearchParams.mockReturnValue(new URLSearchParams("wave=test-wave")); + mockGetActiveWaveIdFromUrl.mockReturnValue(null); + }); + + it("renders the selected wave content for logged-out users", () => { + mockGetActiveWaveIdFromUrl.mockReturnValue("test-wave"); + + render( + +
Real wave content
+
+ ); + + expect(screen.getByTestId("wave-content")).toBeInTheDocument(); + expect(screen.getByTestId("waves-desktop")).toHaveAttribute( + "data-allow-drop-overlay", + "false" + ); + expect(screen.getByTestId("waves-desktop")).toHaveAttribute( + "data-allow-right-sidebar", + "false" + ); + expect(screen.queryByTestId("wave-screen-message")).not.toBeInTheDocument(); + }); + + it("keeps the select-wave prompt when no wave is selected", () => { + render( + +
Real wave content
+
+ ); + + expect(screen.getByText("Select a Wave")).toBeInTheDocument(); + expect( + screen.getByText( + "Connect your wallet to access waves and join the conversation." + ) + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Connect Wallet" }) + ).toBeInTheDocument(); + expect(screen.queryByTestId("wave-content")).not.toBeInTheDocument(); + }); +}); diff --git a/__tests__/components/waves/drops/WaveDropActions.test.tsx b/__tests__/components/waves/drops/WaveDropActions.test.tsx index 7a8403d9ad..9f0e950318 100644 --- a/__tests__/components/waves/drops/WaveDropActions.test.tsx +++ b/__tests__/components/waves/drops/WaveDropActions.test.tsx @@ -1,5 +1,6 @@ import { fireEvent, render, screen } from "@testing-library/react"; +import { useSeizeConnectContext } from "@/components/auth/SeizeConnectContext"; import { useSeizeSettings } from "@/contexts/SeizeSettingsContext"; import { ApiDropType } from "@/generated/models/ApiDropType"; import WaveDropActions from "@/components/waves/drops/WaveDropActions"; @@ -14,6 +15,11 @@ jest.mock("@/components/waves/drops/WaveDropActionsBoost", () => ({ default: () =>
, })); +jest.mock("@/components/waves/drops/WaveDropActionsCopyLink", () => ({ + __esModule: true, + default: () =>
, +})); + jest.mock("@/components/waves/drops/WaveDropActionsEdit", () => ({ __esModule: true, default: () =>
, @@ -55,7 +61,12 @@ jest.mock("@/contexts/SeizeSettingsContext", () => ({ useSeizeSettings: jest.fn(), })); +jest.mock("@/components/auth/SeizeConnectContext", () => ({ + useSeizeConnectContext: jest.fn(), +})); + const settingsMock = useSeizeSettings as jest.Mock; +const seizeConnectContextMock = useSeizeConnectContext as jest.Mock; const baseDrop: any = { id: "drop-1", @@ -66,6 +77,7 @@ const baseDrop: any = { describe("WaveDropActions", () => { beforeEach(() => { settingsMock.mockReturnValue({ isMemesWave: () => false }); + seizeConnectContextMock.mockReturnValue({ isConnected: true }); }); it("keeps hidden actions non-interactive while closed", () => { @@ -118,4 +130,22 @@ describe("WaveDropActions", () => { expect(screen.queryByTestId("rate")).toBeNull(); }); + + it("shows only copy link when disconnected", () => { + seizeConnectContextMock.mockReturnValue({ isConnected: false }); + + render( + {}} /> + ); + + expect(screen.getByTestId("copy-link")).toBeInTheDocument(); + expect(screen.queryByTestId("quick-react")).toBeNull(); + expect(screen.queryByTestId("add-reaction")).toBeNull(); + expect(screen.queryByTestId("reply")).toBeNull(); + expect(screen.queryByTestId("boost")).toBeNull(); + expect( + screen.queryByRole("button", { name: "Open more actions" }) + ).toBeNull(); + expect(screen.queryByTestId("rate")).toBeNull(); + }); }); diff --git a/__tests__/components/waves/drops/WaveDropMobileMenu.test.tsx b/__tests__/components/waves/drops/WaveDropMobileMenu.test.tsx index f53fa42673..21c78b5816 100644 --- a/__tests__/components/waves/drops/WaveDropMobileMenu.test.tsx +++ b/__tests__/components/waves/drops/WaveDropMobileMenu.test.tsx @@ -1,4 +1,5 @@ import { AuthContext } from "@/components/auth/Auth"; +import { useSeizeConnectContext } from "@/components/auth/SeizeConnectContext"; import WaveDropMobileMenu from "@/components/waves/drops/WaveDropMobileMenu"; import { ApiDropType } from "@/generated/models/ApiDropType"; import { useDropInteractionRules } from "@/hooks/drops/useDropInteractionRules"; @@ -11,6 +12,9 @@ const writeText = jest.fn().mockResolvedValue(undefined); jest.mock("@/hooks/drops/useDropInteractionRules", () => ({ useDropInteractionRules: jest.fn(), })); +jest.mock("@/components/auth/SeizeConnectContext", () => ({ + useSeizeConnectContext: jest.fn(), +})); jest.mock("@/components/waves/drops/WaveDropMobileMenuDelete", () => () => (
)); @@ -71,10 +75,12 @@ beforeAll(() => { }); const mockedUseDropInteractionRules = jest.mocked(useDropInteractionRules); +const mockedUseSeizeConnectContext = jest.mocked(useSeizeConnectContext); beforeEach(() => { writeText.mockClear(); mockIsMemesWave.mockReturnValue(false); + mockedUseSeizeConnectContext.mockReturnValue({ isConnected: true } as any); mockedUseDropInteractionRules.mockReturnValue({ canShowVote: true, canVote: true, @@ -282,3 +288,47 @@ test("does not show pinned-drop action in the mobile menu for non-admins", () => expect(screen.queryByTestId("set-pinned-drop")).toBeNull(); }); + +test("shows only copy link in the mobile menu when disconnected", () => { + mockedUseSeizeConnectContext.mockReturnValue({ isConnected: false } as any); + + const drop = { + id: "1", + serial_no: 1, + wave: { id: "w" }, + drop_type: ApiDropType.Chat, + author: { handle: "alice" }, + } as any; + + render( + + + + ); + + expect(screen.getByText("Copy link")).toBeInTheDocument(); + expect(screen.queryByTestId("quick-react")).toBeNull(); + expect(screen.queryByTestId("add-reaction")).toBeNull(); + expect(screen.queryByText("Reply")).toBeNull(); + expect(screen.queryByTestId("boost")).toBeNull(); + expect(screen.queryByTestId("open")).toBeNull(); + expect(screen.queryByTestId("mark-unread")).toBeNull(); + expect(screen.queryByTestId("clap")).toBeNull(); + expect(screen.queryByTestId("set-pinned-drop")).toBeNull(); + expect(screen.queryByTestId("delete")).toBeNull(); +}); diff --git a/__tests__/components/waves/drops/WaveDropReactions.test.tsx b/__tests__/components/waves/drops/WaveDropReactions.test.tsx index aa50ba5261..eacad22659 100644 --- a/__tests__/components/waves/drops/WaveDropReactions.test.tsx +++ b/__tests__/components/waves/drops/WaveDropReactions.test.tsx @@ -1,4 +1,5 @@ import WaveDropReactions from "@/components/waves/drops/WaveDropReactions"; +import { useSeizeConnectContext } from "@/components/auth/SeizeConnectContext"; import { useEmoji } from "@/contexts/EmojiContext"; import * as commonApi from "@/services/api/common-api"; import { fireEvent, render, screen, waitFor } from "@testing-library/react"; @@ -14,6 +15,10 @@ jest.mock("@/contexts/EmojiContext", () => ({ useEmoji: jest.fn(), })); +jest.mock("@/components/auth/SeizeConnectContext", () => ({ + useSeizeConnectContext: jest.fn(() => ({ isConnected: true })), +})); + jest.mock("@/helpers/Helpers", () => ({ formatLargeNumber: (num: number) => `${num}`, })); @@ -42,6 +47,7 @@ jest.mock("@/hooks/useLongPressInteraction", () => ({ })); const mockUseEmoji = useEmoji as jest.Mock; +const mockUseSeizeConnectContext = useSeizeConnectContext as jest.Mock; type NativeEmojiMock = { skins: Array<{ native: string }> }; @@ -87,6 +93,7 @@ describe("WaveDropReactions", () => { beforeEach(() => { // Reset call history without removing default implementations jest.clearAllMocks(); + mockUseSeizeConnectContext.mockReturnValue({ isConnected: true }); getMyStreamMock().mockReturnValue({ applyOptimisticDropUpdate: jest.fn(() => ({ rollback: jest.fn() })), }); @@ -247,12 +254,66 @@ describe("WaveDropReactions", () => { await waitFor(() => { expect(button).toHaveTextContent("3"); }); + expect(commonApi.commonApiPost).toHaveBeenCalledWith({ + endpoint: "drops/test-drop/reaction", + body: { reaction: ":gm:" }, + }); // Click button again to decrement fireEvent.click(button); await waitFor(() => { expect(button).toHaveTextContent("2"); }); + expect(commonApi.commonApiDelete).toHaveBeenCalledWith({ + endpoint: "drops/test-drop/reaction", + }); + }); + + it("renders reaction pills as non-interactive when disconnected", () => { + mockUseSeizeConnectContext.mockReturnValue({ isConnected: false }); + mockUseEmoji.mockReturnValue( + createEmojiContextValue( + [ + { + category: "people", + emojis: [{ id: "gm", skins: [{ src: "/gm.png" }] }], + }, + ], + () => null + ) + ); + + render( + + ); + + const button = screen.getByRole("button"); + expect(button).toBeDisabled(); + expect(button).not.toHaveAttribute("data-tooltip-id"); + expect(button).toHaveTextContent("4"); + + fireEvent.click(button); + + expect(button).toHaveTextContent("4"); + expect(commonApi.commonApiPost).not.toHaveBeenCalled(); + expect(commonApi.commonApiDelete).not.toHaveBeenCalled(); + expect(screen.queryByText("Reactions")).not.toBeInTheDocument(); }); it("shows 'and X more' in tooltip when more than 3 profiles", async () => { diff --git a/__tests__/components/waves/drops/wave-drops-all/hooks/useWaveDropsNotificationRead.test.tsx b/__tests__/components/waves/drops/wave-drops-all/hooks/useWaveDropsNotificationRead.test.tsx new file mode 100644 index 0000000000..82d6de3319 --- /dev/null +++ b/__tests__/components/waves/drops/wave-drops-all/hooks/useWaveDropsNotificationRead.test.tsx @@ -0,0 +1,85 @@ +import { ReactQueryWrapperContext } from "@/components/react-query-wrapper/ReactQueryWrapper"; +import { useWaveDropsNotificationRead } from "@/components/waves/drops/wave-drops-all/hooks/useWaveDropsNotificationRead"; +import { commonApiPostWithoutBodyAndResponse } from "@/services/api/common-api"; +import { render, waitFor } from "@testing-library/react"; +import React from "react"; + +jest.mock("@/services/api/common-api", () => ({ + commonApiPostWithoutBodyAndResponse: jest.fn().mockResolvedValue(undefined), +})); + +function TestComponent({ + enabled, + removeWaveDeliveredNotifications, + waveId, +}: { + readonly enabled?: boolean; + readonly removeWaveDeliveredNotifications: ( + waveId: string + ) => Promise | void; + readonly waveId: string; +}) { + useWaveDropsNotificationRead({ + waveId, + enabled, + removeWaveDeliveredNotifications, + }); + + return null; +} + +describe("useWaveDropsNotificationRead", () => { + const invalidateNotifications = jest.fn(); + const removeWaveDeliveredNotifications = jest + .fn() + .mockResolvedValue(undefined); + + beforeEach(() => { + invalidateNotifications.mockClear(); + removeWaveDeliveredNotifications.mockClear(); + ( + commonApiPostWithoutBodyAndResponse as jest.MockedFunction< + typeof commonApiPostWithoutBodyAndResponse + > + ).mockClear(); + }); + + it("skips read-sync when disabled", () => { + render( + + + + ); + + expect(removeWaveDeliveredNotifications).not.toHaveBeenCalled(); + expect(commonApiPostWithoutBodyAndResponse).not.toHaveBeenCalled(); + expect(invalidateNotifications).not.toHaveBeenCalled(); + }); + + it("marks the wave as read when enabled", async () => { + render( + + + + ); + + await waitFor(() => { + expect(removeWaveDeliveredNotifications).toHaveBeenCalledWith("wave-1"); + expect(commonApiPostWithoutBodyAndResponse).toHaveBeenCalledWith({ + endpoint: "notifications/wave/wave-1/read", + }); + expect(invalidateNotifications).toHaveBeenCalled(); + }); + }); +}); diff --git a/components/brain/BrainMobile.tsx b/components/brain/BrainMobile.tsx index fa0a6276f3..360a4d3856 100644 --- a/components/brain/BrainMobile.tsx +++ b/components/brain/BrainMobile.tsx @@ -33,6 +33,7 @@ import BrainMobileViewContent from "./mobile/BrainMobileViewContent"; import FloatingMemesQuickVoteTrigger from "./mobile/FloatingMemesQuickVoteTrigger"; import { BrainView } from "./mobile/brainMobileViews"; import { useBrainMobileActiveView } from "./mobile/useBrainMobileActiveView"; +import { useSeizeConnectContext } from "@/components/auth/SeizeConnectContext"; interface Props { readonly children: ReactNode; @@ -44,6 +45,7 @@ const BrainMobile: React.FC = ({ children }) => { const pathname = usePathname(); const { isApp } = useDeviceInfo(); const { connectedProfile } = useAuth(); + const { isConnected } = useSeizeConnectContext(); const quickVote = useMemesQuickVoteDialogController(); const hydrated = useSyncExternalStore( () => () => {}, @@ -100,6 +102,7 @@ const BrainMobile: React.FC = ({ children }) => { firstDecisionDone, isApp, isCompleted, + isConnected, isCurationWave, isMemesWave, isRankWave, diff --git a/components/brain/ContentTabContext.tsx b/components/brain/ContentTabContext.tsx index 6e4b4ce69b..3827a93cc5 100644 --- a/components/brain/ContentTabContext.tsx +++ b/components/brain/ContentTabContext.tsx @@ -23,6 +23,7 @@ export enum WaveVotingState { type WaveTabParams = { waveId: string | null; isChatWave: boolean; + isConnected: boolean; isMemesWave: boolean; isCurationWave: boolean; votingState: WaveVotingState; @@ -52,6 +53,7 @@ const isValidWaveTabMap = ( }; const buildMemesTabs = ( + isConnected: boolean, votingState: WaveVotingState, hasFirstDecisionPassed: boolean ) => { @@ -63,7 +65,9 @@ const buildMemesTabs = ( if (hasFirstDecisionPassed) { tabs.push(MyStreamWaveTab.WINNERS); } - tabs.push(MyStreamWaveTab.MY_VOTES); + if (isConnected) { + tabs.push(MyStreamWaveTab.MY_VOTES); + } tabs.push(MyStreamWaveTab.OUTCOME); tabs.push(MyStreamWaveTab.FAQ); return tabs; @@ -142,6 +146,7 @@ export const ContentTabProvider: React.FC<{ children: ReactNode }> = ({ const { waveId, isChatWave, + isConnected, isMemesWave, isCurationWave, votingState, @@ -152,7 +157,7 @@ export const ContentTabProvider: React.FC<{ children: ReactNode }> = ({ if (isChatWave) { tabs = [MyStreamWaveTab.CHAT]; } else if (isMemesWave) { - tabs = buildMemesTabs(votingState, hasFirstDecisionPassed); + tabs = buildMemesTabs(isConnected, votingState, hasFirstDecisionPassed); } else { tabs = buildDefaultTabs( votingState, diff --git a/components/brain/mobile/BrainMobileTabs.tsx b/components/brain/mobile/BrainMobileTabs.tsx index 96e370662b..785dd8a238 100644 --- a/components/brain/mobile/BrainMobileTabs.tsx +++ b/components/brain/mobile/BrainMobileTabs.tsx @@ -11,6 +11,7 @@ import { ArrowLeftIcon } from "@heroicons/react/24/solid"; import { useUnreadIndicator } from "@/hooks/useUnreadIndicator"; import { useUnreadNotifications } from "@/hooks/useUnreadNotifications"; import { useAuth } from "@/components/auth/Auth"; +import { useSeizeConnectContext } from "@/components/auth/SeizeConnectContext"; import { getWaveHomeRoute } from "../../../helpers/navigation.helpers"; interface BrainMobileTabsProps { @@ -35,6 +36,7 @@ const BrainMobileTabs: React.FC = ({ const router = useRouter(); const { registerRef } = useLayout(); const { connectedProfile } = useAuth(); + const { isConnected } = useSeizeConnectContext(); // Local ref for component-specific needs const mobileTabsRef = useRef(null); @@ -264,7 +266,7 @@ const BrainMobileTabs: React.FC = ({ }} renderAfterLeaderboard={salesTabButton} /> - {(isMemesWave || isCurationWave) && ( + {(isCurationWave || (isMemesWave && isConnected)) && ( <> - )} - - + + + + + {copied ? "Copied!" : "Copy link"} + + + ) + ) : ( + <> + + + {showReplyAndQuote && ( + + )} - {showOpenOption && ( - - )} + - {showCopyOption && ( - - )} + )} - - {!isAuthor && ( - - )} - {showOptions && - onEdit && - drop.drop_type !== ApiDropType.Participatory && ( - + + + + + {copied ? "Copied!" : "Copy link"} + + + )} + + - )} - {canSetPinnedDrop && ( - - )} - {canDelete && ( - + {!isAuthor && ( + + )} + {showOptions && + onEdit && + drop.drop_type !== ApiDropType.Participatory && ( + + )} + {canSetPinnedDrop && ( + + )} + {canDelete && ( + + )} + )}
, diff --git a/components/waves/drops/WaveDropReactions.tsx b/components/waves/drops/WaveDropReactions.tsx index f4b8de07be..3784539fb9 100644 --- a/components/waves/drops/WaveDropReactions.tsx +++ b/components/waves/drops/WaveDropReactions.tsx @@ -1,6 +1,7 @@ "use client"; import { useAuth } from "@/components/auth/Auth"; +import { useSeizeConnectContext } from "@/components/auth/SeizeConnectContext"; import { useEmoji } from "@/contexts/EmojiContext"; import { useMyStream } from "@/contexts/wave/MyStreamContext"; import type { ApiAddReactionToDropRequest } from "@/generated/models/ApiAddReactionToDropRequest"; @@ -41,6 +42,7 @@ interface WaveDropReactionsProps { const WaveDropReactions: React.FC = ({ drop }) => { const [dialogReaction, setDialogReaction] = useState(null); const isTouchDevice = useIsTouchDevice(); + const { isConnected } = useSeizeConnectContext(); const handleOpenDialog = useCallback((reactionKey: string) => { setDialogReaction(reactionKey); @@ -58,6 +60,7 @@ const WaveDropReactions: React.FC = ({ drop }) => { drop={drop} reaction={reaction} onOpenDetailDialog={handleOpenDialog} + isConnected={isConnected} isTouchDevice={isTouchDevice} /> ))} @@ -75,17 +78,20 @@ function WaveDropReaction({ drop, reaction, onOpenDetailDialog, + isConnected, isTouchDevice, }: { readonly drop: ApiDrop; readonly reaction: ApiDropReaction; readonly onOpenDetailDialog: (reactionKey: string) => void; + readonly isConnected: boolean; readonly isTouchDevice: boolean; }) { const { setToast, connectedProfile } = useAuth(); const { emojiMap, findNativeEmoji } = useEmoji(); const { applyOptimisticDropUpdate } = useMyStream(); const rollbackRef = useRef<(() => void) | null>(null); + const canReact = isConnected; const handleLongPressStart = useCallback(() => { onOpenDetailDialog(reaction.reaction); @@ -321,7 +327,7 @@ function WaveDropReaction({ ); const handleClick = useCallback(async () => { - if (longPressTriggered) { + if (!canReact || longPressTriggered) { return; } @@ -360,6 +366,7 @@ function WaveDropReaction({ rollbackRef.current = null; }, [ applyOptimisticReactionChange, + canReact, drop.id, longPressTriggered, reaction.reaction, @@ -384,9 +391,12 @@ function WaveDropReaction({ // styles const borderStyle = selected ? "tw-border-primary-500" : "tw-border-iron-700"; const bgStyle = selected ? "tw-bg-primary-500/10" : "tw-bg-iron-900/40"; - const hoverStyle = selected - ? "hover:tw-border-primary-500 hover:tw-bg-primary-500/10" - : "hover:tw-border-iron-500 hover:tw-bg-iron-900/40"; + let hoverStyle = ""; + if (canReact) { + hoverStyle = selected + ? "hover:tw-border-primary-500 hover:tw-bg-primary-500/10" + : "hover:tw-border-iron-500 hover:tw-bg-iron-900/40"; + } let animationStyle = ""; if (animate) { if (selected) { @@ -400,11 +410,14 @@ function WaveDropReaction({ return ( <>