diff --git a/__tests__/components/brain/left-sidebar/waves/BrainLeftSidebarWave.test.tsx b/__tests__/components/brain/left-sidebar/waves/BrainLeftSidebarWave.test.tsx index f163abf3f1..475f077658 100644 --- a/__tests__/components/brain/left-sidebar/waves/BrainLeftSidebarWave.test.tsx +++ b/__tests__/components/brain/left-sidebar/waves/BrainLeftSidebarWave.test.tsx @@ -158,4 +158,25 @@ describe("BrainLeftSidebarWave", () => { ); expect(bellSlashIcons.length).toBe(0); }); + + it("hides the pin control when showPin is false", () => { + const wave = { + ...baseWave, + id: "7", + }; + render( + + ); + expect(screen.queryByTestId("pin")).not.toBeInTheDocument(); + }); + + it("shows the unpin control when showPin is true", () => { + const wave = { + ...baseWave, + id: "8", + isPinned: true, + }; + render(); + expect(screen.getByTestId("pin")).toHaveTextContent("true"); + }); }); diff --git a/__tests__/components/brain/left-sidebar/waves/BrainLeftSidebarWavePin.test.tsx b/__tests__/components/brain/left-sidebar/waves/BrainLeftSidebarWavePin.test.tsx index 033a740fc2..6f5dca2259 100644 --- a/__tests__/components/brain/left-sidebar/waves/BrainLeftSidebarWavePin.test.tsx +++ b/__tests__/components/brain/left-sidebar/waves/BrainLeftSidebarWavePin.test.tsx @@ -1,9 +1,12 @@ -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import BrainLeftSidebarWavePin from '@/components/brain/left-sidebar/waves/BrainLeftSidebarWavePin'; -import { MAX_PINNED_WAVES, usePinnedWavesServer } from '@/hooks/usePinnedWavesServer'; -import { useMyStream } from '@/contexts/wave/MyStreamContext'; -import { useAuth } from '@/components/auth/Auth'; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import BrainLeftSidebarWavePin from "@/components/brain/left-sidebar/waves/BrainLeftSidebarWavePin"; +import { + MAX_PINNED_WAVES, + usePinnedWavesServer, +} from "@/hooks/usePinnedWavesServer"; +import { useMyStream } from "@/contexts/wave/MyStreamContext"; +import { useAuth } from "@/components/auth/Auth"; // Mock ResizeObserver global.ResizeObserver = jest.fn().mockImplementation(() => ({ @@ -13,66 +16,82 @@ global.ResizeObserver = jest.fn().mockImplementation(() => ({ })); // Mock react-tooltip -jest.mock('react-tooltip', () => ({ +jest.mock("react-tooltip", () => ({ Tooltip: ({ children, id }: any) => (
{children}
), })); -jest.mock('@fortawesome/react-fontawesome', () => ({ FontAwesomeIcon: () => })); -jest.mock('@/contexts/wave/MyStreamContext'); -jest.mock('@/hooks/usePinnedWavesServer'); -jest.mock('@/components/auth/Auth'); +jest.mock("@fortawesome/react-fontawesome", () => ({ + FontAwesomeIcon: () => , +})); +jest.mock("@/contexts/wave/MyStreamContext"); +jest.mock("@/hooks/usePinnedWavesServer"); +jest.mock("@/components/auth/Auth"); const addPinnedWave = jest.fn(); const removePinnedWave = jest.fn(); +const setToast = jest.fn(); const mockedUseMyStream = useMyStream as jest.Mock; const mockedUsePinnedWavesServer = usePinnedWavesServer as jest.Mock; const mockedUseAuth = useAuth as jest.Mock; -function setup(isPinned = false, storedPinned: string[] = []) { - mockedUseMyStream.mockReturnValue({ waves: { addPinnedWave, removePinnedWave } }); - mockedUsePinnedWavesServer.mockReturnValue({ +function setup( + isPinned = false, + storedPinned: string[] = [], + canPinWave = (waveId: string) => isPinned || !storedPinned.includes(waveId) +) { + mockedUseMyStream.mockReturnValue({ + waves: { addPinnedWave, removePinnedWave }, + }); + mockedUsePinnedWavesServer.mockReturnValue({ pinnedIds: storedPinned, - isOperationInProgress: jest.fn().mockReturnValue(false) + isOperationInProgress: jest.fn().mockReturnValue(false), + canPinWave: jest.fn().mockImplementation(canPinWave), }); mockedUseAuth.mockReturnValue({ - setToast: jest.fn() + setToast, }); - localStorage.setItem('pinnedWave', JSON.stringify(storedPinned)); + localStorage.setItem("pinnedWave", JSON.stringify(storedPinned)); return render(); } -describe('BrainLeftSidebarWavePin', () => { +describe("BrainLeftSidebarWavePin", () => { beforeEach(() => { jest.clearAllMocks(); localStorage.clear(); }); - it('unpins wave when already pinned', async () => { + it("unpins wave when already pinned", async () => { const user = userEvent.setup(); - setup(true, ['1']); - await user.click(screen.getByRole('button', { name: /unpin wave/i })); - expect(removePinnedWave).toHaveBeenCalledWith('1'); + setup(true, ["1"]); + await user.click(screen.getByRole("button", { name: /unpin wave/i })); + expect(removePinnedWave).toHaveBeenCalledWith("1"); expect(addPinnedWave).not.toHaveBeenCalled(); }); - it('pins wave when under max limit', async () => { + it("pins wave when under max limit", async () => { const user = userEvent.setup(); setup(false, []); - await user.click(screen.getByRole('button', { name: /pin wave/i })); - expect(addPinnedWave).toHaveBeenCalledWith('1'); + await user.click(screen.getByRole("button", { name: /pin wave/i })); + expect(addPinnedWave).toHaveBeenCalledWith("1"); expect(removePinnedWave).not.toHaveBeenCalled(); }); - it('shows tooltip and does not pin when max limit reached', async () => { + it("shows tooltip and does not pin when max limit reached", async () => { const user = userEvent.setup(); - const maxList = Array(MAX_PINNED_WAVES).fill('x'); - setup(false, maxList); - await user.click(screen.getByRole('button', { name: /pin wave/i })); + const maxList = Array(MAX_PINNED_WAVES).fill("x"); + setup(false, maxList, () => false); + await user.click(screen.getByRole("button", { name: /pin wave/i })); expect(addPinnedWave).not.toHaveBeenCalled(); - const tooltip = screen.getByTestId('tooltip-wave-pin-1'); - expect(tooltip).toHaveTextContent(`Max ${MAX_PINNED_WAVES} pinned waves. Unpin another wave first.`); + expect(setToast).toHaveBeenCalledWith({ + type: "error", + message: `Maximum ${MAX_PINNED_WAVES} pinned waves allowed`, + }); + const tooltip = screen.getByTestId("tooltip-wave-pin-1"); + expect(tooltip).toHaveTextContent( + `Max ${MAX_PINNED_WAVES} pinned waves. Unpin another wave first.` + ); }); }); diff --git a/__tests__/components/brain/left-sidebar/waves/UnifiedWavesListWaves.test.tsx b/__tests__/components/brain/left-sidebar/waves/UnifiedWavesListWaves.test.tsx index aaaba0d828..ed7608013c 100644 --- a/__tests__/components/brain/left-sidebar/waves/UnifiedWavesListWaves.test.tsx +++ b/__tests__/components/brain/left-sidebar/waves/UnifiedWavesListWaves.test.tsx @@ -1,75 +1,138 @@ -import React from 'react'; -import { render, screen } from '@testing-library/react'; -import type { UnifiedWavesListWavesHandle } from '@/components/brain/left-sidebar/waves/UnifiedWavesListWaves'; -import UnifiedWavesListWaves from '@/components/brain/left-sidebar/waves/UnifiedWavesListWaves'; -import { useShowFollowingWaves } from '@/hooks/useShowFollowingWaves'; -import { useAuth } from '@/components/auth/Auth'; -import { useVirtualizedWaves } from '@/hooks/useVirtualizedWaves'; -import { createMockMinimalWave } from '@/__tests__/utils/mockFactories'; +import React from "react"; +import { render, screen } from "@testing-library/react"; +import type { UnifiedWavesListWavesHandle } from "@/components/brain/left-sidebar/waves/UnifiedWavesListWaves"; +import UnifiedWavesListWaves from "@/components/brain/left-sidebar/waves/UnifiedWavesListWaves"; +import { useShowFollowingWaves } from "@/hooks/useShowFollowingWaves"; +import { useAuth } from "@/components/auth/Auth"; +import { useVirtualizedWaves } from "@/hooks/useVirtualizedWaves"; +import { useSeizeSettingsOptional } from "@/contexts/SeizeSettingsContext"; +import { createMockMinimalWave } from "@/__tests__/utils/mockFactories"; -jest.mock('@/components/utils/switch/CommonSwitch', () => (props: any) =>
{props.label}-{String(props.isOn)}
); -jest.mock('@/components/brain/left-sidebar/waves/BrainLeftSidebarWave', () => (props: any) =>
); -jest.mock('@/components/brain/left-sidebar/waves/SectionHeader', () => (props: any) =>
{props.label}{props.rightContent}
); +jest.mock("@/components/utils/switch/CommonSwitch", () => (props: any) => ( +
+ {props.label}-{String(props.isOn)} +
+)); +jest.mock( + "@/components/brain/left-sidebar/waves/BrainLeftSidebarWave", + () => (props: any) => ( +
+ ) +); +jest.mock( + "@/components/brain/left-sidebar/waves/SectionHeader", + () => (props: any) => ( +
+ {props.label} + {props.rightContent} +
+ ) +); -jest.mock('@/hooks/useShowFollowingWaves'); -jest.mock('@/components/auth/Auth'); -jest.mock('@/hooks/useVirtualizedWaves'); +jest.mock("@/hooks/useShowFollowingWaves"); +jest.mock("@/components/auth/Auth"); +jest.mock("@/hooks/useVirtualizedWaves"); +jest.mock("@/contexts/SeizeSettingsContext", () => ({ + useSeizeSettingsOptional: jest.fn(), +})); const mockUseShowFollowingWaves = useShowFollowingWaves as jest.Mock; const mockUseAuth = useAuth as jest.Mock; const mockUseVirtualizedWaves = useVirtualizedWaves as jest.Mock; +const mockUseSeizeSettingsOptional = useSeizeSettingsOptional as jest.Mock; -const scrollRef = { current: document.createElement('div') } as React.RefObject; -const container = document.createElement('div'); -const sentinel = document.createElement('div'); +const scrollRef = { + current: document.createElement("div"), +} as React.RefObject; +const container = document.createElement("div"); +const sentinel = document.createElement("div"); const baseWaves = [ - createMockMinimalWave({ id: 'p1', isPinned: true }), - createMockMinimalWave({ id: 'r1', isPinned: false }) + createMockMinimalWave({ id: "a1" }), + createMockMinimalWave({ id: "p1", isPinned: true }), + createMockMinimalWave({ id: "r1", isPinned: false }), ]; beforeEach(() => { jest.clearAllMocks(); mockUseShowFollowingWaves.mockReturnValue([false, jest.fn()]); - mockUseAuth.mockReturnValue({ connectedProfile: { handle: 'alice' }, activeProfileProxy: null }); + mockUseAuth.mockReturnValue({ + connectedProfile: { handle: "alice" }, + activeProfileProxy: null, + }); + mockUseSeizeSettingsOptional.mockReturnValue({ + isAnnouncementsWave: (waveId: string) => waveId === "a1", + }); mockUseVirtualizedWaves.mockReturnValue({ containerRef: { current: container }, sentinelRef: { current: sentinel }, virtualItems: [ { index: 0, start: 0, size: 62 }, - { index: 1, start: 62, size: 40 } + { index: 1, start: 62, size: 40 }, ], - totalHeight: 102 + totalHeight: 102, }); }); -it('renders structure even when no waves', () => { +it("renders structure even when no waves", () => { const { container } = render( - + ); expect(container.firstChild).not.toBeNull(); - expect(screen.getByTestId('header-All Waves')).toBeInTheDocument(); - expect(screen.getByTestId('switch')).toBeInTheDocument(); + expect(screen.getByTestId("header-All Waves")).toBeInTheDocument(); + expect(screen.getByTestId("switch")).toBeInTheDocument(); }); -it('renders pinned and regular waves with headers and switch', () => { +it("renders pinned and regular waves with headers and switch", () => { const ref = React.createRef(); render( - + ); - // Component only renders "All Waves" header, pinned waves don't get their own header - expect(screen.getByTestId('header-All Waves')).toBeInTheDocument(); - expect(screen.getByTestId('switch')).toBeInTheDocument(); - // Verify pinned waves section exists by aria-label - expect(screen.getByLabelText('Pinned waves')).toBeInTheDocument(); - expect(screen.getByTestId('wave-p1')).toHaveAttribute('data-pin', 'true'); - expect(screen.getByTestId('wave-r1')).toHaveAttribute('data-pin', 'true'); + expect(screen.getByTestId("header-All Waves")).toBeInTheDocument(); + expect(screen.getByTestId("switch")).toBeInTheDocument(); + expect(screen.getByLabelText("Announcement waves")).toBeInTheDocument(); + expect(screen.getByLabelText("Pinned waves")).toBeInTheDocument(); + expect(screen.getByTestId("wave-a1")).toHaveAttribute("data-pin", "false"); + expect(screen.getByTestId("wave-p1")).toHaveAttribute("data-pin", "true"); + expect(screen.getByTestId("wave-r1")).toHaveAttribute("data-pin", "true"); expect(ref.current?.containerRef.current).toBe(container); expect(ref.current?.sentinelRef.current).toBeInstanceOf(HTMLElement); }); -it('respects hide options and does not render toggle when not connected', () => { - mockUseAuth.mockReturnValue({ connectedProfile: null, activeProfileProxy: null }); +it("passes pin controls through for pinned announcement waves", () => { + render( + + ); + + expect(screen.getByTestId("wave-a1")).toHaveAttribute("data-pin", "true"); +}); + +it("respects hide options and does not render toggle when not connected", () => { + mockUseAuth.mockReturnValue({ + connectedProfile: null, + activeProfileProxy: null, + }); render( hideToggle /> ); - expect(screen.queryByTestId('header-Pinned')).toBeNull(); - expect(screen.queryByTestId('header-All Waves')).toBeNull(); - expect(screen.queryByTestId('switch')).toBeNull(); - expect(screen.queryByTestId('wave-p1')).toBeNull(); - expect(screen.getByTestId('wave-r1')).toHaveAttribute('data-pin', 'false'); + expect(screen.queryByTestId("header-Pinned")).toBeNull(); + expect(screen.queryByTestId("header-All Waves")).toBeNull(); + expect(screen.queryByTestId("switch")).toBeNull(); + expect(screen.getByTestId("wave-a1")).toHaveAttribute("data-pin", "false"); + expect(screen.queryByTestId("wave-p1")).toBeNull(); + expect(screen.getByTestId("wave-r1")).toHaveAttribute("data-pin", "false"); }); diff --git a/__tests__/components/brain/left-sidebar/web/WebUnifiedWavesListWaves.test.tsx b/__tests__/components/brain/left-sidebar/web/WebUnifiedWavesListWaves.test.tsx new file mode 100644 index 0000000000..59cc2c15d7 --- /dev/null +++ b/__tests__/components/brain/left-sidebar/web/WebUnifiedWavesListWaves.test.tsx @@ -0,0 +1,117 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import type { WebUnifiedWavesListWavesHandle } from "@/components/brain/left-sidebar/web/WebUnifiedWavesListWaves"; +import WebUnifiedWavesListWaves from "@/components/brain/left-sidebar/web/WebUnifiedWavesListWaves"; +import { useVirtualizedWaves } from "@/hooks/useVirtualizedWaves"; +import { useSeizeSettingsOptional } from "@/contexts/SeizeSettingsContext"; +import { createMockMinimalWave } from "@/__tests__/utils/mockFactories"; + +jest.mock("@/components/utils/button/PrimaryButton", () => (props: any) => ( + +)); +jest.mock("@/hooks/useCreateModalState", () => ({ + __esModule: true, + default: () => ({ openWave: jest.fn(), isApp: false }), +})); +jest.mock("@/hooks/useIsTouchDevice", () => ({ + __esModule: true, + default: () => false, +})); +jest.mock("@/components/auth/Auth", () => ({ + useAuth: () => ({ connectedProfile: { handle: "alice" } }), +})); +jest.mock( + "@/components/brain/left-sidebar/waves/SectionHeader", + () => (props: any) => ( +
{props.label}
+ ) +); +jest.mock( + "@/components/brain/left-sidebar/waves/WavesFilterToggle", + () => () =>
+); +jest.mock( + "@/components/brain/left-sidebar/web/WebBrainLeftSidebarWave", + () => (props: any) => ( +
+ ) +); +jest.mock("react-tooltip", () => ({ + Tooltip: () => null, +})); +jest.mock("@/hooks/useVirtualizedWaves"); +jest.mock("@/contexts/SeizeSettingsContext", () => ({ + useSeizeSettingsOptional: jest.fn(), +})); + +const mockUseVirtualizedWaves = useVirtualizedWaves as jest.Mock; +const mockUseSeizeSettingsOptional = useSeizeSettingsOptional as jest.Mock; + +const scrollRef = { + current: document.createElement("div"), +} as React.RefObject; +const sentinel = document.createElement("div"); + +const baseWaves = [ + createMockMinimalWave({ id: "a1" }), + createMockMinimalWave({ id: "p1", isPinned: true }), + createMockMinimalWave({ id: "r1", isPinned: false }), +]; + +beforeEach(() => { + jest.clearAllMocks(); + mockUseSeizeSettingsOptional.mockReturnValue({ + isAnnouncementsWave: (waveId: string) => waveId === "a1", + }); + mockUseVirtualizedWaves.mockReturnValue({ + containerRef: { current: document.createElement("div") }, + sentinelRef: { current: sentinel }, + virtualItems: [ + { index: 0, start: 0, size: 62 }, + { index: 1, start: 62, size: 40 }, + ], + totalHeight: 102, + }); +}); + +it("renders announcement, pinned, and regular sections without double rendering", () => { + const ref = React.createRef(); + + render( + + ); + + expect(screen.getByTestId("header-Waves")).toBeInTheDocument(); + expect(screen.getByTestId("waves-filter-toggle")).toBeInTheDocument(); + expect(screen.getByLabelText("Announcement waves")).toBeInTheDocument(); + expect(screen.getByLabelText("Pinned waves")).toBeInTheDocument(); + expect(screen.getByTestId("wave-a1")).toHaveAttribute("data-pin", "false"); + expect(screen.getByTestId("wave-p1")).toHaveAttribute("data-pin", "true"); + expect(screen.getByTestId("wave-r1")).toHaveAttribute("data-pin", "true"); + expect(ref.current?.sentinelRef.current).toBe(sentinel); +}); + +it("passes pin controls through for pinned announcement waves", () => { + render( + + ); + + expect(screen.getByTestId("wave-a1")).toHaveAttribute("data-pin", "true"); +}); diff --git a/__tests__/components/waves/header/WaveHeaderPinButton.test.tsx b/__tests__/components/waves/header/WaveHeaderPinButton.test.tsx index 14f5ef1ed1..a6790db122 100644 --- a/__tests__/components/waves/header/WaveHeaderPinButton.test.tsx +++ b/__tests__/components/waves/header/WaveHeaderPinButton.test.tsx @@ -1,15 +1,16 @@ -import React from 'react'; -import { render, screen, fireEvent } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import WaveHeaderPinButton from '@/components/waves/header/WaveHeaderPinButton'; -import { AuthContext } from '@/components/auth/Auth'; +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import WaveHeaderPinButton from "@/components/waves/header/WaveHeaderPinButton"; +import { AuthContext } from "@/components/auth/Auth"; +import { useSeizeSettings } from "@/contexts/SeizeSettingsContext"; // Create mocks that we can access const mockAddPinnedWave = jest.fn(); const mockRemovePinnedWave = jest.fn(); // Mock the MyStreamContext -jest.mock('@/contexts/wave/MyStreamContext', () => ({ +jest.mock("@/contexts/wave/MyStreamContext", () => ({ useMyStream: () => ({ waves: { addPinnedWave: mockAddPinnedWave, @@ -21,21 +22,25 @@ jest.mock('@/contexts/wave/MyStreamContext', () => ({ // Create mocks that we can modify during tests const mockUsePinnedWavesServer = jest.fn(); const mockIsOperationInProgress = jest.fn(() => false); +const mockCanPinWave = jest.fn(() => true); -jest.mock('@/hooks/usePinnedWavesServer', () => ({ +jest.mock("@/hooks/usePinnedWavesServer", () => ({ usePinnedWavesServer: () => mockUsePinnedWavesServer(), MAX_PINNED_WAVES: 3, })); -jest.mock('react-tooltip', () => ({ +jest.mock("@/contexts/SeizeSettingsContext", () => ({ + useSeizeSettings: jest.fn(), +})); + +jest.mock("react-tooltip", () => ({ Tooltip: ({ children, id }: any) => (
{children}
), })); - const mockAuth = { - connectedProfile: { handle: 'testuser' }, + connectedProfile: { handle: "testuser" }, activeProfileProxy: null, setToast: jest.fn(), }; @@ -47,24 +52,32 @@ const mockAuthNotConnected = { }; const mockAuthWithProxy = { - connectedProfile: { handle: 'testuser' }, - activeProfileProxy: { id: 'proxy1' }, + connectedProfile: { handle: "testuser" }, + activeProfileProxy: { id: "proxy1" }, setToast: jest.fn(), }; -describe('WaveHeaderPinButton', () => { +const mockUseSeizeSettings = useSeizeSettings as jest.Mock; + +describe("WaveHeaderPinButton", () => { beforeEach(() => { jest.clearAllMocks(); + mockUseSeizeSettings.mockReturnValue({ + isAnnouncementsWave: () => false, + isLoaded: true, + }); // Set default mock return values mockUsePinnedWavesServer.mockReturnValue({ pinnedIds: [], isOperationInProgress: mockIsOperationInProgress, + canPinWave: mockCanPinWave, }); mockIsOperationInProgress.mockReturnValue(false); + mockCanPinWave.mockReturnValue(true); }); - const renderComponent = (authContext = mockAuth, waveId = 'wave-123') => { + const renderComponent = (authContext = mockAuth, waveId = "wave-123") => { return render( @@ -72,267 +85,344 @@ describe('WaveHeaderPinButton', () => { ); }; - describe('Authentication Checks', () => { - it('does not render when user is not authenticated', () => { + describe("Authentication Checks", () => { + it("does not render when user is not authenticated", () => { const { container } = renderComponent(mockAuthNotConnected); expect(container.firstChild).toBeNull(); }); - it('does not render when user is using proxy', () => { + it("does not render when user is using proxy", () => { const { container } = renderComponent(mockAuthWithProxy); expect(container.firstChild).toBeNull(); }); - it('renders when user is authenticated without proxy', () => { + it("does not render for the announcement wave", () => { + mockUseSeizeSettings.mockReturnValue({ + isAnnouncementsWave: (waveId: string) => waveId === "wave-123", + isLoaded: true, + }); + const { container } = renderComponent(); + expect(container.firstChild).toBeNull(); + }); + + it("hides ordinary waves while settings are still loading", () => { + mockUseSeizeSettings.mockReturnValue({ + isAnnouncementsWave: () => false, + isLoaded: false, + }); + const { container } = renderComponent(); + expect(container.firstChild).toBeNull(); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + }); + + it("renders unpin control for a pinned announcement wave", () => { + mockUseSeizeSettings.mockReturnValue({ + isAnnouncementsWave: (waveId: string) => waveId === "wave-123", + isLoaded: false, + }); + mockUsePinnedWavesServer.mockReturnValue({ + pinnedIds: ["wave-123"], + isOperationInProgress: mockIsOperationInProgress, + canPinWave: mockCanPinWave, + }); renderComponent(); - expect(screen.getByRole('button')).toBeInTheDocument(); + expect(screen.getByRole("button")).toHaveAttribute( + "aria-label", + "Unpin wave" + ); + }); + + it("renders when user is authenticated without proxy", () => { + renderComponent(); + expect(screen.getByRole("button")).toBeInTheDocument(); }); }); - describe('Pin Button Rendering', () => { - it('renders pin button with correct styling', () => { + describe("Pin Button Rendering", () => { + it("renders pin button with correct styling", () => { renderComponent(); - const button = screen.getByRole('button'); - + const button = screen.getByRole("button"); + expect(button).toBeInTheDocument(); - expect(button).toHaveAttribute('aria-label', 'Pin wave'); - expect(button.querySelector('svg')).toBeInTheDocument(); + expect(button).toHaveAttribute("aria-label", "Pin wave"); + expect(button.querySelector("svg")).toBeInTheDocument(); }); - it('renders tooltip with correct content for unpinned wave', () => { + it("renders tooltip with correct content for unpinned wave", () => { renderComponent(); - expect(screen.getByTestId('tooltip-wave-header-pin-wave-123')).toBeInTheDocument(); + expect( + screen.getByTestId("tooltip-wave-header-pin-wave-123") + ).toBeInTheDocument(); }); }); - describe('Pin Functionality', () => { - it('pins wave when clicked and not currently pinned', async () => { + describe("Pin Functionality", () => { + it("pins wave when clicked and not currently pinned", async () => { const user = userEvent.setup(); renderComponent(); - - const button = screen.getByRole('button'); + + const button = screen.getByRole("button"); await user.click(button); - - expect(mockAddPinnedWave).toHaveBeenCalledWith('wave-123'); + + expect(mockAddPinnedWave).toHaveBeenCalledWith("wave-123"); expect(mockRemovePinnedWave).not.toHaveBeenCalled(); }); - it('prevents event propagation when clicked', async () => { + it("prevents event propagation when clicked", async () => { const user = userEvent.setup(); const mockStopPropagation = jest.fn(); const mockPreventDefault = jest.fn(); - + renderComponent(); - const button = screen.getByRole('button'); - + const button = screen.getByRole("button"); + // Mock the event methods button.click = () => { - const event = new MouseEvent('click', { bubbles: true }); + const event = new MouseEvent("click", { bubbles: true }); event.stopPropagation = mockStopPropagation; event.preventDefault = mockPreventDefault; fireEvent(button, event); }; - + await user.click(button); - + expect(mockAddPinnedWave).toHaveBeenCalled(); }); }); - describe('Unpin Functionality', () => { + describe("Unpin Functionality", () => { beforeEach(() => { // Mock usePinnedWavesServer to return wave as pinned mockUsePinnedWavesServer.mockReturnValue({ - pinnedIds: ['wave-123'], + pinnedIds: ["wave-123"], isOperationInProgress: mockIsOperationInProgress, + canPinWave: mockCanPinWave, }); }); - it('unpins wave when clicked and currently pinned', async () => { + it("unpins wave when clicked and currently pinned", async () => { // Re-render with updated mock const user = userEvent.setup(); renderComponent(); - - const button = screen.getByRole('button'); - expect(button).toHaveAttribute('aria-label', 'Unpin wave'); - + + const button = screen.getByRole("button"); + expect(button).toHaveAttribute("aria-label", "Unpin wave"); + await user.click(button); - - expect(mockRemovePinnedWave).toHaveBeenCalledWith('wave-123'); + + expect(mockRemovePinnedWave).toHaveBeenCalledWith("wave-123"); expect(mockAddPinnedWave).not.toHaveBeenCalled(); }); + + it("unpins a pinned announcement wave when clicked", async () => { + mockUseSeizeSettings.mockReturnValue({ + isAnnouncementsWave: (waveId: string) => waveId === "wave-123", + isLoaded: false, + }); + const user = userEvent.setup(); + renderComponent(); + + const button = screen.getByRole("button"); + await user.click(button); + + expect(mockRemovePinnedWave).toHaveBeenCalledWith("wave-123"); + }); }); - describe('Max Limit Handling', () => { + describe("Max Limit Handling", () => { beforeEach(() => { // Mock usePinnedWavesServer to return max pinned waves mockUsePinnedWavesServer.mockReturnValue({ - pinnedIds: ['wave-1', 'wave-2', 'wave-3'], + pinnedIds: ["wave-1", "wave-2", "wave-3"], isOperationInProgress: mockIsOperationInProgress, + canPinWave: mockCanPinWave, }); + mockCanPinWave.mockReturnValue(false); }); - it('shows error toast when trying to pin beyond limit', async () => { + it("shows error toast when trying to pin beyond limit", async () => { const user = userEvent.setup(); renderComponent(); - - const button = screen.getByRole('button'); + + const button = screen.getByRole("button"); await user.click(button); - + expect(mockAuth.setToast).toHaveBeenCalledWith({ - type: 'error', - message: 'Maximum 3 pinned waves allowed', + type: "error", + message: "Maximum 3 pinned waves allowed", + }); + expect(mockAddPinnedWave).not.toHaveBeenCalled(); + }); + + it("keeps the header control hidden during settings load at max capacity", () => { + mockUseSeizeSettings.mockReturnValue({ + isAnnouncementsWave: () => false, + isLoaded: false, }); + + const { container } = renderComponent(); + + expect(container.firstChild).toBeNull(); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + expect(mockAuth.setToast).not.toHaveBeenCalled(); expect(mockAddPinnedWave).not.toHaveBeenCalled(); }); }); - describe('Loading States', () => { + describe("Loading States", () => { beforeEach(() => { // Mock usePinnedWavesServer to return operation in progress mockIsOperationInProgress.mockReturnValue(true); mockUsePinnedWavesServer.mockReturnValue({ pinnedIds: [], isOperationInProgress: mockIsOperationInProgress, + canPinWave: mockCanPinWave, }); }); - it('disables button when operation is in progress', () => { + it("disables button when operation is in progress", () => { renderComponent(); - - const button = screen.getByRole('button'); + + const button = screen.getByRole("button"); expect(button).toBeDisabled(); - expect(button).toHaveClass('tw-opacity-50', 'tw-cursor-not-allowed'); + expect(button).toHaveClass("tw-opacity-50", "tw-cursor-not-allowed"); }); - it('does not execute action when operation is in progress', async () => { + it("does not execute action when operation is in progress", async () => { const user = userEvent.setup(); renderComponent(); - - const button = screen.getByRole('button'); + + const button = screen.getByRole("button"); await user.click(button); - + expect(mockAddPinnedWave).not.toHaveBeenCalled(); expect(mockRemovePinnedWave).not.toHaveBeenCalled(); }); }); - describe('Error Handling', () => { - it('handles errors when pinning fails', async () => { + describe("Error Handling", () => { + it("handles errors when pinning fails", async () => { const user = userEvent.setup(); - const mockError = new Error('Network error'); + const mockError = new Error("Network error"); mockAddPinnedWave.mockImplementation(() => { throw mockError; }); - + renderComponent(); - - const button = screen.getByRole('button'); + + const button = screen.getByRole("button"); await user.click(button); - + expect(mockAuth.setToast).toHaveBeenCalledWith({ - type: 'error', - message: 'Failed to pin wave: Network error', + type: "error", + message: "Failed to pin wave: Network error", }); }); - it('handles errors when unpinning fails', async () => { + it("handles errors when unpinning fails", async () => { const user = userEvent.setup(); - const mockError = new Error('Server error'); + const mockError = new Error("Server error"); mockRemovePinnedWave.mockImplementation(() => { throw mockError; }); - + // Mock as pinned wave mockUsePinnedWavesServer.mockReturnValue({ - pinnedIds: ['wave-123'], + pinnedIds: ["wave-123"], isOperationInProgress: mockIsOperationInProgress, + canPinWave: mockCanPinWave, }); - + renderComponent(); - - const button = screen.getByRole('button'); + + const button = screen.getByRole("button"); await user.click(button); - + expect(mockAuth.setToast).toHaveBeenCalledWith({ - type: 'error', - message: 'Failed to unpin wave: Server error', + type: "error", + message: "Failed to unpin wave: Server error", }); }); - it('handles non-Error objects in catch block', async () => { + it("handles non-Error objects in catch block", async () => { const user = userEvent.setup(); mockAddPinnedWave.mockImplementation(() => { - throw new Error('String error'); + throw new Error("String error"); }); - + renderComponent(); - - const button = screen.getByRole('button'); + + const button = screen.getByRole("button"); await user.click(button); - + expect(mockAuth.setToast).toHaveBeenCalledWith({ - type: 'error', - message: 'Failed to pin wave: String error', + type: "error", + message: "Failed to pin wave: String error", }); }); }); - describe('Tooltip Content', () => { - it('shows correct tooltip for unpinned wave', () => { + describe("Tooltip Content", () => { + it("shows correct tooltip for unpinned wave", () => { renderComponent(); - const tooltip = screen.getByTestId('tooltip-wave-header-pin-wave-123'); - expect(tooltip).toHaveTextContent('Pin wave'); + const tooltip = screen.getByTestId("tooltip-wave-header-pin-wave-123"); + expect(tooltip).toHaveTextContent("Pin wave"); }); - it('shows correct tooltip for pinned wave', () => { + it("shows correct tooltip for pinned wave", () => { // Mock as pinned mockUsePinnedWavesServer.mockReturnValue({ - pinnedIds: ['wave-123'], + pinnedIds: ["wave-123"], isOperationInProgress: mockIsOperationInProgress, + canPinWave: mockCanPinWave, }); - + renderComponent(); - const tooltip = screen.getByTestId('tooltip-wave-header-pin-wave-123'); - expect(tooltip).toHaveTextContent('Unpin wave'); + const tooltip = screen.getByTestId("tooltip-wave-header-pin-wave-123"); + expect(tooltip).toHaveTextContent("Unpin wave"); }); - it('shows max limit tooltip when at capacity', () => { + it("shows max limit tooltip when at capacity", () => { // Mock as at max capacity mockUsePinnedWavesServer.mockReturnValue({ - pinnedIds: ['wave-1', 'wave-2', 'wave-3'], + pinnedIds: ["wave-1", "wave-2", "wave-3"], isOperationInProgress: mockIsOperationInProgress, + canPinWave: mockCanPinWave, }); - + mockCanPinWave.mockReturnValue(false); + renderComponent(); - const tooltip = screen.getByTestId('tooltip-wave-header-pin-wave-123'); - expect(tooltip).toHaveTextContent('Max 3 pinned waves. Unpin another wave first.'); + const tooltip = screen.getByTestId("tooltip-wave-header-pin-wave-123"); + expect(tooltip).toHaveTextContent( + "Max 3 pinned waves. Unpin another wave first." + ); }); }); - describe('Visual States', () => { - it('applies correct styles for unpinned state', () => { + describe("Visual States", () => { + it("applies correct styles for unpinned state", () => { renderComponent(); - const button = screen.getByRole('button'); - const icon = button.querySelector('svg'); - - expect(button).toHaveClass('tw-text-iron-500'); - expect(icon).not.toHaveClass('tw-rotate-[-45deg]'); + const button = screen.getByRole("button"); + const icon = button.querySelector("svg"); + + expect(button).toHaveClass("tw-text-iron-500"); + expect(icon).not.toHaveClass("tw-rotate-[-45deg]"); }); - it('applies correct styles for pinned state', () => { + it("applies correct styles for pinned state", () => { // Mock as pinned mockUsePinnedWavesServer.mockReturnValue({ - pinnedIds: ['wave-123'], + pinnedIds: ["wave-123"], isOperationInProgress: mockIsOperationInProgress, + canPinWave: mockCanPinWave, }); - + renderComponent(); - const button = screen.getByRole('button'); - const icon = button.querySelector('svg'); - - expect(button).toHaveClass('tw-text-iron-200', 'tw-bg-iron-700'); - expect(icon).toHaveClass('tw-rotate-[-45deg]'); + const button = screen.getByRole("button"); + const icon = button.querySelector("svg"); + + expect(button).toHaveClass("tw-text-iron-200", "tw-bg-iron-700"); + expect(icon).toHaveClass("tw-rotate-[-45deg]"); }); }); -}); \ No newline at end of file +}); diff --git a/__tests__/contexts/SeizeSettingsContext.test.tsx b/__tests__/contexts/SeizeSettingsContext.test.tsx index f05751cd38..e685a7f525 100644 --- a/__tests__/contexts/SeizeSettingsContext.test.tsx +++ b/__tests__/contexts/SeizeSettingsContext.test.tsx @@ -18,15 +18,19 @@ test("provides settings and helper", async () => { all_drops_notifications_subscribers_limit: 2, memes_wave_id: "orig", curation_wave_id: "orig-curation", + announcements_wave_id: "announcement-wave-id", }); function Consumer() { - const { seizeSettings, isMemesWave, isCurationWave } = useSeizeSettings(); + const { seizeSettings, isMemesWave, isCurationWave, isAnnouncementsWave } = + useSeizeSettings(); return (
{`${seizeSettings.memes_wave_id}-${isMemesWave( "test-memes-wave-id" )}-${seizeSettings.curation_wave_id}-${isCurationWave( "test-curation-wave-id" + )}-${seizeSettings.announcements_wave_id}-${isAnnouncementsWave( + "announcement-wave-id" )}`}
); } @@ -39,7 +43,9 @@ test("provides settings and helper", async () => { await waitFor(() => expect( - screen.getByText("test-memes-wave-id-true-test-curation-wave-id-true") + screen.getByText( + "test-memes-wave-id-true-test-curation-wave-id-true-announcement-wave-id-true" + ) ).toBeInTheDocument() ); expect(fetchUrl).toHaveBeenCalledWith( @@ -47,6 +53,31 @@ test("provides settings and helper", async () => { ); }); +test("normalizes announcement wave ids before matching", async () => { + fetchUrl.mockResolvedValue({ + rememes_submission_tdh_threshold: 1, + all_drops_notifications_subscribers_limit: 2, + memes_wave_id: null, + curation_wave_id: null, + distribution_admin_wallets: [], + claims_admin_wallets: [], + announcements_wave_id: " announcement-wave-id ", + }); + + function Consumer() { + const { isAnnouncementsWave } = useSeizeSettings(); + return
{String(isAnnouncementsWave("announcement-wave-id"))}
; + } + + render( + + + + ); + + await waitFor(() => expect(screen.getByText("true")).toBeInTheDocument()); +}); + test("captures initial load failures without leaking an unhandled rejection", async () => { const expectedError = new Error("network down"); const onUnhandledRejection = jest.fn(); diff --git a/__tests__/contexts/wave/MyStreamContext.test.tsx b/__tests__/contexts/wave/MyStreamContext.test.tsx index 5f335b1eab..72a7900060 100644 --- a/__tests__/contexts/wave/MyStreamContext.test.tsx +++ b/__tests__/contexts/wave/MyStreamContext.test.tsx @@ -18,7 +18,7 @@ jest.mock("@/contexts/wave/hooks/useActiveWaveManager", () => ({ const addPinnedWave = jest.fn(); const removePinnedWave = jest.fn(); -jest.mock("@/contexts/wave/hooks/useEnhancedWavesList", () => ({ +jest.mock("@/contexts/wave/hooks/useEnhancedWavesListCore", () => ({ __esModule: true, default: () => ({ waves: [], diff --git a/__tests__/hooks/usePinnedWavesServer.test.tsx b/__tests__/hooks/usePinnedWavesServer.test.tsx new file mode 100644 index 0000000000..3b5611ef96 --- /dev/null +++ b/__tests__/hooks/usePinnedWavesServer.test.tsx @@ -0,0 +1,135 @@ +import { renderHook } from "@testing-library/react"; +import React from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { AuthContext } from "@/components/auth/Auth"; +import { + MAX_PINNED_WAVES, + usePinnedWavesServer, +} from "@/hooks/usePinnedWavesServer"; +import { useSeizeConnectContext } from "@/components/auth/SeizeConnectContext"; +import { useSeizeSettingsOptional } from "@/contexts/SeizeSettingsContext"; + +jest.mock("@tanstack/react-query", () => ({ + useMutation: jest.fn(), + useQuery: jest.fn(), + useQueryClient: jest.fn(), +})); + +jest.mock("@/components/auth/SeizeConnectContext", () => ({ + useSeizeConnectContext: jest.fn(), +})); + +jest.mock("@/contexts/SeizeSettingsContext", () => ({ + useSeizeSettingsOptional: jest.fn(), +})); + +const useMutationMock = useMutation as jest.Mock; +const useQueryMock = useQuery as jest.Mock; +const useQueryClientMock = useQueryClient as jest.Mock; +const useSeizeConnectContextMock = useSeizeConnectContext as jest.Mock; +const useSeizeSettingsOptionalMock = useSeizeSettingsOptional as jest.Mock; + +const queryClientMock = { + cancelQueries: jest.fn().mockResolvedValue(undefined), + getQueryData: jest.fn(), + getQueriesData: jest.fn().mockReturnValue([]), + invalidateQueries: jest.fn(), + setQueryData: jest.fn(), +}; + +const wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( + + {children} + +); + +const createWave = (id: string) => + ({ + id, + pinned: true, + }) as any; + +let pinMutateAsync: jest.Mock; +let unpinMutateAsync: jest.Mock; + +beforeEach(() => { + jest.clearAllMocks(); + pinMutateAsync = jest.fn().mockResolvedValue(undefined); + unpinMutateAsync = jest.fn().mockResolvedValue(undefined); + + let mutationCallCount = 0; + useMutationMock.mockImplementation(() => { + mutationCallCount += 1; + + return mutationCallCount === 1 + ? { mutateAsync: pinMutateAsync, error: null } + : { mutateAsync: unpinMutateAsync, error: null }; + }); + + useQueryClientMock.mockReturnValue(queryClientMock); + useQueryMock.mockReturnValue({ + data: [], + isLoading: false, + isError: false, + error: null, + refetch: jest.fn(), + }); + useSeizeConnectContextMock.mockReturnValue({ address: "0xabc" }); + useSeizeSettingsOptionalMock.mockReturnValue({ + isAnnouncementsWave: (waveId: string | null | undefined) => + waveId === "announcement-wave", + }); +}); + +test("keeps raw pinned ids but ignores a legacy announcement pin for budget checks", async () => { + const pinnedWaves = [ + createWave("announcement-wave"), + ...Array.from({ length: MAX_PINNED_WAVES - 1 }, (_, index) => + createWave(`wave-${index}`) + ), + ]; + useQueryMock.mockReturnValue({ + data: pinnedWaves, + isLoading: false, + isError: false, + error: null, + refetch: jest.fn(), + }); + + const { result } = renderHook(() => usePinnedWavesServer(), { wrapper }); + + expect(result.current.pinnedIds).toContain("announcement-wave"); + expect(result.current.canPinWave("new-wave")).toBe(true); + + await result.current.pinWave("new-wave"); + + expect(pinMutateAsync).toHaveBeenCalledWith("new-wave"); +}); + +test("still enforces the cap once non-announcement pins reach the limit", async () => { + const pinnedWaves = [ + createWave("announcement-wave"), + ...Array.from({ length: MAX_PINNED_WAVES }, (_, index) => + createWave(`wave-${index}`) + ), + ]; + useQueryMock.mockReturnValue({ + data: pinnedWaves, + isLoading: false, + isError: false, + error: null, + refetch: jest.fn(), + }); + + const { result } = renderHook(() => usePinnedWavesServer(), { wrapper }); + + expect(result.current.canPinWave("new-wave")).toBe(false); + await expect(result.current.pinWave("new-wave")).rejects.toThrow( + `Maximum ${MAX_PINNED_WAVES} pinned waves allowed` + ); + expect(pinMutateAsync).not.toHaveBeenCalled(); +}); diff --git a/__tests__/hooks/useWavesList.test.tsx b/__tests__/hooks/useWavesList.test.tsx index 54127bef6e..92e9d9ff6a 100644 --- a/__tests__/hooks/useWavesList.test.tsx +++ b/__tests__/hooks/useWavesList.test.tsx @@ -3,6 +3,7 @@ import React from "react"; import useWavesList from "@/hooks/useWavesList"; import { AuthContext } from "@/components/auth/Auth"; import { ApiWaveType } from "@/generated/models/ApiWaveType"; +import { useWaveById } from "@/hooks/useWaveById"; jest.mock("@/hooks/useWavesOverview", () => ({ useWavesOverview: jest.fn(), @@ -12,19 +13,25 @@ jest.mock("@/hooks/usePinnedWavesServer", () => ({ usePinnedWavesServer: jest.fn(), })); -jest.mock("@/hooks/useWaveData", () => ({ - useWaveData: jest.fn(), +jest.mock("@/hooks/useWaveById", () => ({ + useWaveById: jest.fn(), })); jest.mock("@/hooks/useShowFollowingWaves", () => ({ useShowFollowingWaves: jest.fn(() => [false]), })); +jest.mock("@/contexts/SeizeSettingsContext", () => ({ + useSeizeSettings: jest.fn(), +})); + const useWavesOverviewMock = require("@/hooks/useWavesOverview") .useWavesOverview as jest.Mock; const usePinnedWavesServerMock = require("@/hooks/usePinnedWavesServer") .usePinnedWavesServer as jest.Mock; -const useWaveDataMock = require("@/hooks/useWaveData").useWaveData as jest.Mock; +const useSeizeSettingsMock = require("@/contexts/SeizeSettingsContext") + .useSeizeSettings as jest.Mock; +const useWaveByIdMock = useWaveById as jest.Mock; const wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( { jest.clearAllMocks(); + announcementRefetchMock = jest.fn(); useWavesOverviewMock.mockReturnValue({ waves: [dmWave, mainWave], isFetching: false, @@ -76,11 +91,19 @@ beforeEach(() => { isError: false, refetch: jest.fn(), }); - useWaveDataMock.mockReturnValue({ - data: pinnedExtra, + useSeizeSettingsMock.mockReturnValue({ + seizeSettings: { + announcements_wave_id: null, + }, + isAnnouncementsWave: () => false, + }); + useWaveByIdMock.mockReturnValue({ + wave: null, isLoading: false, isError: false, - refetch: jest.fn(), + error: null, + refetch: announcementRefetchMock, + isFetching: false, }); }); @@ -92,6 +115,187 @@ test("combines main and pinned waves, filtering DMs and flagging pinned", () => expect(result.current.pinnedWaves.map((w: any) => w.id)).toEqual(["3"]); }); +test("injects the announcement wave once and excludes it from pinned metadata", () => { + const fallbackAnnouncementRefetch = jest.fn(); + + useSeizeSettingsMock.mockReturnValue({ + seizeSettings: { + announcements_wave_id: "4", + }, + isAnnouncementsWave: (waveId: string | null | undefined) => waveId === "4", + }); + useWaveByIdMock.mockReturnValue({ + wave: announcementWave, + isLoading: false, + isError: false, + error: null, + refetch: fallbackAnnouncementRefetch, + isFetching: false, + }); + + const { result } = renderHook(() => useWavesList(), { wrapper }); + + expect(result.current.waves.map((wave: any) => wave.id)).toEqual([ + "4", + "3", + "2", + ]); + expect( + result.current.waves.find((wave: any) => wave.id === "4") + ).toMatchObject({ + id: "4", + isPinned: false, + }); + expect(result.current.announcementWave).toMatchObject({ id: "4" }); + expect(result.current.trackedAnnouncementWave).toBeNull(); + expect(result.current.announcementQueryLoading).toBe(false); + expect(result.current.announcementQueryError).toBeNull(); + expect(result.current.announcementRefetch).toBe(fallbackAnnouncementRefetch); + expect( + Object.prototype.hasOwnProperty.call( + result.current.waves.find((wave: any) => wave.id === "4"), + "isAnnouncement" + ) + ).toBe(false); + expect(result.current.pinnedWaves.map((wave: any) => wave.id)).toEqual(["3"]); + expect(useWaveByIdMock).toHaveBeenCalledWith("4", { enabled: true }); +}); + +test("preserves pin state for a legacy pinned announcement wave", () => { + usePinnedWavesServerMock.mockReturnValue({ + pinnedIds: ["2", "3", "4"], + pinnedWaves: [pinnedExtra, announcementWave], + pinWave: jest.fn(), + unpinWave: jest.fn(), + isLoading: false, + isError: false, + refetch: jest.fn(), + }); + useSeizeSettingsMock.mockReturnValue({ + seizeSettings: { + announcements_wave_id: "4", + }, + isAnnouncementsWave: (waveId: string | null | undefined) => waveId === "4", + }); + + const { result } = renderHook(() => useWavesList(), { wrapper }); + + expect( + result.current.waves.find((wave: any) => wave.id === "4") + ).toMatchObject({ + id: "4", + isPinned: true, + }); + expect(result.current.trackedAnnouncementWave).toMatchObject({ id: "4" }); + expect(result.current.announcementWave).toMatchObject({ id: "4" }); + expect(result.current.announcementQueryLoading).toBe(false); + expect(result.current.announcementQueryError).toBeNull(); + expect(result.current.announcementRefetch).toBe(announcementRefetchMock); + expect(result.current.pinnedWaves.map((wave: any) => wave.id)).toEqual(["3"]); + expect(useWaveByIdMock).toHaveBeenCalledWith("4", { enabled: false }); +}); + +test("reuses an overview announcement wave without enabling the fallback fetch", () => { + const staleAnnouncementQueryError = new Error( + "Stale cached announcement query error" + ); + + useWavesOverviewMock.mockReturnValue({ + waves: [announcementWave, mainWave], + isFetching: false, + isFetchingNextPage: false, + hasNextPage: false, + fetchNextPage: jest.fn(), + status: "success", + refetch: jest.fn(), + }); + useSeizeSettingsMock.mockReturnValue({ + seizeSettings: { + announcements_wave_id: " 4 ", + }, + isAnnouncementsWave: (waveId: string | null | undefined) => waveId === "4", + }); + useWaveByIdMock.mockReturnValue({ + wave: null, + isLoading: true, + isError: true, + error: staleAnnouncementQueryError, + refetch: announcementRefetchMock, + isFetching: false, + }); + + const { result } = renderHook(() => useWavesList(), { wrapper }); + + expect( + result.current.waves.find((wave: any) => wave.id === "4") + ).toMatchObject({ + id: "4", + }); + expect(result.current.trackedAnnouncementWave).toMatchObject({ id: "4" }); + expect(result.current.announcementWave).toMatchObject({ id: "4" }); + expect(result.current.announcementQueryLoading).toBe(false); + expect(result.current.announcementQueryError).toBeNull(); + expect(result.current.announcementRefetch).toBe(announcementRefetchMock); + expect(useWaveByIdMock).toHaveBeenCalledWith("4", { enabled: false }); +}); + +test("exposes fallback announcement query loading state when unresolved", () => { + const loadingAnnouncementRefetch = jest.fn(); + + useSeizeSettingsMock.mockReturnValue({ + seizeSettings: { + announcements_wave_id: "4", + }, + isAnnouncementsWave: (waveId: string | null | undefined) => waveId === "4", + }); + useWaveByIdMock.mockReturnValue({ + wave: null, + isLoading: true, + isError: false, + error: null, + refetch: loadingAnnouncementRefetch, + isFetching: true, + }); + + const { result } = renderHook(() => useWavesList(), { wrapper }); + + expect(result.current.announcementWave).toBeNull(); + expect(result.current.trackedAnnouncementWave).toBeNull(); + expect(result.current.announcementQueryLoading).toBe(true); + expect(result.current.announcementQueryError).toBeNull(); + expect(result.current.announcementRefetch).toBe(loadingAnnouncementRefetch); + expect(useWaveByIdMock).toHaveBeenCalledWith("4", { enabled: true }); +}); + +test("exposes fallback announcement query error when unresolved", () => { + const failedAnnouncementRefetch = jest.fn(); + const announcementQueryError = new Error("Failed to load announcement wave"); + + useSeizeSettingsMock.mockReturnValue({ + seizeSettings: { + announcements_wave_id: "4", + }, + isAnnouncementsWave: (waveId: string | null | undefined) => waveId === "4", + }); + useWaveByIdMock.mockReturnValue({ + wave: null, + isLoading: false, + isError: true, + error: announcementQueryError, + refetch: failedAnnouncementRefetch, + isFetching: false, + }); + + const { result } = renderHook(() => useWavesList(), { wrapper }); + + expect(result.current.announcementWave).toBeNull(); + expect(result.current.trackedAnnouncementWave).toBeNull(); + expect(result.current.announcementQueryLoading).toBe(false); + expect(result.current.announcementQueryError).toBe(announcementQueryError); + expect(result.current.announcementRefetch).toBe(failedAnnouncementRefetch); + expect(useWaveByIdMock).toHaveBeenCalledWith("4", { enabled: true }); +}); + test("indicates loading when pinned wave is still loading", () => { usePinnedWavesServerMock.mockReturnValue({ pinnedIds: ["2", "3"], diff --git a/__tests__/utils/mockFactories.ts b/__tests__/utils/mockFactories.ts index d8b3800379..52ed7ba5b9 100644 --- a/__tests__/utils/mockFactories.ts +++ b/__tests__/utils/mockFactories.ts @@ -1,4 +1,4 @@ -import type { MinimalWave } from "@/contexts/wave/hooks/useEnhancedWavesList"; +import type { MinimalWave } from "@/contexts/wave/hooks/useEnhancedWavesListCore"; import { ApiWaveType } from "@/generated/models/ApiWaveType"; /** @@ -6,7 +6,9 @@ import { ApiWaveType } from "@/generated/models/ApiWaveType"; * @param overrides - Partial properties to override defaults * @returns Complete MinimalWave object suitable for testing */ -export function createMockMinimalWave(overrides: Partial = {}): MinimalWave { +export function createMockMinimalWave( + overrides: Partial = {} +): MinimalWave { return { id: "mock-wave-id", name: "Mock Wave", diff --git a/components/brain/left-sidebar/waves/BrainLeftSidebarWavePin.tsx b/components/brain/left-sidebar/waves/BrainLeftSidebarWavePin.tsx index 2d1ae03966..72c54de047 100644 --- a/components/brain/left-sidebar/waves/BrainLeftSidebarWavePin.tsx +++ b/components/brain/left-sidebar/waves/BrainLeftSidebarWavePin.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useEffect, useState, useCallback } from "react"; +import React, { useEffect, useState, useMemo } from "react"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faThumbtack } from "@fortawesome/free-solid-svg-icons"; import { useMyStream } from "@/contexts/wave/MyStreamContext"; @@ -21,22 +21,18 @@ const BrainLeftSidebarWavePin: React.FC = ({ isPinned, }) => { const { waves } = useMyStream(); - const { pinnedIds, isOperationInProgress } = usePinnedWavesServer(); + const { pinnedIds, isOperationInProgress, canPinWave } = + usePinnedWavesServer(); const { setToast } = useAuth(); const [isTouchDevice, setIsTouchDevice] = useState(false); const [showMaxLimitTooltip, setShowMaxLimitTooltip] = useState(false); // Check if this specific wave operation is in progress const isCurrentlyProcessing = isOperationInProgress(waveId); - - // Check if we can pin this wave using server data - const canPinWave = useCallback(() => { - // If this wave is already pinned, we can always unpin it - if (isPinned) return true; - - // Check if we have room for another pinned wave using the hook's data - return pinnedIds.length < MAX_PINNED_WAVES; - }, [isPinned, pinnedIds.length]); + const canPinCurrentWave = useMemo( + () => canPinWave(waveId), + [canPinWave, waveId] + ); // // Reset tooltip state when pinned state changes useEffect(() => { @@ -45,10 +41,10 @@ const BrainLeftSidebarWavePin: React.FC = ({ // Also reset tooltip state when pinnedIds array changes useEffect(() => { - if (canPinWave()) { + if (canPinCurrentWave) { setShowMaxLimitTooltip(false); } - }, [pinnedIds, canPinWave]); + }, [pinnedIds, canPinCurrentWave]); // Auto-hide tooltip after 3 seconds with proper cleanup useEffect(() => { @@ -88,16 +84,20 @@ const BrainLeftSidebarWavePin: React.FC = ({ try { if (isPinned) { - await waves.removePinnedWave(waveId); + waves.removePinnedWave(waveId); setShowMaxLimitTooltip(false); - } else if (!canPinWave()) { - setShowMaxLimitTooltip(true); - setToast({ - type: "error", - message: `Maximum ${MAX_PINNED_WAVES} pinned waves allowed`, - }); } else { - await waves.addPinnedWave(waveId); + const canPin = canPinWave(waveId); + + if (canPin) { + waves.addPinnedWave(waveId); + } else { + setShowMaxLimitTooltip(true); + setToast({ + type: "error", + message: `Maximum ${MAX_PINNED_WAVES} pinned waves allowed`, + }); + } } } catch (error) { console.error("Error updating wave pin status:", error); @@ -123,7 +123,7 @@ const BrainLeftSidebarWavePin: React.FC = ({ // Ensure tooltip is updated immediately by always checking the current state const getTooltipContent = () => { if (isPinned) return "Unpin"; - if (canPinWave()) return "Pin"; + if (canPinCurrentWave) return "Pin"; return `Max ${MAX_PINNED_WAVES} pinned waves. Unpin another wave first.`; }; const tooltipContent = getTooltipContent(); diff --git a/components/brain/left-sidebar/waves/UnifiedWavesListWaves.tsx b/components/brain/left-sidebar/waves/UnifiedWavesListWaves.tsx index e74e1f605a..60a2edde55 100644 --- a/components/brain/left-sidebar/waves/UnifiedWavesListWaves.tsx +++ b/components/brain/left-sidebar/waves/UnifiedWavesListWaves.tsx @@ -7,6 +7,7 @@ import JoinedToggle from "./JoinedToggle"; import type { VirtualItem } from "@/hooks/useVirtualizedWaves"; import { useVirtualizedWaves } from "@/hooks/useVirtualizedWaves"; import type { MinimalWave } from "@/contexts/wave/hooks/useEnhancedWavesListCore"; +import { useSeizeSettingsOptional } from "@/contexts/SeizeSettingsContext"; // VirtualItem interface is now imported from useVirtualizedWaves @@ -110,18 +111,29 @@ const UnifiedWavesListWaves = forwardRef< ref ) => { const listContainerRef = useRef(null); + const seizeSettings = useSeizeSettingsOptional(); - // Split waves into pinned and regular waves (no separate active section) - const { pinnedWaves, regularWaves } = useMemo(() => { - const pinned = waves.filter((wave) => wave.isPinned); - const regular = waves.filter((wave) => !wave.isPinned); + const { announcementWaves, pinnedWaves, regularWaves } = useMemo(() => { + const announcements: MinimalWave[] = []; + const pinned: MinimalWave[] = []; + const regular: MinimalWave[] = []; + + for (const wave of waves) { + if (seizeSettings?.isAnnouncementsWave(wave.id)) { + announcements.push(wave); + } else if (wave.isPinned) { + pinned.push(wave); + } else { + regular.push(wave); + } + } - // No special sorting for active waves - keep them in their original position return { + announcementWaves: announcements, pinnedWaves: pinned, regularWaves: regular, }; - }, [waves]); + }, [waves, seizeSettings]); const virtual = useVirtualizedWaves( regularWaves, @@ -147,6 +159,44 @@ const UnifiedWavesListWaves = forwardRef< /> )} + {announcementWaves.length > 0 && ( +
+ {announcementWaves + .filter((wave): wave is MinimalWave => { + if (!isValidWave(wave)) { + console.warn("Invalid announcement wave object", wave); + if (!validateWaveDetailed(wave)) { + console.warn( + "Announcement wave failed detailed validation:", + wave + ); + } + return false; + } + return true; + }) + .map((wave) => ( +
+ +
+ ))} +
+ )} + + {!hideHeaders && + announcementWaves.length > 0 && + (pinnedWaves.length > 0 || regularWaves.length > 0) && ( +
+ )} + {/* Conditionally show pinned section */} {!hideHeaders && pinnedWaves.length > 0 && (
diff --git a/components/brain/left-sidebar/web/WebUnifiedWavesListWaves.tsx b/components/brain/left-sidebar/web/WebUnifiedWavesListWaves.tsx index 6b197db502..f934b33138 100644 --- a/components/brain/left-sidebar/web/WebUnifiedWavesListWaves.tsx +++ b/components/brain/left-sidebar/web/WebUnifiedWavesListWaves.tsx @@ -14,6 +14,7 @@ import SectionHeader from "../waves/SectionHeader"; import WavesFilterToggle from "../waves/WavesFilterToggle"; import WebBrainLeftSidebarWave from "./WebBrainLeftSidebarWave"; import type { MinimalWave } from "@/contexts/wave/hooks/useEnhancedWavesListCore"; +import { useSeizeSettingsOptional } from "@/contexts/SeizeSettingsContext"; function isValidWave(wave: unknown): wave is MinimalWave { if (wave === null || wave === undefined || typeof wave !== "object") { @@ -71,6 +72,7 @@ const WebUnifiedWavesListWaves = forwardRef< const { connectedProfile } = useAuth(); const { openWave, isApp } = useCreateModalState(); const isTouchDevice = useIsTouchDevice(); + const seizeSettings = useSeizeSettingsOptional(); useImperativeHandle(ref, () => ({ sentinelRef, @@ -78,20 +80,27 @@ const WebUnifiedWavesListWaves = forwardRef< const showCreateWaveButton = !isApp && !!connectedProfile; - const { pinnedWaves, regularWaves } = useMemo(() => { + const { announcementWaves, pinnedWaves, regularWaves } = useMemo(() => { + const announcements: MinimalWave[] = []; const pinned: MinimalWave[] = []; const regular: MinimalWave[] = []; for (const wave of waves) { - if (wave.isPinned) { + if (seizeSettings?.isAnnouncementsWave(wave.id)) { + announcements.push(wave); + } else if (wave.isPinned) { pinned.push(wave); } else { regular.push(wave); } } - return { pinnedWaves: pinned, regularWaves: regular }; - }, [waves]); + return { + announcementWaves: announcements, + pinnedWaves: pinned, + regularWaves: regular, + }; + }, [waves, seizeSettings]); const rowHeight = isCollapsed ? WAVE_ROW_HEIGHT_COLLAPSED @@ -163,6 +172,39 @@ const WebUnifiedWavesListWaves = forwardRef< )}
+ {announcementWaves.length > 0 && ( +
+ {announcementWaves + .filter((wave): wave is MinimalWave => { + if (!isValidWave(wave)) { + console.warn("Invalid announcement wave object", wave); + return false; + } + return true; + }) + .map((wave) => ( +
+ +
+ ))} +
+ )} + {announcementWaves.length > 0 && + !hideHeaders && + (pinnedWaves.length > 0 || regularWaves.length > 0) && ( +
+ )} {!hideHeaders && pinnedWaves.length > 0 && (
diff --git a/components/waves/header/WaveHeaderPinButton.tsx b/components/waves/header/WaveHeaderPinButton.tsx index 4447b742ce..5bb3c894ac 100644 --- a/components/waves/header/WaveHeaderPinButton.tsx +++ b/components/waves/header/WaveHeaderPinButton.tsx @@ -4,6 +4,7 @@ import React, { useEffect, useState, useCallback, useMemo } from "react"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faThumbtack } from "@fortawesome/free-solid-svg-icons"; import { useMyStream } from "@/contexts/wave/MyStreamContext"; +import { useSeizeSettings } from "@/contexts/SeizeSettingsContext"; import { Tooltip } from "react-tooltip"; import { useAuth } from "@/components/auth/Auth"; import { @@ -25,12 +26,19 @@ const WaveHeaderPinButton: React.FC = ({ waveId, }) => { const { waves } = useMyStream(); - const { pinnedIds, isOperationInProgress } = usePinnedWavesServer(); + const { isAnnouncementsWave, isLoaded: isSettingsLoaded } = + useSeizeSettings(); + const { pinnedIds, isOperationInProgress, canPinWave } = + usePinnedWavesServer(); const { setToast, connectedProfile, activeProfileProxy } = useAuth(); const [showMaxLimitTooltip, setShowMaxLimitTooltip] = useState(false); const isCurrentlyProcessing = isOperationInProgress(waveId); const isPinned = pinnedIds.includes(waveId); + const canPinCurrentWave = useMemo( + () => canPinWave(waveId), + [canPinWave, waveId] + ); // Helper function to hide tooltip const hideTooltip = useCallback(() => setShowMaxLimitTooltip(false), []); @@ -43,21 +51,12 @@ const WaveHeaderPinButton: React.FC = ({ [setToast] ); - // Check if we can pin this wave using server data - const canPinWave = useCallback(() => { - // If this wave is already pinned, we can always unpin it - if (isPinned) return true; - - // Check if we have room for another pinned wave using the hook's data - return pinnedIds.length < MAX_PINNED_WAVES; - }, [isPinned, pinnedIds.length]); - // Memoize tooltip content to prevent unnecessary re-calculations const tooltipContent = useMemo(() => { if (isPinned) return PIN_ACTIONS.UNPIN; - if (canPinWave()) return PIN_ACTIONS.PIN; + if (canPinCurrentWave) return PIN_ACTIONS.PIN; return `Max ${MAX_PINNED_WAVES} pinned waves. Unpin another wave first.`; - }, [isPinned, canPinWave]); + }, [isPinned, canPinCurrentWave]); const buttonStyles = useMemo(() => { if (isPinned) { @@ -77,10 +76,10 @@ const WaveHeaderPinButton: React.FC = ({ // Also reset tooltip state when pinnedIds array changes useEffect(() => { - if (canPinWave()) { + if (canPinCurrentWave) { hideTooltip(); } - }, [pinnedIds, canPinWave, hideTooltip]); + }, [pinnedIds, canPinCurrentWave, hideTooltip]); // Auto-hide tooltip after 3 seconds with proper cleanup useEffect(() => { @@ -94,6 +93,14 @@ const WaveHeaderPinButton: React.FC = ({ return null; } + if (!isPinned && !isSettingsLoaded) { + return null; + } + + if (isAnnouncementsWave(waveId) && !isPinned) { + return null; + } + const handleClick = async (e: React.MouseEvent) => { e.stopPropagation(); e.preventDefault(); @@ -109,7 +116,7 @@ const WaveHeaderPinButton: React.FC = ({ return; } - if (!canPinWave()) { + if (!canPinWave(waveId)) { setShowMaxLimitTooltip(true); showErrorToast(`Maximum ${MAX_PINNED_WAVES} pinned waves allowed`); return; diff --git a/contexts/SeizeSettingsContext.tsx b/contexts/SeizeSettingsContext.tsx index 9c2573df97..7dde827216 100644 --- a/contexts/SeizeSettingsContext.tsx +++ b/contexts/SeizeSettingsContext.tsx @@ -13,6 +13,7 @@ import { publicEnv } from "@/config/env"; import { ApiDrop } from "@/generated/models/ApiDrop"; import { ApiDropType } from "@/generated/models/ApiDropType"; import type { ApiSeizeSettings } from "@/generated/models/ApiSeizeSettings"; +import { normalizeOptionalWaveId } from "@/helpers/waves/wave.helpers"; import { fetchUrl } from "@/services/6529api"; import { SeizeSettingsMode } from "@/types/enums"; import type { ReactNode } from "react"; @@ -25,6 +26,7 @@ type SeizeSettingsContextType = { seizeSettings: TempApiSeizeSettings; isMemesWave: (waveId: string | undefined | null) => boolean; isCurationWave: (waveId: string | undefined | null) => boolean; + isAnnouncementsWave: (waveId: string | undefined | null) => boolean; isMemesSubmission: (drop: ApiDrop | undefined | null) => boolean; // True once at least one fetch succeeds; stays true during background refreshes // unless callers opt into reset=true before reloading. @@ -53,6 +55,7 @@ export const SeizeSettingsProvider = ({ curation_wave_id: null, distribution_admin_wallets: [], claims_admin_wallets: [], + announcements_wave_id: null, }); const [isLoaded, setIsLoaded] = useState(false); const [loadError, setLoadError] = useState(null); @@ -120,7 +123,12 @@ export const SeizeSettingsProvider = ({ }; }, [loadSeizeSettings, mode]); - const { memes_wave_id, curation_wave_id } = seizeSettings; + const { memes_wave_id, curation_wave_id, announcements_wave_id } = + seizeSettings; + const normalizedAnnouncementsWaveId = useMemo( + () => normalizeOptionalWaveId(announcements_wave_id), + [announcements_wave_id] + ); const isMemesWave = useCallback( (waveId: string | undefined | null): boolean => { @@ -138,6 +146,17 @@ export const SeizeSettingsProvider = ({ [curation_wave_id] ); + const isAnnouncementsWave = useCallback( + (waveId: string | undefined | null): boolean => { + const normalizedWaveId = normalizeOptionalWaveId(waveId); + if (!normalizedWaveId || !normalizedAnnouncementsWaveId) { + return false; + } + return normalizedAnnouncementsWaveId === normalizedWaveId; + }, + [normalizedAnnouncementsWaveId] + ); + const isMemesSubmission = useCallback( (drop: ApiDrop | undefined | null): boolean => { if (!drop) return false; @@ -155,6 +174,7 @@ export const SeizeSettingsProvider = ({ seizeSettings, isMemesWave, isCurationWave, + isAnnouncementsWave, isMemesSubmission, isLoaded, loadError, @@ -164,6 +184,7 @@ export const SeizeSettingsProvider = ({ seizeSettings, isMemesWave, isCurationWave, + isAnnouncementsWave, isMemesSubmission, isLoaded, loadError, diff --git a/contexts/TitleContext.tsx b/contexts/TitleContext.tsx index 3875eaa43e..c8fe14c7b6 100644 --- a/contexts/TitleContext.tsx +++ b/contexts/TitleContext.tsx @@ -93,9 +93,9 @@ export const TitleProvider: React.FC<{ children: React.ReactNode }> = ({ pathname?.startsWith("/messages") || (pathname === "/" && searchParams?.get("view") === "waves"); const waveParam = isWaveRoute - ? myStream?.activeWave.id ?? + ? (myStream?.activeWave.id ?? getActiveWaveIdFromUrl({ pathname, searchParams }) ?? - null + null) : null; useEffect(() => { diff --git a/generated/models/ApiSeizeSettings.ts b/generated/models/ApiSeizeSettings.ts index 7173dbc883..4c31e2e738 100644 --- a/generated/models/ApiSeizeSettings.ts +++ b/generated/models/ApiSeizeSettings.ts @@ -20,6 +20,7 @@ export class ApiSeizeSettings { 'curation_wave_id': string | null; 'distribution_admin_wallets': Array; 'claims_admin_wallets': Array; + 'announcements_wave_id': string | null; static readonly discriminator: string | undefined = undefined; @@ -61,6 +62,12 @@ export class ApiSeizeSettings { "baseName": "claims_admin_wallets", "type": "Array", "format": "" + }, + { + "name": "announcements_wave_id", + "baseName": "announcements_wave_id", + "type": "string", + "format": "" } ]; static getAttributeTypeMap() { diff --git a/helpers/waves/wave.helpers.ts b/helpers/waves/wave.helpers.ts index 2a149a8c92..c0da5ea9f3 100644 --- a/helpers/waves/wave.helpers.ts +++ b/helpers/waves/wave.helpers.ts @@ -1,17 +1,30 @@ interface WaveDetailsLike { - readonly chat?: { - readonly scope?: { - readonly group?: { - readonly is_direct_message?: boolean | undefined; - } | undefined; - } | undefined; - } | undefined; + readonly chat?: + | { + readonly scope?: + | { + readonly group?: + | { + readonly is_direct_message?: boolean | undefined; + } + | undefined; + } + | undefined; + } + | undefined; } interface MinimalWaveLike { readonly id: string; } +export const normalizeOptionalWaveId = ( + waveId: string | null | undefined +): string | null => { + const normalizedWaveId = waveId?.trim() ?? null; + return normalizedWaveId === "" ? null : normalizedWaveId; +}; + export const isWaveDirectMessage = ( waveId: string, waveDetails?: WaveDetailsLike, @@ -23,4 +36,3 @@ export const isWaveDirectMessage = ( return waveDetails?.chat?.scope?.group?.is_direct_message ?? false; }; - diff --git a/hooks/usePinnedWavesServer.ts b/hooks/usePinnedWavesServer.ts index b7a7c5ba82..b7eead2c7e 100644 --- a/hooks/usePinnedWavesServer.ts +++ b/hooks/usePinnedWavesServer.ts @@ -9,6 +9,7 @@ import { } from "@tanstack/react-query"; import { AuthContext } from "@/components/auth/Auth"; import { useSeizeConnectContext } from "@/components/auth/SeizeConnectContext"; +import { useSeizeSettingsOptional } from "@/contexts/SeizeSettingsContext"; import { pinnedWavesApi } from "@/services/api/pinned-waves-api"; import type { ApiWave } from "@/generated/models/ApiWave"; import { ApiWavesPinFilter } from "@/generated/models/ApiWavesPinFilter"; @@ -37,11 +38,13 @@ interface UsePinnedWavesServerReturn { unpinWave: (waveId: string) => Promise; refetch: () => Promise>; isOperationInProgress: (waveId: string) => boolean; + canPinWave: (waveId: string) => boolean; } export function usePinnedWavesServer(): UsePinnedWavesServerReturn { const { connectedProfile, activeProfileProxy } = useContext(AuthContext); const { address } = useSeizeConnectContext(); + const seizeSettings = useSeizeSettingsOptional(); const queryClient = useQueryClient(); // Track ongoing operations to prevent concurrent pins @@ -93,6 +96,42 @@ export function usePinnedWavesServer(): UsePinnedWavesServerReturn { () => pinnedWaves.map((wave) => wave.id), [pinnedWaves] ); + const countsTowardPinBudget = useCallback( + (waveId: string) => !seizeSettings?.isAnnouncementsWave(waveId), + [seizeSettings] + ); + const pinnedBudgetCount = useMemo( + () => pinnedIds.filter(countsTowardPinBudget).length, + [pinnedIds, countsTowardPinBudget] + ); + const getOngoingPinCount = useCallback( + (waveId: string) => { + let ongoingPinCount = 0; + + ongoingOperations.current.forEach((id) => { + if (id === waveId) { + return; + } + + if (!pinnedIds.includes(id) && countsTowardPinBudget(id)) { + ongoingPinCount++; + } + }); + + return ongoingPinCount; + }, + [pinnedIds, countsTowardPinBudget] + ); + const canPinWave = useCallback( + (waveId: string) => { + if (pinnedIds.includes(waveId)) { + return true; + } + + return pinnedBudgetCount + getOngoingPinCount(waveId) < MAX_PINNED_WAVES; + }, + [pinnedIds, pinnedBudgetCount, getOngoingPinCount] + ); // Shared invalidation logic for both pin and unpin operations const invalidateWavesQueries = useCallback(() => { @@ -241,16 +280,7 @@ export function usePinnedWavesServer(): UsePinnedWavesServerReturn { throw new Error("Operation already in progress for this wave"); } - // Check limit including ongoing pin operations - let ongoingPinCount = 0; - ongoingOperations.current.forEach((id) => { - if (!pinnedIds.includes(id)) { - ongoingPinCount++; - } - }); - const totalPinnedCount = pinnedIds.length + ongoingPinCount; - - if (totalPinnedCount >= MAX_PINNED_WAVES) { + if (!canPinWave(waveId)) { throw new Error(`Maximum ${MAX_PINNED_WAVES} pinned waves allowed`); } @@ -264,7 +294,7 @@ export function usePinnedWavesServer(): UsePinnedWavesServerReturn { ongoingOperations.current.delete(waveId); } }, - [pinnedIds, pinMutation] + [canPinWave, pinMutation] ); const unpinWave = useCallback( @@ -297,5 +327,6 @@ export function usePinnedWavesServer(): UsePinnedWavesServerReturn { refetch, isOperationInProgress: (waveId: string) => ongoingOperations.current.has(waveId), + canPinWave, }; } diff --git a/hooks/useWavesList.ts b/hooks/useWavesList.ts index f78e9ef53d..f9924601ac 100644 --- a/hooks/useWavesList.ts +++ b/hooks/useWavesList.ts @@ -3,9 +3,12 @@ import { useContext, useMemo, useCallback } from "react"; import { AuthContext } from "@/components/auth/Auth"; import { useSeizeConnectContext } from "@/components/auth/SeizeConnectContext"; +import { useSeizeSettings } from "@/contexts/SeizeSettingsContext"; +import { normalizeOptionalWaveId } from "@/helpers/waves/wave.helpers"; import { useWavesOverview } from "./useWavesOverview"; import { WAVE_FOLLOWING_WAVES_PARAMS } from "@/components/react-query-wrapper/utils/query-utils"; import { usePinnedWavesServer } from "./usePinnedWavesServer"; +import { useWaveById } from "./useWaveById"; import type { ApiWave } from "@/generated/models/ApiWave"; import { useShowFollowingWaves } from "./useShowFollowingWaves"; import { ApiWaveType } from "@/generated/models/ApiWaveType"; @@ -22,6 +25,7 @@ interface EnhancedWave extends ApiWave { const useWavesList = () => { const { connectedProfile, activeProfileProxy } = useContext(AuthContext); const { address } = useSeizeConnectContext(); + const { seizeSettings, isAnnouncementsWave } = useSeizeSettings(); const { pinnedIds, pinnedWaves: serverPinnedWaves, @@ -32,6 +36,9 @@ const useWavesList = () => { refetch: refetchPinnedWaves, } = usePinnedWavesServer(); const [following] = useShowFollowingWaves(); + const announcementsWaveId = normalizeOptionalWaveId( + seizeSettings.announcements_wave_id + ); // Track connected identity state - memoize to prevent re-renders const isConnectedIdentity = useMemo(() => { @@ -66,12 +73,47 @@ const useWavesList = () => { viewerIdentityKey, }); + const trackedAnnouncementWave = useMemo( + () => + mainWaves.find((wave) => isAnnouncementsWave(wave.id)) ?? + serverPinnedWaves.find((wave) => isAnnouncementsWave(wave.id)) ?? + null, + [mainWaves, serverPinnedWaves, isAnnouncementsWave] + ); + const shouldFetchAnnouncementWave = Boolean( + announcementsWaveId && !trackedAnnouncementWave + ); + const { + wave: fetchedAnnouncementWave, + isLoading: rawAnnouncementQueryLoading, + error: rawAnnouncementQueryError, + refetch: announcementRefetch, + } = useWaveById(announcementsWaveId, { + enabled: shouldFetchAnnouncementWave, + }); + const announcementQueryLoading = + shouldFetchAnnouncementWave && rawAnnouncementQueryLoading; + const announcementQueryError = shouldFetchAnnouncementWave + ? rawAnnouncementQueryError + : null; + const announcementWave = useMemo(() => { + const resolvedWave = trackedAnnouncementWave ?? fetchedAnnouncementWave; + if (!resolvedWave || waveIsDm(resolvedWave)) { + return null; + } + return resolvedWave; + }, [trackedAnnouncementWave, fetchedAnnouncementWave]); + // Create a map of mainWaves by ID for easy lookup const mainWavesMap = useMemo(() => { const map = new Map(); - mainWaves.forEach((wave) => map.set(wave.id, wave)); + mainWaves.forEach((wave) => { + if (!isAnnouncementsWave(wave.id)) { + map.set(wave.id, wave); + } + }); return map; - }, [mainWaves]); + }, [mainWaves, isAnnouncementsWave]); // Set of wave IDs in the main list for quick checking const mainWaveIds = useMemo(() => { @@ -86,13 +128,23 @@ const useWavesList = () => { mainWavesRefetch(); // Refetch server-side pinned waves refetchPinnedWaves(); - }, [mainWavesRefetch, refetchPinnedWaves]); + if (shouldFetchAnnouncementWave) { + void announcementRefetch(); + } + }, [ + mainWavesRefetch, + refetchPinnedWaves, + announcementRefetch, + shouldFetchAnnouncementWave, + ]); // Use server-provided pinned waves const separatelyFetchedPinnedWaves = useMemo(() => { // Filter out pinned waves that are already in mainWaves to avoid duplicates - return serverPinnedWaves.filter((wave) => !mainWaveIds.has(wave.id)); - }, [serverPinnedWaves, mainWaveIds]); + return serverPinnedWaves.filter( + (wave) => !mainWaveIds.has(wave.id) && !isAnnouncementsWave(wave.id) + ); + }, [serverPinnedWaves, mainWaveIds, isAnnouncementsWave]); // Collect ALL pinned waves (both from mainWaves and server-provided) const allPinnedWaves = useMemo(() => { @@ -100,64 +152,56 @@ const useWavesList = () => { // Add all server-provided pinned waves, filtering out DMs serverPinnedWaves.forEach((wave) => { - if (!waveIsDm(wave)) { + if (!waveIsDm(wave) && !isAnnouncementsWave(wave.id)) { result.push({ ...wave, isPinned: true }); } }); return result; - }, [serverPinnedWaves]); + }, [serverPinnedWaves, isAnnouncementsWave]); // New drops counts are now managed externally // Combine main waves with separately fetched pinned waves using useMemo // Simplified order: All waves sorted by latest_drop_timestamp (most recent first) const combinedWaves = useMemo(() => { - // Create a Map of all waves by ID for easy lookup - const allWavesMap = new Map(); - - // Add main waves to the map - mainWaves.forEach((wave) => { - allWavesMap.set(wave.id, wave); - }); - - // Add separately fetched pinned waves to the map - separatelyFetchedPinnedWaves.forEach((wave) => { - allWavesMap.set(wave.id, wave); - }); - - // Process pinned waves first to mark them accordingly + const allWavesMap = new Map(); const pinnedWavesSet = new Set(pinnedIds); - - // Create array of all waves const allWavesArray: EnhancedWave[] = []; - // Process all waves and add them to the array [...mainWaves, ...separatelyFetchedPinnedWaves].forEach((wave) => { - // Skip duplicates (waves that appear in both collections) - if (!allWavesMap.has(wave.id)) return; - - // Remove from map to avoid processing twice - allWavesMap.delete(wave.id); - - const isPinned = pinnedWavesSet.has(wave.id); - - if (!waveIsDm(wave)) { - allWavesArray.push({ - ...wave, - isPinned, - }); + if (waveIsDm(wave) || isAnnouncementsWave(wave.id)) { + return; } + + allWavesMap.set(wave.id, { + ...wave, + isPinned: pinnedWavesSet.has(wave.id), + }); }); - // Sort all waves by latest_drop_timestamp (most recent first) - allWavesArray.sort( + const sortedNonAnnouncementWaves = [...allWavesMap.values()].sort( (a, b) => b.metrics.latest_drop_timestamp - a.metrics.latest_drop_timestamp ); + if (announcementWave) { + allWavesArray.push({ + ...announcementWave, + isPinned: pinnedWavesSet.has(announcementWave.id), + }); + } + + allWavesArray.push(...sortedNonAnnouncementWaves); + return allWavesArray; - }, [mainWaves, separatelyFetchedPinnedWaves, pinnedIds]); + }, [ + mainWaves, + separatelyFetchedPinnedWaves, + pinnedIds, + announcementWave, + isAnnouncementsWave, + ]); // Derived data should come directly from memoized inputs const allWaves = combinedWaves; @@ -191,6 +235,11 @@ const useWavesList = () => { pinnedWaves: allPinnedWaves, isPinnedWavesLoading, hasPinnedWavesError, + trackedAnnouncementWave, + announcementWave, + announcementQueryLoading, + announcementQueryError, + announcementRefetch, // Pinned waves management functions addPinnedWave: pinWave, @@ -214,6 +263,11 @@ const useWavesList = () => { allPinnedWaves, isPinnedWavesLoading, hasPinnedWavesError, + trackedAnnouncementWave, + announcementWave, + announcementQueryLoading, + announcementQueryError, + announcementRefetch, pinWave, unpinWave, missingPinnedIds, diff --git a/instrumentation.ts b/instrumentation.ts index f3e08f1333..8a7295bb61 100644 --- a/instrumentation.ts +++ b/instrumentation.ts @@ -1,4 +1,4 @@ -import {publicEnv} from "@/config/env"; +import { publicEnv } from "@/config/env"; const sentryEnabled = !!publicEnv.SENTRY_DSN; const serverInstrumentationEnabled = @@ -7,17 +7,21 @@ const serverInstrumentationEnabled = export async function register() { if (!sentryEnabled) return; if (!serverInstrumentationEnabled) return; - if (publicEnv.NEXT_RUNTIME === 'nodejs') { - await import('./sentry.server.config'); + if (publicEnv.NEXT_RUNTIME === "nodejs") { + await import("./sentry.server.config"); } - if (publicEnv.NEXT_RUNTIME === 'edge') { - await import('./sentry.edge.config'); + if (publicEnv.NEXT_RUNTIME === "edge") { + await import("./sentry.edge.config"); } } export const onRequestError = sentryEnabled - ? async (...args: Parameters) => { + ? async ( + ...args: Parameters< + (typeof import("@sentry/nextjs"))["captureRequestError"] + > + ) => { if (!serverInstrumentationEnabled) return; const Sentry = await import("@sentry/nextjs"); return Sentry.captureRequestError(...args); diff --git a/knip.jsonc b/knip.jsonc index ea03e2690b..b1fc22f48d 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -22,6 +22,7 @@ "eslint.config.single.mjs", "eslint.config.tight.mjs", "eslint.config.diff.mjs", + "eslint.config.single.mjs", "jest.config.js", "jest.setup.js", "next-sitemap.config.ts", @@ -50,6 +51,7 @@ "eslint.config.single.mjs": ["exports"], "eslint.config.tight.mjs": ["exports"], "eslint.config.diff.mjs": ["exports"], + "eslint.config.single.mjs": ["exports"], "standalone/standalone-memes-mint/src/app/layout.tsx": ["exports"], "standalone/standalone-memes-mint/src/app/page.tsx": ["exports"], "standalone/standalone-memes-mint/src/next.config.ts": ["exports"], diff --git a/openapi.yaml b/openapi.yaml index 6cb08f3035..2579b2c69e 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -10893,6 +10893,7 @@ components: - curation_wave_id - distribution_admin_wallets - claims_admin_wallets + - announcements_wave_id properties: rememes_submission_tdh_threshold: type: integer @@ -10914,6 +10915,9 @@ components: type: array items: $ref: "#/components/schemas/ApiEthereumAddress" + announcements_wave_id: + type: string + nullable: true ApiSetPinnedDropRequest: type: object required: diff --git a/playwright.config.ts b/playwright.config.ts index 5055644ac2..da4a3d0fa0 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,25 +1,25 @@ -import { defineConfig, devices } from '@playwright/test'; +import { defineConfig, devices } from "@playwright/test"; import * as dotenv from "dotenv"; dotenv.config(); // Loads variables from .env dotenv.config({ path: ".env.test" }); // Overrides or adds variables from .env const config = defineConfig({ - testDir: './', + testDir: "./", testMatch: /.*\.spec\.ts/, fullyParallel: true, forbidOnly: !!process.env["CI"], retries: process.env["CI"] ? 2 : 0, ...(process.env["CI"] && { workers: 1 }), - reporter: 'html', + reporter: "html", use: { - baseURL: 'http://localhost:3001', - trace: 'on-first-retry', + baseURL: "http://localhost:3001", + trace: "on-first-retry", }, projects: [ { - name: 'chromium', - use: { ...devices['Desktop Chrome'] }, + name: "chromium", + use: { ...devices["Desktop Chrome"] }, }, // Add other browsers like Firefox, WebKit if needed // { @@ -32,8 +32,8 @@ const config = defineConfig({ // }, ], webServer: { - command: './bin/6529 run dev', - url: 'http://localhost:3001', + command: "./bin/6529 run dev", + url: "http://localhost:3001", reuseExistingServer: !process.env["CI"], timeout: 120000, },