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,
},