diff --git a/__tests__/components/WaveSmallLeaderboardDrop.test.tsx b/__tests__/components/WaveSmallLeaderboardDrop.test.tsx index 5e3249b21c..95c28f7e88 100644 --- a/__tests__/components/WaveSmallLeaderboardDrop.test.tsx +++ b/__tests__/components/WaveSmallLeaderboardDrop.test.tsx @@ -1,32 +1,41 @@ -import { render, screen } from '@testing-library/react'; -import React from 'react'; +import { render, screen } from "@testing-library/react"; +import React from "react"; -jest.mock('@/components/waves/small-leaderboard/MemesWaveSmallLeaderboardDrop', () => ({ - MemesWaveSmallLeaderboardDrop: () =>
memes
, -})); -jest.mock('@/components/waves/small-leaderboard/DefaultWaveSmallLeaderboardDrop', () => ({ - DefaultWaveSmallLeaderboardDrop: () =>
default
, -})); -jest.mock('@/hooks/useWave', () => ({ useWave: jest.fn() })); +const useWaveLeaderboardRendererSet = jest.fn(); -const { useWave } = require('@/hooks/useWave'); +jest.mock("@/components/waves/leaderboard/leaderboardRendererRegistry", () => ({ + useWaveLeaderboardRendererSet: (...args: any[]) => + useWaveLeaderboardRendererSet(...args), +})); -const { WaveSmallLeaderboardDrop } = require('@/components/waves/small-leaderboard/WaveSmallLeaderboardDrop'); +const { + WaveSmallLeaderboardDrop, +} = require("@/components/waves/small-leaderboard/WaveSmallLeaderboardDrop"); -describe('WaveSmallLeaderboardDrop', () => { +describe("WaveSmallLeaderboardDrop", () => { const drop = {} as any; - const wave = {} as any; + const wave = { id: "w1" } as any; const onDropClick = jest.fn(); - it('renders Memes component when wave is memes', () => { - useWave.mockReturnValue({ isMemesWave: true }); - render(); - expect(screen.getByText('memes')).toBeInTheDocument(); + beforeEach(() => { + useWaveLeaderboardRendererSet.mockReset(); }); - it('renders Default component when wave is not memes', () => { - useWave.mockReturnValue({ isMemesWave: false }); - render(); - expect(screen.getByText('default')).toBeInTheDocument(); + it("renders the resolved small leaderboard renderer", () => { + useWaveLeaderboardRendererSet.mockReturnValue({ + variant: "quorum", + LeaderboardDrop: () => null, + SmallLeaderboardDrop: () =>
quorum
, + }); + + render( + + ); + expect(useWaveLeaderboardRendererSet).toHaveBeenCalledWith("w1"); + expect(screen.getByText("quorum")).toBeInTheDocument(); }); }); diff --git a/__tests__/components/memes/drops/MemesLeaderboardDrop.test.tsx b/__tests__/components/memes/drops/MemesLeaderboardDrop.test.tsx index bb1cc5c18b..0dbbd1260c 100644 --- a/__tests__/components/memes/drops/MemesLeaderboardDrop.test.tsx +++ b/__tests__/components/memes/drops/MemesLeaderboardDrop.test.tsx @@ -100,14 +100,18 @@ jest.mock("@/components/waves/memes/submission/MemesArtResubmitAction", () => ({ ), })); + +const mockMemesArtSubmissionModal = jest.fn((p: any) => + p.isOpen ? ( + + ) : null +); + jest.mock("@/components/waves/memes/MemesArtSubmissionModal", () => ({ __esModule: true, - default: (p: any) => - p.isOpen ? ( - - ) : null, + default: (p: any) => mockMemesArtSubmissionModal(p), })); jest.mock("react-dom", () => ({ ...jest.requireActual("react-dom"), @@ -130,6 +134,7 @@ const drop: any = { }; beforeEach(() => { + mockMemesArtSubmissionModal.mockClear(); useDropInteractionRules.mockReturnValue({ canDelete: true }); useLongPressInteraction.mockReturnValue({ isActive: false, @@ -198,10 +203,14 @@ test("opens mobile resubmit modal after the touch menu leaves", async () => { await userEvent.click(screen.getByTestId("resubmit-action")); expect(setIsActive).toHaveBeenCalledWith(false); + expect(mockMemesArtSubmissionModal).not.toHaveBeenCalled(); expect(screen.queryByTestId("resubmit-modal")).not.toBeInTheDocument(); await userEvent.click(screen.getByTestId("after-leave")); + expect(mockMemesArtSubmissionModal).toHaveBeenCalledWith( + expect.objectContaining({ isOpen: true }) + ); expect(screen.getByTestId("resubmit-modal")).toBeInTheDocument(); }); diff --git a/__tests__/components/waves/drop/SingleWaveDrop.test.tsx b/__tests__/components/waves/drop/SingleWaveDrop.test.tsx index d31c35f0aa..e22d15e37e 100644 --- a/__tests__/components/waves/drop/SingleWaveDrop.test.tsx +++ b/__tests__/components/waves/drop/SingleWaveDrop.test.tsx @@ -1,36 +1,35 @@ -import { render, screen } from '@testing-library/react'; -import React from 'react'; -import { SingleWaveDrop } from '@/components/waves/drop/SingleWaveDrop'; +import { render, screen } from "@testing-library/react"; +import React from "react"; +import { SingleWaveDrop } from "@/components/waves/drop/SingleWaveDrop"; -jest.mock('@/components/waves/drop/DefaultSingleWaveDrop', () => ({ - __esModule: true, - DefaultSingleWaveDrop: () =>
, -})); +const useWaveParticipationRendererSet = jest.fn(); -jest.mock('@/components/waves/drop/MemesSingleWaveDrop', () => ({ - __esModule: true, - MemesSingleWaveDrop: () =>
, -})); +jest.mock( + "@/components/waves/drops/participation/participationRendererRegistry", + () => ({ + useWaveParticipationRendererSet: (...args: any[]) => + useWaveParticipationRendererSet(...args), + }) +); -const useSettings = jest.fn(); - -jest.mock('@/contexts/SeizeSettingsContext', () => ({ - useSeizeSettings: () => ({ isMemesWave: useSettings }), -})); - -describe('SingleWaveDrop', () => { - const drop: any = { wave: { id: 'w1' } }; +describe("SingleWaveDrop", () => { + const drop: any = { wave: { id: "w1" } }; const onClose = jest.fn(); - it('renders memes drop when wave is memes', () => { - useSettings.mockReturnValue(true); - render(); - expect(screen.getByTestId('memes')).toBeInTheDocument(); + beforeEach(() => { + useWaveParticipationRendererSet.mockReset(); }); - it('renders default drop otherwise', () => { - useSettings.mockReturnValue(false); + it("renders the resolved single drop renderer", () => { + useWaveParticipationRendererSet.mockReturnValue({ + variant: "quorum", + ParticipationDrop: () => null, + SingleWaveDrop: () =>
, + }); + render(); - expect(screen.getByTestId('default')).toBeInTheDocument(); + + expect(useWaveParticipationRendererSet).toHaveBeenCalledWith("w1"); + expect(screen.getByTestId("quorum")).toBeInTheDocument(); }); }); diff --git a/__tests__/components/waves/drops/WaveDropPartContentMarkdown.test.tsx b/__tests__/components/waves/drops/WaveDropPartContentMarkdown.test.tsx index d1715e091f..e5608c9cc5 100644 --- a/__tests__/components/waves/drops/WaveDropPartContentMarkdown.test.tsx +++ b/__tests__/components/waves/drops/WaveDropPartContentMarkdown.test.tsx @@ -2,9 +2,14 @@ import React from "react"; import { render, screen } from "@testing-library/react"; import { AuthContext } from "@/components/auth/Auth"; import WaveDropPartContentMarkdown from "@/components/waves/drops/WaveDropPartContentMarkdown"; +import { + buildQuorumProposalMarkdown, + EMPTY_QUORUM_PROPOSAL_FORM_VALUES, +} from "@/components/waves/quorum/quorumProposalMarkdown"; let markdownProps: any; let quoteProps: any; +let compactProps: any; jest.mock( "@/components/drops/view/part/DropPartMarkdownWithPropLogger", @@ -26,6 +31,13 @@ jest.mock( ); } ); +jest.mock( + "@/components/waves/quorum/QuorumProposalCompactContent", + () => (props: any) => { + compactProps = props; + return
{props.proposal.title}
; + } +); const basePart: any = { content: "hello", quoted_drop: null }; const wave: any = { id: "w" }; @@ -33,6 +45,7 @@ const wave: any = { id: "w" }; beforeEach(() => { markdownProps = undefined; quoteProps = undefined; + compactProps = undefined; }); it("renders markdown only", () => { @@ -169,3 +182,306 @@ it("keeps link preview toggle control stable across equivalent drop rerenders", expect(markdownProps.linkPreviewToggleControl).toBe(firstControl); }); + +it("renders the compact quorum proposal view when parsing succeeds", () => { + const proposalPart = { + content: buildQuorumProposalMarkdown({ + ...EMPTY_QUORUM_PROPOSAL_FORM_VALUES, + title: "Slow Mode", + summary: "Keep the feed readable.", + problemStatement: "There are too many drops.", + }), + quoted_drop: null, + } as any; + + render( + + ); + + expect(screen.getByTestId("compact")).toHaveTextContent("Slow Mode"); + expect(compactProps.proposal.summaryMarkdown).toBe("Keep the feed readable."); + expect(markdownProps).toBeUndefined(); +}); + +it("renders compact quorum cards when intermediate sections are omitted", () => { + const proposalPart = { + content: [ + "# Slow Mode", + "", + "## Summary", + "", + "Keep the feed readable.", + "", + "## Problem Statement", + "", + "There are too many drops.", + "", + "## Risks & Trade-offs", + "", + "More structure for submitters.", + ].join("\n"), + quoted_drop: null, + } as any; + + render( + + ); + + expect(screen.getByTestId("compact")).toHaveTextContent("Slow Mode"); + expect( + compactProps.proposal.sections.map((section: any) => section.heading) + ).toEqual(["Problem Statement", "Risks & Trade-offs"]); + expect(markdownProps).toBeUndefined(); +}); + +it("falls back to regular markdown for out-of-order quorum headings", () => { + const proposalPart = { + content: [ + "# Slow Mode", + "", + "## Summary", + "", + "Keep the feed readable.", + "", + "## Risks & Trade-offs", + "", + "More structure for submitters.", + "", + "## Problem Statement", + "", + "There are too many drops.", + ].join("\n"), + quoted_drop: null, + } as any; + + render( + + ); + + expect(screen.getByTestId("md")).toHaveTextContent("## Risks & Trade-offs"); + expect(screen.queryByTestId("compact")).toBeNull(); +}); + +it("falls back to regular markdown when quorum headings go out of order after a valid prefix", () => { + const proposalPart = { + content: [ + "# Slow Mode", + "", + "## Summary", + "", + "Keep the feed readable.", + "", + "## Problem Statement", + "", + "There are too many drops.", + "", + "## Risks & Trade-offs", + "", + "More structure for submitters.", + "", + "## Proposed Solution", + "", + "Add a cooldown setting.", + ].join("\n"), + quoted_drop: null, + } as any; + + render( + + ); + + expect(screen.getByTestId("md")).toHaveTextContent("## Risks & Trade-offs"); + expect(screen.queryByTestId("compact")).toBeNull(); +}); + +it("keeps repeated canonical headings inside the final compact quorum section", () => { + const proposalPart = { + content: [ + "# Slow Mode", + "", + "## Summary", + "", + "Keep the feed readable.", + "", + "## Problem Statement", + "", + "There are too many drops.", + "", + "## Problem Statement", + "", + "This repeated heading is still part of the same section.", + ].join("\n"), + quoted_drop: null, + } as any; + + render( + + ); + + expect(screen.getByTestId("compact")).toHaveTextContent("Slow Mode"); + expect( + compactProps.proposal.sections.map((section: any) => section.heading) + ).toEqual(["Problem Statement"]); + expect(compactProps.proposal.sections[0].markdown).toContain( + "## Problem Statement" + ); + expect(compactProps.proposal.sections[0].markdown).toContain( + "This repeated heading is still part of the same section." + ); + expect(markdownProps).toBeUndefined(); +}); + +it("keeps embedded level-two headings inside the same compact quorum section", () => { + const proposalPart = { + content: buildQuorumProposalMarkdown({ + ...EMPTY_QUORUM_PROPOSAL_FORM_VALUES, + title: "Slow Mode", + summary: "Keep the feed readable.", + problemStatement: "There are too many drops.", + proposedSolution: "Add a cooldown setting.", + coreFeatures: + "- Toggle on/off\n\n## Rollout Notes\n\nStill part of the working spec.", + userFlow: "Users see a countdown after dropping.", + implementationPath: "Ship as an opt-in setting.", + }), + quoted_drop: null, + } as any; + + render( + + ); + + expect(screen.getByTestId("compact")).toHaveTextContent("Slow Mode"); + expect( + compactProps.proposal.sections.map((section: any) => section.heading) + ).toEqual([ + "Problem Statement", + "Proposed Solution", + "Working Spec (Required)", + "Implementation Path", + "Impact & Priority", + "Success Criteria", + "Risks & Trade-offs", + ]); + expect(compactProps.proposal.sections[2].markdown).toContain( + "## Rollout Notes" + ); + expect(compactProps.proposal.sections[2].markdown).toContain( + "Still part of the working spec." + ); + expect(markdownProps).toBeUndefined(); +}); + +it("keeps canonical embedded headings inside the same compact quorum section", () => { + const proposalPart = { + content: buildQuorumProposalMarkdown({ + ...EMPTY_QUORUM_PROPOSAL_FORM_VALUES, + title: "Slow Mode", + summary: "Keep the feed readable.", + problemStatement: "There are too many drops.", + proposedSolution: "Add a cooldown setting.", + implementationPath: + "1. Draft the rollout.\n\n## Risks & Trade-offs\n\nStill part of the implementation notes.", + risksTradeoffs: "Actual trade-off goes here.", + }), + quoted_drop: null, + } as any; + + render( + + ); + + expect(screen.getByTestId("compact")).toHaveTextContent("Slow Mode"); + expect(compactProps.proposal.sections[3].heading).toBe("Implementation Path"); + expect(compactProps.proposal.sections[3].markdown).toContain( + "## Risks & Trade-offs" + ); + expect(compactProps.proposal.sections[3].markdown).toContain( + "Still part of the implementation notes." + ); + expect(compactProps.proposal.sections[6].heading).toBe("Risks & Trade-offs"); + expect(compactProps.proposal.sections[6].markdown).toBe( + "Actual trade-off goes here." + ); + expect(markdownProps).toBeUndefined(); +}); + +it("falls back to regular markdown when compact quorum parsing fails", () => { + render( + + ); + + expect(screen.getByTestId("md")).toHaveTextContent("# Not enough structure"); + expect(screen.queryByTestId("compact")).toBeNull(); +}); diff --git a/__tests__/components/waves/drops/participation/ParticipationDrop.test.tsx b/__tests__/components/waves/drops/participation/ParticipationDrop.test.tsx index b903aca941..674057339a 100644 --- a/__tests__/components/waves/drops/participation/ParticipationDrop.test.tsx +++ b/__tests__/components/waves/drops/participation/ParticipationDrop.test.tsx @@ -1,43 +1,45 @@ -import { render, screen } from '@testing-library/react'; -import React from 'react'; -import ParticipationDrop from '@/components/waves/drops/participation/ParticipationDrop'; -import { DropLocation } from '@/components/waves/drops/Drop'; +import React from "react"; +import { render, screen } from "@testing-library/react"; +import ParticipationDrop from "@/components/waves/drops/participation/ParticipationDrop"; +import { DropLocation } from "@/components/waves/drops/drop.types"; -jest.mock('@/contexts/SeizeSettingsContext', () => ({ - useSeizeSettings: jest.fn() -})); +const useWaveParticipationRendererSet = jest.fn(); -jest.mock('@/components/memes/drops/MemeParticipationDrop', () => (props: any) => ( -
-)); +jest.mock( + "@/components/waves/drops/participation/participationRendererRegistry", + () => ({ + useWaveParticipationRendererSet: (...args: any[]) => + useWaveParticipationRendererSet(...args), + }) +); -jest.mock('@/components/waves/drops/participation/DefaultParticipationDrop', () => (props: any) => ( -
-)); - -const { useSeizeSettings } = require('@/contexts/SeizeSettingsContext'); +describe("ParticipationDrop", () => { + beforeEach(() => { + useWaveParticipationRendererSet.mockReset(); + }); -describe('ParticipationDrop', () => { - const baseProps = { - drop: { wave: { id: '1' } } as any, - showWaveInfo: false, - activeDrop: null, - showReplyAndQuote: true, - location: DropLocation.FEED, - onReply: jest.fn(), - onQuote: jest.fn(), - onQuoteClick: jest.fn() - }; + it("delegates to the resolved renderer", () => { + useWaveParticipationRendererSet.mockReturnValue({ + variant: "quorum", + ParticipationDrop: (props: any) => ( +
{props.drop.id}
+ ), + SingleWaveDrop: () => null, + }); - it('renders meme drop when wave is memes', () => { - useSeizeSettings.mockReturnValue({ isMemesWave: () => true }); - render(); - expect(screen.getByTestId('meme')).toBeInTheDocument(); - }); + render( + + ); - it('renders default drop otherwise', () => { - useSeizeSettings.mockReturnValue({ isMemesWave: () => false }); - render(); - expect(screen.getByTestId('default')).toBeInTheDocument(); + expect(useWaveParticipationRendererSet).toHaveBeenCalledWith("quorum-wave"); + expect(screen.getByTestId("resolved-renderer")).toHaveTextContent("drop-1"); }); }); diff --git a/__tests__/components/waves/drops/participation/participationRendererRegistry.test.tsx b/__tests__/components/waves/drops/participation/participationRendererRegistry.test.tsx new file mode 100644 index 0000000000..5de0aef4c8 --- /dev/null +++ b/__tests__/components/waves/drops/participation/participationRendererRegistry.test.tsx @@ -0,0 +1,70 @@ +import { renderHook } from "@testing-library/react"; +import { useSeizeSettings } from "@/contexts/SeizeSettingsContext"; +import { DefaultSingleWaveDrop } from "@/components/waves/drop/DefaultSingleWaveDrop"; +import DefaultParticipationDrop from "@/components/waves/drops/participation/DefaultParticipationDrop"; +import { useWaveParticipationRendererSet } from "@/components/waves/drops/participation/participationRendererRegistry"; + +jest.mock("@/contexts/SeizeSettingsContext", () => ({ + useSeizeSettings: jest.fn(), +})); + +jest.mock( + "@/components/waves/drops/participation/DefaultParticipationDrop", + () => ({ + __esModule: true, + default: jest.fn(), + }) +); + +jest.mock("@/components/waves/drop/DefaultSingleWaveDrop", () => ({ + __esModule: true, + DefaultSingleWaveDrop: jest.fn(), +})); + +jest.mock("@/components/memes/drops/MemeParticipationDrop", () => ({ + __esModule: true, + default: jest.fn(), +})); + +jest.mock("@/components/waves/drop/MemesSingleWaveDrop", () => ({ + __esModule: true, + MemesSingleWaveDrop: jest.fn(), +})); + +jest.mock("@/components/waves/quorum/QuorumParticipationDrop", () => ({ + __esModule: true, + default: jest.fn(), +})); + +jest.mock("@/components/waves/drop/QuorumSingleWaveDrop", () => ({ + __esModule: true, + QuorumSingleWaveDrop: jest.fn(), +})); + +const mockUseSeizeSettings = useSeizeSettings as jest.Mock; + +describe("useWaveParticipationRendererSet", () => { + beforeEach(() => { + mockUseSeizeSettings.mockReturnValue({ + isMemesWave: () => false, + isCurationWave: () => false, + isQuorumWave: () => false, + }); + }); + + it("returns the explicit curation variant with default renderers", () => { + mockUseSeizeSettings.mockReturnValue({ + isMemesWave: () => false, + isCurationWave: (waveId: string) => waveId === "curation-wave", + isQuorumWave: () => false, + }); + + const { result } = renderHook(() => + useWaveParticipationRendererSet("curation-wave") + ); + + expect(result.current.variant).toBe("curation"); + expect(result.current.ParticipationDrop).toBe(DefaultParticipationDrop); + expect(result.current.SingleWaveDrop).toBe(DefaultSingleWaveDrop); + }); +}); diff --git a/__tests__/components/waves/leaderboard/content/WaveLeaderboardDropContent.test.tsx b/__tests__/components/waves/leaderboard/content/WaveLeaderboardDropContent.test.tsx index e0e08db3c4..c0c537b397 100644 --- a/__tests__/components/waves/leaderboard/content/WaveLeaderboardDropContent.test.tsx +++ b/__tests__/components/waves/leaderboard/content/WaveLeaderboardDropContent.test.tsx @@ -5,11 +5,15 @@ import { useRouter } from "next/navigation"; import { ApiWaveParticipationSubmissionStrategyType } from "@/generated/models/ApiWaveParticipationSubmissionStrategyType"; jest.mock("next/navigation", () => ({ useRouter: jest.fn() })); +const waveDropContentMock = jest.fn((props: any) => ( +
props.onDropContentClick(props.drop)} + /> +)); jest.mock("@/components/waves/drops/WaveDropContent", () => ({ __esModule: true, - default: ({ onDropContentClick, drop }: any) => ( -
onDropContentClick(drop)} /> - ), + default: (props: any) => waveDropContentMock(props), })); jest.mock("@/components/waves/drops/WaveDropMetadata", () => ({ __esModule: true, @@ -31,6 +35,10 @@ jest.mock( const routerMock = useRouter as jest.Mock; describe("WaveLeaderboardDropContent", () => { + beforeEach(() => { + waveDropContentMock.mockClear(); + }); + it("navigates on drop click, renders identity, and filters reserved metadata", () => { const push = jest.fn(); routerMock.mockReturnValue({ push }); @@ -51,4 +59,26 @@ describe("WaveLeaderboardDropContent", () => { expect(screen.getByTestId("identity")).toBeInTheDocument(); expect(screen.getByTestId("meta")).toHaveTextContent("1"); }); + + it("forwards custom content presentation to the shared drop content", () => { + routerMock.mockReturnValue({ push: jest.fn() }); + const drop = { + wave: { id: "w" }, + serial_no: 5, + metadata: [], + } as any; + + render( + + ); + + expect(waveDropContentMock).toHaveBeenCalledWith( + expect.objectContaining({ + contentPresentation: "quorumCompact", + }) + ); + }); }); diff --git a/__tests__/components/waves/leaderboard/drops/QuorumWaveLeaderboardDrop.test.tsx b/__tests__/components/waves/leaderboard/drops/QuorumWaveLeaderboardDrop.test.tsx new file mode 100644 index 0000000000..1ca7660e5c --- /dev/null +++ b/__tests__/components/waves/leaderboard/drops/QuorumWaveLeaderboardDrop.test.tsx @@ -0,0 +1,34 @@ +import { render } from "@testing-library/react"; +import React from "react"; +import { QuorumWaveLeaderboardDrop } from "@/components/waves/leaderboard/drops/QuorumWaveLeaderboardDrop"; + +const defaultWaveLeaderboardDrop = jest.fn(() => null); + +jest.mock( + "@/components/waves/leaderboard/drops/DefaultWaveLeaderboardDrop", + () => ({ + DefaultWaveLeaderboardDrop: (props: any) => + defaultWaveLeaderboardDrop(props), + }) +); + +describe("QuorumWaveLeaderboardDrop", () => { + beforeEach(() => { + defaultWaveLeaderboardDrop.mockClear(); + }); + + it("forwards quorumCompact content presentation", () => { + const drop = { id: "d1" } as any; + const onDropClick = jest.fn(); + + render(); + + expect(defaultWaveLeaderboardDrop).toHaveBeenCalledWith( + expect.objectContaining({ + drop, + onDropClick, + contentPresentation: "quorumCompact", + }) + ); + }); +}); diff --git a/__tests__/components/waves/leaderboard/drops/WaveLeaderboardDrop.test.tsx b/__tests__/components/waves/leaderboard/drops/WaveLeaderboardDrop.test.tsx index 87c4d740f8..3c592226b3 100644 --- a/__tests__/components/waves/leaderboard/drops/WaveLeaderboardDrop.test.tsx +++ b/__tests__/components/waves/leaderboard/drops/WaveLeaderboardDrop.test.tsx @@ -1,58 +1,42 @@ -import React from "react"; import { render, screen } from "@testing-library/react"; +import React from "react"; import { WaveLeaderboardDrop } from "@/components/waves/leaderboard/drops/WaveLeaderboardDrop"; -jest.mock( - "@/components/waves/leaderboard/drops/DefaultWaveLeaderboardDrop", - () => ({ - DefaultWaveLeaderboardDrop: (p: any) => ( -
{p.drop.id}
- ), - }) -); -jest.mock("@/components/memes/drops/MemesLeaderboardDrop", () => ({ - MemesLeaderboardDrop: (p: any) => ( - - ), -})); -jest.mock("@/hooks/useWave", () => ({ useWave: jest.fn() })); +const useWaveLeaderboardRendererSet = jest.fn(); -const useWave = require("@/hooks/useWave").useWave as jest.Mock; +jest.mock("@/components/waves/leaderboard/leaderboardRendererRegistry", () => ({ + useWaveLeaderboardRendererSet: (...args: any[]) => + useWaveLeaderboardRendererSet(...args), +})); describe("WaveLeaderboardDrop", () => { - const wave = { id: "w" } as any; - const drop = { id: "d" } as any; + const drop = { id: "d1" } as any; + const wave = { id: "w1" } as any; + const onDropClick = jest.fn(); - it("renders memes drop when wave is memes", () => { - useWave.mockReturnValue({ isMemesWave: true }); - render( - - ); - expect(screen.getByTestId("memes")).toHaveTextContent("d"); + beforeEach(() => { + useWaveLeaderboardRendererSet.mockReset(); + onDropClick.mockReset(); }); - it("passes source deletion callback to memes drop", () => { - const onSourceDropDeleted = jest.fn(); - useWave.mockReturnValue({ isMemesWave: true }); - render( - - ); - screen.getByTestId("memes").click(); - expect(onSourceDropDeleted).toHaveBeenCalledTimes(1); - }); + it("renders the resolved leaderboard renderer", () => { + let rendererProps: any; + + useWaveLeaderboardRendererSet.mockReturnValue({ + variant: "quorum", + LeaderboardDrop: (props: any) => { + rendererProps = props; + return
; + }, + SmallLeaderboardDrop: () => null, + }); - it("renders default drop otherwise", () => { - useWave.mockReturnValue({ isMemesWave: false }); render( - + ); - expect(screen.getByTestId("default")).toHaveTextContent("d"); + + expect(useWaveLeaderboardRendererSet).toHaveBeenCalledWith("w1"); + expect(rendererProps).toEqual({ drop, wave, onDropClick }); + expect(screen.getByTestId("quorum")).toBeInTheDocument(); }); }); diff --git a/__tests__/components/waves/leaderboard/drops/WaveLeaderboardDrops.test.tsx b/__tests__/components/waves/leaderboard/drops/WaveLeaderboardDrops.test.tsx index 825d04bb70..e3dbe84857 100644 --- a/__tests__/components/waves/leaderboard/drops/WaveLeaderboardDrops.test.tsx +++ b/__tests__/components/waves/leaderboard/drops/WaveLeaderboardDrops.test.tsx @@ -28,7 +28,7 @@ jest.mock("@/hooks/useIntersectionObserver", () => ({ jest.mock("@/components/waves/leaderboard/drops/WaveLeaderboardDrop", () => ({ WaveLeaderboardDrop: (props: any) => ( - ), @@ -49,21 +49,19 @@ jest.mock( "@/components/waves/leaderboard/drops/WaveLeaderboardLoadingBar", () => ({ WaveLeaderboardLoadingBar: () =>
}) ); -jest.mock("next/navigation", () => ({ - useRouter: () => ({ push: jest.fn() }), - usePathname: () => "/p", - useSearchParams: () => ({ toString: () => "", get: () => null }), -})); - const wave = { id: "w1" } as ApiWave; -const renderComp = (hookReturn: any) => { +const renderComp = ( + hookReturn: any, + onDropClick: (drop: { id: string }) => void = jest.fn() +) => { hook.mockReturnValue(hookReturn); return render( @@ -107,17 +105,19 @@ describe("WaveLeaderboardDrops", () => { expect(fetchNextPage).toHaveBeenCalled(); }); - it("refetches when a leaderboard drop reports source deletion", () => { - const refetch = jest.fn(); - renderComp({ - drops: [{ id: "d1" }], - isFetching: false, - isFetchingNextPage: false, - fetchNextPage: jest.fn(), - hasNextPage: false, - refetch, - }); + it("passes drop clicks through to the parent handler", () => { + const onDropClick = jest.fn(); + renderComp( + { + drops: [{ id: "d1" }], + isFetching: false, + isFetchingNextPage: false, + fetchNextPage: jest.fn(), + hasNextPage: false, + }, + onDropClick + ); screen.getByTestId("drop").click(); - expect(refetch).toHaveBeenCalledTimes(1); + expect(onDropClick).toHaveBeenCalledWith({ id: "d1" }); }); }); diff --git a/__tests__/components/waves/memes/MemesArtSubmissionModal.test.tsx b/__tests__/components/waves/memes/MemesArtSubmissionModal.test.tsx index 757e416eae..b26af34b8c 100644 --- a/__tests__/components/waves/memes/MemesArtSubmissionModal.test.tsx +++ b/__tests__/components/waves/memes/MemesArtSubmissionModal.test.tsx @@ -20,6 +20,28 @@ describe("MemesArtSubmissionModal", () => { expect(container.firstChild).toBeNull(); }); + it("does not call onClose on Escape when closed", () => { + const onClose = jest.fn(); + render( + + ); + + fireEvent.keyDown(document, { key: "Escape" }); + + expect(onClose).not.toHaveBeenCalled(); + }); + + it("calls onClose on Escape when open", () => { + const onClose = jest.fn(); + render( + + ); + + fireEvent.keyDown(document, { key: "Escape" }); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + it("calls onClose when backdrop clicked", () => { const onClose = jest.fn(); render( diff --git a/__tests__/components/waves/memes/submission/MemesArtResubmitAction.test.tsx b/__tests__/components/waves/memes/submission/MemesArtResubmitAction.test.tsx index 212c964dd5..d311f8340d 100644 --- a/__tests__/components/waves/memes/submission/MemesArtResubmitAction.test.tsx +++ b/__tests__/components/waves/memes/submission/MemesArtResubmitAction.test.tsx @@ -10,19 +10,23 @@ jest.mock("@/hooks/useWave"); jest.mock("react-tooltip", () => ({ Tooltip: ({ children }: any) => children, })); + +const mockMemesArtSubmissionModal = jest.fn((props: any) => + props.isOpen ? ( +
+ + +
+ ) : null +); + jest.mock("@/components/waves/memes/MemesArtSubmissionModal", () => ({ __esModule: true, - default: (props: any) => - props.isOpen ? ( -
- - -
- ) : null, + default: (props: any) => mockMemesArtSubmissionModal(props), })); const mockUseDropInteractionRules = @@ -78,6 +82,7 @@ describe("MemesArtResubmitAction", () => { await userEvent.click(screen.getByRole("button", { name: /resubmit/i })); expect(onOpenModal).toHaveBeenCalledTimes(1); + expect(mockMemesArtSubmissionModal).not.toHaveBeenCalled(); expect(screen.queryByTestId("resubmit-modal")).not.toBeInTheDocument(); }); @@ -111,9 +116,16 @@ describe("MemesArtResubmitAction", () => { /> ); + expect(mockMemesArtSubmissionModal).not.toHaveBeenCalled(); + await userEvent.click( screen.getByRole("button", { name: /resubmit drop/i }) ); + + expect(mockMemesArtSubmissionModal).toHaveBeenCalledWith( + expect.objectContaining({ isOpen: true }) + ); + await userEvent.click( screen.getByRole("button", { name: /source deleted/i }) ); diff --git a/__tests__/components/waves/quorum/QuorumParticipationDrop.test.tsx b/__tests__/components/waves/quorum/QuorumParticipationDrop.test.tsx new file mode 100644 index 0000000000..ef992786ad --- /dev/null +++ b/__tests__/components/waves/quorum/QuorumParticipationDrop.test.tsx @@ -0,0 +1,69 @@ +import { render, screen } from "@testing-library/react"; +import QuorumParticipationDrop from "@/components/waves/quorum/QuorumParticipationDrop"; + +const useDropInteractionRules = jest.fn(); + +jest.mock("@/hooks/drops/useDropInteractionRules", () => ({ + useDropInteractionRules: (...args: any[]) => useDropInteractionRules(...args), +})); + +const OngoingParticipationDropMock = jest.fn(() => ( +
+)); +jest.mock( + "@/components/waves/drops/participation/OngoingParticipationDrop", + () => (props: any) => { + OngoingParticipationDropMock(props); + return
; + } +); + +const EndedParticipationDropMock = jest.fn(() => ( +
+)); +jest.mock( + "@/components/waves/drops/participation/EndedParticipationDrop", + () => (props: any) => { + EndedParticipationDropMock(props); + return
; + } +); + +const baseProps: any = { + drop: { id: "drop-1" }, + showWaveInfo: false, + activeDrop: null, + showReplyAndQuote: false, + location: "wave", + onReply: jest.fn(), + onQuoteClick: jest.fn(), +}; + +describe("QuorumParticipationDrop", () => { + beforeEach(() => { + OngoingParticipationDropMock.mockClear(); + EndedParticipationDropMock.mockClear(); + }); + + it("renders the ongoing card with compact quorum presentation by default", () => { + useDropInteractionRules.mockReturnValue({ isVotingEnded: false }); + + render(); + + expect(screen.getByTestId("ongoing-drop")).toBeInTheDocument(); + expect( + OngoingParticipationDropMock.mock.calls[0][0]?.contentPresentation + ).toBe("quorumCompact"); + }); + + it("renders the ended card with compact quorum presentation after voting closes", () => { + useDropInteractionRules.mockReturnValue({ isVotingEnded: true }); + + render(); + + expect(screen.getByTestId("ended-drop")).toBeInTheDocument(); + expect( + EndedParticipationDropMock.mock.calls[0][0]?.contentPresentation + ).toBe("quorumCompact"); + }); +}); diff --git a/__tests__/components/waves/quorum/QuorumProposalCompactContent.test.tsx b/__tests__/components/waves/quorum/QuorumProposalCompactContent.test.tsx new file mode 100644 index 0000000000..dd54aec785 --- /dev/null +++ b/__tests__/components/waves/quorum/QuorumProposalCompactContent.test.tsx @@ -0,0 +1,170 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import QuorumProposalCompactContent from "@/components/waves/quorum/QuorumProposalCompactContent"; + +jest.mock( + "@/components/drops/view/part/DropPartMarkdownWithPropLogger", + () => (props: any) => ( +
{props.partContent}
+ ) +); + +const proposal = { + title: "Slow Mode", + summaryMarkdown: "Keep the feed readable.", + sections: [ + { heading: "Problem Statement", markdown: "Too many drops." }, + { heading: "Proposed Solution", markdown: "Add a countdown." }, + ], +} as const; + +function getSectionSummaryElement(heading: string): HTMLElement { + const summary = screen.getByText(heading).closest("summary"); + if (!summary) { + throw new Error(`Expected summary for section heading: ${heading}`); + } + return summary; +} + +function getDetailsToggle(): HTMLElement { + return screen.getByRole("button", { name: /show details/i }); +} + +describe("QuorumProposalCompactContent", () => { + it("shows the title and summary immediately", () => { + render( + + ); + + expect(screen.getByText("Slow Mode")).toBeInTheDocument(); + expect(screen.getByText("Summary")).toBeInTheDocument(); + expect(screen.getByText("Keep the feed readable.")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Show details (2)" }) + ).toBeInTheDocument(); + expect(screen.queryByText("Problem Statement")).toBeNull(); + expect(screen.queryByText("Proposed Solution")).toBeNull(); + expect(screen.queryByText("Too many drops.")).toBeNull(); + }); + + it("reveals section headings without bubbling the click to the parent drop container", () => { + const onParentClick = jest.fn(); + + render( +
+ +
+ ); + + fireEvent.click(getDetailsToggle()); + + expect(onParentClick).not.toHaveBeenCalled(); + expect( + screen.getByRole("button", { name: "Hide details" }) + ).toBeInTheDocument(); + expect(screen.getByText("Problem Statement")).toBeInTheDocument(); + expect(screen.getByText("Proposed Solution")).toBeInTheDocument(); + expect(screen.queryByText("Too many drops.")).toBeNull(); + expect(screen.queryByText("Add a countdown.")).toBeNull(); + }); + + it("allows section accordions to expand after details are revealed", () => { + render( + + ); + + fireEvent.click(getDetailsToggle()); + fireEvent.click(getSectionSummaryElement("Problem Statement")); + + expect(screen.getByText("Too many drops.")).toBeInTheDocument(); + expect(screen.queryByText("Add a countdown.")).toBeNull(); + }); + + it("does not bubble toggle keyboard events to the parent drop container", () => { + const onParentKeyDown = jest.fn(); + + render( +
+ +
+ ); + + fireEvent.keyDown(getDetailsToggle(), { + key: "Enter", + }); + + expect(onParentKeyDown).not.toHaveBeenCalled(); + }); + + it("hides the section list again when details are collapsed", () => { + render( + + ); + + fireEvent.click(getDetailsToggle()); + fireEvent.click(screen.getByRole("button", { name: "Hide details" })); + + expect( + screen.getByRole("button", { name: "Show details (2)" }) + ).toBeInTheDocument(); + expect(screen.queryByText("Problem Statement")).toBeNull(); + expect(screen.queryByText("Proposed Solution")).toBeNull(); + }); + + it("does not bubble clicks from expanded section content to the parent drop container", () => { + const onParentClick = jest.fn(); + + render( +
+ +
+ ); + + fireEvent.click(getDetailsToggle()); + fireEvent.click(getSectionSummaryElement("Problem Statement")); + fireEvent.click(screen.getByText("Too many drops.")); + + expect(onParentClick).not.toHaveBeenCalled(); + }); +}); diff --git a/__tests__/components/waves/quorum/quorumProposalMarkdown.test.ts b/__tests__/components/waves/quorum/quorumProposalMarkdown.test.ts index fa7e6bc6f4..54b03eaa47 100644 --- a/__tests__/components/waves/quorum/quorumProposalMarkdown.test.ts +++ b/__tests__/components/waves/quorum/quorumProposalMarkdown.test.ts @@ -2,6 +2,7 @@ import { buildQuorumProposalMarkdown, EMPTY_QUORUM_PROPOSAL_FORM_VALUES, hasQuorumProposalContent, + parseQuorumProposalMarkdown, type QuorumProposalFormValues, } from "@/components/waves/quorum/quorumProposalMarkdown"; @@ -56,4 +57,367 @@ describe("quorumProposalMarkdown", () => { false ); }); + + it("parses generated quorum markdown into a compact proposal model", () => { + const markdown = buildQuorumProposalMarkdown({ + ...EMPTY_QUORUM_PROPOSAL_FORM_VALUES, + title: "Slow Mode", + summary: "Restrict users to one drop every 24 hours.", + problemStatement: "Waves can become noisy.", + proposedSolution: "Allow creators to enable slow mode.", + coreFeatures: "- Toggle on/off\n- One drop per 24h", + userFlow: "Users see a countdown after dropping.", + edgeCases: "", + scopeBoundaries: "Users cannot disable it.", + implementationPath: "Ship as an opt-in setting.", + whoBenefits: "Busy art-sharing waves.", + whatImproves: "Visibility.", + urgency: "Low", + observableOutcome: "Fewer duplicate drops.", + measurableSignal: "More space between drops.", + risksTradeoffs: "", + }); + + expect(parseQuorumProposalMarkdown(markdown)).toEqual({ + title: "Slow Mode", + summaryMarkdown: "Restrict users to one drop every 24 hours.", + sections: [ + { + heading: "Problem Statement", + markdown: "Waves can become noisy.", + }, + { + heading: "Proposed Solution", + markdown: "Allow creators to enable slow mode.", + }, + { + heading: "Working Spec (Required)", + markdown: + "### Core features\n\n- Toggle on/off\n- One drop per 24h\n\n### User flow\n\nUsers see a countdown after dropping.\n\n### Edge cases (what if...)\n\n_Not provided_\n\n### What is NOT included (scope boundaries)\n\nUsers cannot disable it.", + }, + { + heading: "Implementation Path", + markdown: "Ship as an opt-in setting.", + }, + { + heading: "Impact & Priority", + markdown: + "### Who benefits\n\nBusy art-sharing waves.\n\n### What improves\n\nVisibility.\n\n### Urgency level\n\nLow", + }, + { + heading: "Success Criteria", + markdown: + "### Observable outcome\n\nFewer duplicate drops.\n\n### Measurable signal\n\nMore space between drops.", + }, + { + heading: "Risks & Trade-offs", + markdown: "_Not provided_", + }, + ], + }); + }); + + it("keeps non-canonical level-two headings inside the current section body", () => { + const markdown = buildQuorumProposalMarkdown({ + ...EMPTY_QUORUM_PROPOSAL_FORM_VALUES, + title: "Slow Mode", + summary: "Restrict users to one drop every 24 hours.", + problemStatement: "Waves can become noisy.", + proposedSolution: "Allow creators to enable slow mode.", + coreFeatures: + "- Toggle on/off\n\n## Rollout Notes\n\nStill part of the working spec.", + userFlow: "Users see a countdown after dropping.", + scopeBoundaries: "Users cannot disable it.", + implementationPath: "Ship as an opt-in setting.", + whoBenefits: "Busy art-sharing waves.", + whatImproves: "Visibility.", + urgency: "Low", + observableOutcome: "Fewer duplicate drops.", + measurableSignal: "More space between drops.", + }); + + const parsed = parseQuorumProposalMarkdown(markdown); + + expect(parsed).not.toBeNull(); + if (!parsed) { + throw new Error("Expected quorum markdown to parse"); + } + + expect(parsed.sections.map((section) => section.heading)).toEqual([ + "Problem Statement", + "Proposed Solution", + "Working Spec (Required)", + "Implementation Path", + "Impact & Priority", + "Success Criteria", + "Risks & Trade-offs", + ]); + expect(parsed.sections[2]?.markdown).toContain("## Rollout Notes"); + expect(parsed.sections[2]?.markdown).toContain( + "Still part of the working spec." + ); + }); + + it("keeps indented level-two headings inside the current section body", () => { + const markdown = buildQuorumProposalMarkdown({ + ...EMPTY_QUORUM_PROPOSAL_FORM_VALUES, + title: "Slow Mode", + summary: "Restrict users to one drop every 24 hours.", + problemStatement: "Waves can become noisy.", + proposedSolution: "Allow creators to enable slow mode.", + implementationPath: + "1. Add the setting.\n\n ## Example config\n\n2. Ship it.", + }); + + const parsed = parseQuorumProposalMarkdown(markdown); + + expect(parsed).not.toBeNull(); + if (!parsed) { + throw new Error("Expected quorum markdown to parse"); + } + + expect(parsed.sections[3]?.heading).toBe("Implementation Path"); + expect(parsed.sections[3]?.markdown).toContain(" ## Example config"); + expect(parsed.sections[3]?.markdown).toContain("2. Ship it."); + }); + + it("parses sparse proposals when intermediate sections are omitted", () => { + const markdown = [ + "# Slow Mode", + "", + "## Summary", + "", + "Keep the feed readable.", + "", + "## Problem Statement", + "", + "There are too many drops.", + "", + "## Risks & Trade-offs", + "", + "More structure for submitters.", + ].join("\n"); + + expect(parseQuorumProposalMarkdown(markdown)).toEqual({ + title: "Slow Mode", + summaryMarkdown: "Keep the feed readable.", + sections: [ + { + heading: "Problem Statement", + markdown: "There are too many drops.", + }, + { + heading: "Risks & Trade-offs", + markdown: "More structure for submitters.", + }, + ], + }); + }); + + it("returns null for out-of-order canonical headings", () => { + const markdown = [ + "# Slow Mode", + "", + "## Summary", + "", + "Keep the feed readable.", + "", + "## Risks & Trade-offs", + "", + "More structure for submitters.", + "", + "## Problem Statement", + "", + "There are too many drops.", + ].join("\n"); + + expect(parseQuorumProposalMarkdown(markdown)).toBeNull(); + }); + + it("returns null when canonical headings go out of order after a valid prefix", () => { + const markdown = [ + "# Slow Mode", + "", + "## Summary", + "", + "Keep the feed readable.", + "", + "## Problem Statement", + "", + "There are too many drops.", + "", + "## Risks & Trade-offs", + "", + "More structure for submitters.", + "", + "## Proposed Solution", + "", + "Add a cooldown setting.", + ].join("\n"); + + expect(parseQuorumProposalMarkdown(markdown)).toBeNull(); + }); + + it("keeps repeated canonical headings inside the final parsed section body", () => { + const markdown = [ + "# Slow Mode", + "", + "## Summary", + "", + "Keep the feed readable.", + "", + "## Problem Statement", + "", + "There are too many drops.", + "", + "## Problem Statement", + "", + "This repeated heading is still part of the same section.", + ].join("\n"); + + expect(parseQuorumProposalMarkdown(markdown)).toEqual({ + title: "Slow Mode", + summaryMarkdown: "Keep the feed readable.", + sections: [ + { + heading: "Problem Statement", + markdown: [ + "There are too many drops.", + "", + "## Problem Statement", + "", + "This repeated heading is still part of the same section.", + ].join("\n"), + }, + ], + }); + }); + + it("keeps canonical level-two headings inside the current section body when a later section matches", () => { + const markdown = buildQuorumProposalMarkdown({ + ...EMPTY_QUORUM_PROPOSAL_FORM_VALUES, + title: "Slow Mode", + summary: "Restrict users to one drop every 24 hours.", + problemStatement: "Waves can become noisy.", + proposedSolution: "Allow creators to enable slow mode.", + implementationPath: + "1. Draft the rollout.\n\n## Risks & Trade-offs\n\nStill part of the implementation notes.", + whoBenefits: "Busy art-sharing waves.", + whatImproves: "Visibility.", + urgency: "Low", + observableOutcome: "Fewer duplicate drops.", + measurableSignal: "More space between drops.", + risksTradeoffs: "Actual trade-off goes here.", + }); + + const parsed = parseQuorumProposalMarkdown(markdown); + + expect(parsed).not.toBeNull(); + if (!parsed) { + throw new Error("Expected quorum markdown to parse"); + } + + expect(parsed.sections.map((section) => section.heading)).toEqual([ + "Problem Statement", + "Proposed Solution", + "Working Spec (Required)", + "Implementation Path", + "Impact & Priority", + "Success Criteria", + "Risks & Trade-offs", + ]); + expect(parsed.sections[3]?.markdown).toContain("## Risks & Trade-offs"); + expect(parsed.sections[3]?.markdown).toContain( + "Still part of the implementation notes." + ); + expect(parsed.sections[6]?.markdown).toBe("Actual trade-off goes here."); + }); + + it("keeps canonical headings inside fenced code blocks", () => { + const markdown = buildQuorumProposalMarkdown({ + ...EMPTY_QUORUM_PROPOSAL_FORM_VALUES, + title: "Slow Mode", + summary: "Restrict users to one drop every 24 hours.", + problemStatement: + "```md\n## Impact & Priority\n```\n\nStill describing the problem.", + proposedSolution: "Allow creators to enable slow mode.", + risksTradeoffs: "More structure for submitters.", + }); + + const parsed = parseQuorumProposalMarkdown(markdown); + + expect(parsed).not.toBeNull(); + if (!parsed) { + throw new Error("Expected quorum markdown to parse"); + } + + expect(parsed.sections[0]?.heading).toBe("Problem Statement"); + expect(parsed.sections[0]?.markdown).toContain("## Impact & Priority"); + expect(parsed.sections[0]?.markdown).toContain( + "Still describing the problem." + ); + expect(parsed.sections[1]?.heading).toBe("Proposed Solution"); + }); + + it("keeps fenced code blocks open when matching fence lines have trailing text", () => { + const markdown = buildQuorumProposalMarkdown({ + ...EMPTY_QUORUM_PROPOSAL_FORM_VALUES, + title: "Slow Mode", + summary: "Restrict users to one drop every 24 hours.", + problemStatement: + "````md\n## Impact & Priority\n````ts\n## Risks & Trade-offs\n````\n\nStill describing the problem.", + proposedSolution: "Allow creators to enable slow mode.", + risksTradeoffs: "More structure for submitters.", + }); + + const parsed = parseQuorumProposalMarkdown(markdown); + + expect(parsed).not.toBeNull(); + if (!parsed) { + throw new Error("Expected quorum markdown to parse"); + } + + expect(parsed.sections[0]?.heading).toBe("Problem Statement"); + expect(parsed.sections[0]?.markdown).toContain("## Impact & Priority"); + expect(parsed.sections[0]?.markdown).toContain("````ts"); + expect(parsed.sections[0]?.markdown).toContain("## Risks & Trade-offs"); + expect(parsed.sections[0]?.markdown).toContain( + "Still describing the problem." + ); + expect(parsed.sections[1]?.heading).toBe("Proposed Solution"); + expect(parsed.sections[6]?.heading).toBe("Risks & Trade-offs"); + expect(parsed.sections[6]?.markdown).toBe("More structure for submitters."); + }); + + it("returns null for markdown that does not match the quorum proposal shape", () => { + expect( + parseQuorumProposalMarkdown("## Summary\n\nMissing title") + ).toBeNull(); + expect(parseQuorumProposalMarkdown("# Title only")).toBeNull(); + }); + + it("allows leading blank lines before the title", () => { + expect( + parseQuorumProposalMarkdown( + "\n\n# Slow Mode\n\n## Summary\n\nKeep the feed readable.\n\n## Problem Statement\n\nThere are too many drops." + ) + ).toEqual({ + title: "Slow Mode", + summaryMarkdown: "Keep the feed readable.", + sections: [ + { + heading: "Problem Statement", + markdown: "There are too many drops.", + }, + ], + }); + }); + + it("returns null for stray content before the first section", () => { + expect( + parseQuorumProposalMarkdown( + "# Slow Mode\n\nThis line should not appear before a section.\n\n## Summary\n\nKeep the feed readable.\n\n## Problem Statement\n\nThere are too many drops." + ) + ).toBeNull(); + }); }); diff --git a/__tests__/components/waves/small-leaderboard/QuorumWaveSmallLeaderboardDrop.test.tsx b/__tests__/components/waves/small-leaderboard/QuorumWaveSmallLeaderboardDrop.test.tsx new file mode 100644 index 0000000000..710f7d9f9c --- /dev/null +++ b/__tests__/components/waves/small-leaderboard/QuorumWaveSmallLeaderboardDrop.test.tsx @@ -0,0 +1,36 @@ +import { render } from "@testing-library/react"; +import React from "react"; +import { QuorumWaveSmallLeaderboardDrop } from "@/components/waves/small-leaderboard/QuorumWaveSmallLeaderboardDrop"; + +const defaultWaveSmallLeaderboardDrop = jest.fn(() => null); + +jest.mock( + "@/components/waves/small-leaderboard/DefaultWaveSmallLeaderboardDrop", + () => ({ + DefaultWaveSmallLeaderboardDrop: (props: any) => + defaultWaveSmallLeaderboardDrop(props), + }) +); + +describe("QuorumWaveSmallLeaderboardDrop", () => { + beforeEach(() => { + defaultWaveSmallLeaderboardDrop.mockClear(); + }); + + it("forwards quorumCompact content presentation", () => { + const drop = { id: "d1" } as any; + const onDropClick = jest.fn(); + + render( + + ); + + expect(defaultWaveSmallLeaderboardDrop).toHaveBeenCalledWith( + expect.objectContaining({ + drop, + onDropClick, + contentPresentation: "quorumCompact", + }) + ); + }); +}); diff --git a/__tests__/components/waves/small-leaderboard/WaveSmallLeaderboardItemContent.test.tsx b/__tests__/components/waves/small-leaderboard/WaveSmallLeaderboardItemContent.test.tsx index 145ec85eb4..27d7c84f50 100644 --- a/__tests__/components/waves/small-leaderboard/WaveSmallLeaderboardItemContent.test.tsx +++ b/__tests__/components/waves/small-leaderboard/WaveSmallLeaderboardItemContent.test.tsx @@ -1,43 +1,103 @@ -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { WaveSmallLeaderboardItemContent } from '@/components/waves/small-leaderboard/WaveSmallLeaderboardItemContent'; -import { MemesSubmissionAdditionalInfoKey } from '@/components/waves/memes/submission/types/OperationalData'; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { WaveSmallLeaderboardItemContent } from "@/components/waves/small-leaderboard/WaveSmallLeaderboardItemContent"; +import { MemesSubmissionAdditionalInfoKey } from "@/components/waves/memes/submission/types/OperationalData"; -jest.mock('@/components/waves/drops/WaveDropPartContentMedias', () => () =>
); -jest.mock('@/components/waves/drops/WaveDropPartContentMarkdown', () => () =>
); +jest.mock("@/components/waves/drops/WaveDropPartContentMedias", () => () => ( +
+)); +const waveDropPartContentMarkdownMock = jest.fn(() => ( +
+)); +jest.mock("@/components/waves/drops/WaveDropPartContentMarkdown", () => ({ + __esModule: true, + default: (props: any) => waveDropPartContentMarkdownMock(props), +})); -describe('WaveSmallLeaderboardItemContent', () => { - const baseDrop = { parts: [{ media: [], id: 1 }], metadata: [], mentioned_users: [], referenced_nfts: [], wave: {}, rank: 1 } as any; +describe("WaveSmallLeaderboardItemContent", () => { + const baseDrop = { + parts: [{ media: [], id: 1 }], + metadata: [], + mentioned_users: [], + referenced_nfts: [], + wave: {}, + rank: 1, + } as any; - it('calls onDropClick when content clicked', async () => { + beforeEach(() => { + waveDropPartContentMarkdownMock.mockClear(); + }); + + it("calls onDropClick when content clicked", async () => { const onDropClick = jest.fn(); const user = userEvent.setup(); - render(); - await user.click(screen.getByTestId('markdown').parentElement as HTMLElement); + render( + + ); + await user.click( + screen.getByTestId("markdown").parentElement as HTMLElement + ); expect(onDropClick).toHaveBeenCalled(); }); - it('shows preview image when available instead of media', () => { + it("shows preview image when available instead of media", () => { const dropWithPreview = { ...baseDrop, - parts: [{ media: [{ url: 'original.jpg' }], id: 1 }], - metadata: [{ - data_key: MemesSubmissionAdditionalInfoKey.ADDITIONAL_MEDIA, - data_value: JSON.stringify({ preview_image: 'https://example.com/preview.jpg' }) - }] + parts: [{ media: [{ url: "original.jpg" }], id: 1 }], + metadata: [ + { + data_key: MemesSubmissionAdditionalInfoKey.ADDITIONAL_MEDIA, + data_value: JSON.stringify({ + preview_image: "https://example.com/preview.jpg", + }), + }, + ], }; - render(); - expect(screen.getByRole('img', { name: 'Preview' })).toBeInTheDocument(); - expect(screen.queryByTestId('medias')).not.toBeInTheDocument(); + render( + + ); + expect( + screen.getByRole("img", { name: "Preview image" }) + ).toBeInTheDocument(); + expect(screen.queryByTestId("medias")).not.toBeInTheDocument(); }); - it('shows original media when no preview image', () => { + it("shows original media when no preview image", () => { const dropWithMedia = { ...baseDrop, - parts: [{ media: [{ url: 'original.jpg' }], id: 1 }], + parts: [{ media: [{ url: "original.jpg" }], id: 1 }], }; - render(); - expect(screen.getByTestId('medias')).toBeInTheDocument(); - expect(screen.queryByRole('img', { name: 'Preview' })).not.toBeInTheDocument(); + render( + + ); + expect(screen.getByTestId("medias")).toBeInTheDocument(); + expect( + screen.queryByRole("img", { name: "Preview image" }) + ).not.toBeInTheDocument(); + }); + + it("forwards custom content presentation to markdown rendering", () => { + render( + + ); + + expect(waveDropPartContentMarkdownMock).toHaveBeenCalledWith( + expect.objectContaining({ + contentPresentation: "quorumCompact", + }) + ); }); }); diff --git a/__tests__/components/waves/small-leaderboard/WaveSmallLeaderboardTopThreeDrop.test.tsx b/__tests__/components/waves/small-leaderboard/WaveSmallLeaderboardTopThreeDrop.test.tsx index ae5da2bd59..72534bc428 100644 --- a/__tests__/components/waves/small-leaderboard/WaveSmallLeaderboardTopThreeDrop.test.tsx +++ b/__tests__/components/waves/small-leaderboard/WaveSmallLeaderboardTopThreeDrop.test.tsx @@ -1,11 +1,11 @@ -import { render, screen, fireEvent } from '@testing-library/react'; -import React from 'react'; -import { WaveSmallLeaderboardTopThreeDrop } from '@/components/waves/small-leaderboard/WaveSmallLeaderboardTopThreeDrop'; -import type { ExtendedDrop } from '@/helpers/waves/drop.helpers'; -import type { ApiWave } from '@/generated/models/ApiWave'; +import { render, screen, fireEvent } from "@testing-library/react"; +import React from "react"; +import { WaveSmallLeaderboardTopThreeDrop } from "@/components/waves/small-leaderboard/WaveSmallLeaderboardTopThreeDrop"; +import type { ExtendedDrop } from "@/helpers/waves/drop.helpers"; +import type { ApiWave } from "@/generated/models/ApiWave"; // Mock dependencies -jest.mock('next/link', () => { +jest.mock("next/link", () => { return function MockLink({ children, href, onClick, className }: any) { return ( @@ -15,55 +15,67 @@ jest.mock('next/link', () => { }; }); -jest.mock('@/helpers/Helpers', () => ({ +jest.mock("@/helpers/Helpers", () => ({ cicToType: (cic: number) => { - if (cic >= 90) return 'HIGHLY_ACCURATE'; - if (cic >= 70) return 'ACCURATE'; - if (cic >= 50) return 'PROBABLY_ACCURATE'; - if (cic >= 30) return 'UNKNOWN'; - return 'INACCURATE'; + if (cic >= 90) return "HIGHLY_ACCURATE"; + if (cic >= 70) return "ACCURATE"; + if (cic >= 50) return "PROBABLY_ACCURATE"; + if (cic >= 30) return "UNKNOWN"; + return "INACCURATE"; }, - formatNumberWithCommas: (num: number) => num.toLocaleString('en-US'), + formatNumberWithCommas: (num: number) => num.toLocaleString("en-US"), })); -jest.mock('@/helpers/AllowlistToolHelpers', () => ({ +jest.mock("@/helpers/AllowlistToolHelpers", () => ({ assertUnreachable: jest.fn(), })); -jest.mock('@/components/waves/small-leaderboard/WaveSmallLeaderboardItemContent', () => { - return { - WaveSmallLeaderboardItemContent: function MockWaveSmallLeaderboardItemContent({ drop, onDropClick }: any) { - return ( -
onDropClick(drop)}> - Content for drop {drop.id} -
- ); - } - }; -}); - -jest.mock('@/components/waves/small-leaderboard/WaveSmallLeaderboardItemOutcomes', () => { - return { - WaveSmallLeaderboardItemOutcomes: function MockWaveSmallLeaderboardItemOutcomes({ drop, wave }: any) { - return
Outcomes for {drop.id}
; - } - }; -}); - -jest.mock('@/components/waves/drops/winner/WinnerDropBadge', () => { +jest.mock( + "@/components/waves/small-leaderboard/WaveSmallLeaderboardItemContent", + () => { + return { + WaveSmallLeaderboardItemContent: + function MockWaveSmallLeaderboardItemContent({ + drop, + onDropClick, + }: any) { + return ( +
onDropClick(drop)}> + Content for drop {drop.id} +
+ ); + }, + }; + } +); + +jest.mock( + "@/components/waves/small-leaderboard/WaveSmallLeaderboardItemOutcomes", + () => { + return { + WaveSmallLeaderboardItemOutcomes: + function MockWaveSmallLeaderboardItemOutcomes({ drop, wave }: any) { + return
Outcomes for {drop.id}
; + }, + }; + } +); + +jest.mock("@/components/waves/drops/winner/WinnerDropBadge", () => { return { __esModule: true, default: function MockWinnerDropBadge({ rank, decisionTime }: any) { return (
- Winner Badge - Rank: {rank} {decisionTime ? `Time: ${decisionTime}` : ''} + Winner Badge - Rank: {rank}{" "} + {decisionTime ? `Time: ${decisionTime}` : ""}
); }, }; }); -jest.mock('@/components/drops/view/utils/DropVoteProgressing', () => { +jest.mock("@/components/drops/view/utils/DropVoteProgressing", () => { return { __esModule: true, default: function MockDropVoteProgressing({ current, projected }: any) { @@ -76,46 +88,49 @@ jest.mock('@/components/drops/view/utils/DropVoteProgressing', () => { }; }); -describe('WaveSmallLeaderboardTopThreeDrop', () => { +describe("WaveSmallLeaderboardTopThreeDrop", () => { const mockWave: ApiWave = { - id: 'wave-1', - name: 'Test Wave', + id: "wave-1", + name: "Test Wave", } as ApiWave; const mockOnDropClick = jest.fn(); - const createMockDrop = (overrides: Partial = {}): ExtendedDrop => ({ - id: 'drop-1', - rank: 1, - rating: 100, - rating_prediction: 120, - author: { - id: 'author-1', - handle: 'testuser', - pfp: 'https://example.com/pfp.jpg', - level: 5, - cic: 85, - banner1_color: null, - banner2_color: null, - rep: 0, - tdh: 0, - primary_address: 'test-address', - subscribed_actions: [], - archived: false, - }, - created_at: Date.now(), - title: 'Test Drop', - winning_context: { - decision_time: 1234567890, - }, - ...overrides, - } as ExtendedDrop); + const createMockDrop = ( + overrides: Partial = {} + ): ExtendedDrop => + ({ + id: "drop-1", + rank: 1, + rating: 100, + rating_prediction: 120, + author: { + id: "author-1", + handle: "testuser", + pfp: "https://example.com/pfp.jpg", + level: 5, + cic: 85, + banner1_color: null, + banner2_color: null, + rep: 0, + tdh: 0, + primary_address: "test-address", + subscribed_actions: [], + archived: false, + }, + created_at: Date.now(), + title: "Test Drop", + winning_context: { + decision_time: 1234567890, + }, + ...overrides, + }) as ExtendedDrop; beforeEach(() => { jest.clearAllMocks(); }); - it('renders drop with rank 1 styling', () => { + it("renders drop with rank 1 styling", () => { const drop = createMockDrop({ rank: 1 }); render( { /> ); - expect(screen.getByTestId('winner-badge')).toBeInTheDocument(); - expect(screen.getByText('Winner Badge - Rank: 1 Time: 1234567890')).toBeInTheDocument(); + expect(screen.getByTestId("winner-badge")).toBeInTheDocument(); + expect( + screen.getByText("Winner Badge - Rank: 1 Time: 1234567890") + ).toBeInTheDocument(); }); - it('renders drop with rank 2 styling', () => { + it("renders drop with rank 2 styling", () => { const drop = createMockDrop({ rank: 2 }); render( { /> ); - expect(screen.getByTestId('winner-badge')).toBeInTheDocument(); - expect(screen.getByText('Winner Badge - Rank: 2 Time: 1234567890')).toBeInTheDocument(); + expect(screen.getByTestId("winner-badge")).toBeInTheDocument(); + expect( + screen.getByText("Winner Badge - Rank: 2 Time: 1234567890") + ).toBeInTheDocument(); }); - it('renders drop with rank 3 styling', () => { + it("renders drop with rank 3 styling", () => { const drop = createMockDrop({ rank: 3 }); render( { /> ); - expect(screen.getByTestId('winner-badge')).toBeInTheDocument(); - expect(screen.getByText('Winner Badge - Rank: 3 Time: 1234567890')).toBeInTheDocument(); + expect(screen.getByTestId("winner-badge")).toBeInTheDocument(); + expect( + screen.getByText("Winner Badge - Rank: 3 Time: 1234567890") + ).toBeInTheDocument(); }); - it('does not render winner badge when rank is null', () => { + it("does not render winner badge when rank is null", () => { const drop = createMockDrop({ rank: null }); render( { /> ); - expect(screen.queryByTestId('winner-badge')).not.toBeInTheDocument(); + expect(screen.queryByTestId("winner-badge")).not.toBeInTheDocument(); }); - it('renders author information correctly', () => { + it("renders author information correctly", () => { const drop = createMockDrop(); render( { /> ); - expect(screen.getByText('testuser')).toBeInTheDocument(); - expect(screen.getByText('5')).toBeInTheDocument(); // Level - expect(screen.getByAltText('testuser')).toBeInTheDocument(); // PFP + expect(screen.getByText("testuser")).toBeInTheDocument(); + expect(screen.getByText("5")).toBeInTheDocument(); // Level + expect( + screen.getByAltText("testuser's profile picture") + ).toBeInTheDocument(); // PFP }); - it('renders placeholder when author has no pfp', () => { + it("renders placeholder when author has no pfp", () => { const drop = createMockDrop({ author: { - id: 'author-1', - handle: 'testuser', + id: "author-1", + handle: "testuser", pfp: null, level: 5, cic: 85, @@ -197,7 +220,7 @@ describe('WaveSmallLeaderboardTopThreeDrop', () => { banner2_color: null, rep: 0, tdh: 0, - primary_address: 'test-address', + primary_address: "test-address", subscribed_actions: [], archived: false, }, @@ -210,11 +233,13 @@ describe('WaveSmallLeaderboardTopThreeDrop', () => { /> ); - const placeholderDiv = document.querySelector('.tw-size-6.tw-flex-shrink-0.tw-rounded-lg.tw-bg-iron-800'); + const placeholderDiv = document.querySelector( + ".tw-size-6.tw-flex-shrink-0.tw-rounded-lg.tw-bg-iron-800" + ); expect(placeholderDiv).toBeInTheDocument(); }); - it('displays rating information', () => { + it("displays rating information", () => { const drop = createMockDrop({ rating: 1234 }); render( { /> ); - expect(screen.getByText('1,234')).toBeInTheDocument(); - expect(screen.getByTestId('vote-progressing')).toBeInTheDocument(); + expect(screen.getByText("1,234")).toBeInTheDocument(); + expect(screen.getByTestId("vote-progressing")).toBeInTheDocument(); }); - it('renders all child components', () => { + it("renders all child components", () => { const drop = createMockDrop(); render( { /> ); - expect(screen.getByTestId('item-content')).toBeInTheDocument(); - expect(screen.getByTestId('item-outcomes')).toBeInTheDocument(); + expect(screen.getByTestId("item-content")).toBeInTheDocument(); + expect(screen.getByTestId("item-outcomes")).toBeInTheDocument(); }); - it('applies correct CIC color for highly accurate', () => { + it("applies correct CIC color for highly accurate", () => { const drop = createMockDrop({ author: { ...createMockDrop().author, cic: 95 }, }); @@ -254,11 +279,11 @@ describe('WaveSmallLeaderboardTopThreeDrop', () => { /> ); - const cicIndicator = container.querySelector('.tw-bg-\\[\\#3CCB7F\\]'); + const cicIndicator = container.querySelector(".tw-bg-\\[\\#3CCB7F\\]"); expect(cicIndicator).toBeInTheDocument(); }); - it('applies correct CIC color for inaccurate', () => { + it("applies correct CIC color for inaccurate", () => { const drop = createMockDrop({ author: { ...createMockDrop().author, cic: 25 }, }); @@ -270,11 +295,11 @@ describe('WaveSmallLeaderboardTopThreeDrop', () => { /> ); - const cicIndicator = container.querySelector('.tw-bg-\\[\\#F97066\\]'); + const cicIndicator = container.querySelector(".tw-bg-\\[\\#F97066\\]"); expect(cicIndicator).toBeInTheDocument(); }); - it('stops propagation when author link is clicked', () => { + it("stops propagation when author link is clicked", () => { const drop = createMockDrop(); const { container } = render( { /> ); - const authorLink = screen.getByRole('link'); - + const authorLink = screen.getByRole("link"); + // Spy on stopPropagation on the prototype - const stopPropagationSpy = jest.spyOn(Event.prototype, 'stopPropagation'); - + const stopPropagationSpy = jest.spyOn(Event.prototype, "stopPropagation"); + fireEvent.click(authorLink); expect(stopPropagationSpy).toHaveBeenCalled(); - + stopPropagationSpy.mockRestore(); }); - it('has correct hover styling classes', () => { + it("has correct hover styling classes", () => { const drop = createMockDrop(); const { container } = render( { /> ); - const dropContainer = container.querySelector('.desktop-hover\\:hover\\:tw-bg-iron-800\\/80'); + const dropContainer = container.querySelector( + ".desktop-hover\\:hover\\:tw-bg-iron-800\\/80" + ); expect(dropContainer).toBeInTheDocument(); }); - it('renders with correct rank-based border styling', () => { + it("renders with correct rank-based border styling", () => { const drop = createMockDrop({ rank: 1 }); const { container } = render( { expect(styledElement).toBeInTheDocument(); }); - it('handles missing winning context gracefully', () => { - const drop = createMockDrop({ ...(undefined !== undefined ? { winning_context: undefined } : {}) }); + it("handles missing winning context gracefully", () => { + const drop = createMockDrop({ + ...(undefined !== undefined ? { winning_context: undefined } : {}), + }); render( { /> ); - expect(screen.getByText('Winner Badge - Rank: 1')).toBeInTheDocument(); + expect(screen.getByText("Winner Badge - Rank: 1")).toBeInTheDocument(); }); -}); \ No newline at end of file +}); diff --git a/__tests__/helpers/waves/wave-participation-presentation.helpers.test.ts b/__tests__/helpers/waves/wave-participation-presentation.helpers.test.ts new file mode 100644 index 0000000000..225d4ce422 --- /dev/null +++ b/__tests__/helpers/waves/wave-participation-presentation.helpers.test.ts @@ -0,0 +1,59 @@ +import { resolveWaveParticipationVariant } from "@/helpers/waves/wave-participation-presentation.helpers"; + +describe("resolveWaveParticipationVariant", () => { + it("returns default when wave id is missing", () => { + expect( + resolveWaveParticipationVariant({ + waveId: null, + isMemesWave: () => false, + isCurationWave: () => false, + isQuorumWave: () => false, + }) + ).toBe("default"); + }); + + it("returns memes for memes waves", () => { + expect( + resolveWaveParticipationVariant({ + waveId: "meme-wave", + isMemesWave: (waveId) => waveId === "meme-wave", + isCurationWave: () => false, + isQuorumWave: () => false, + }) + ).toBe("memes"); + }); + + it("returns curation for curation waves", () => { + expect( + resolveWaveParticipationVariant({ + waveId: "curation-wave", + isMemesWave: () => false, + isCurationWave: (waveId) => waveId === "curation-wave", + isQuorumWave: () => false, + }) + ).toBe("curation"); + }); + + it("returns quorum for quorum waves", () => { + expect( + resolveWaveParticipationVariant({ + waveId: "quorum-wave", + isMemesWave: () => false, + isCurationWave: () => false, + isQuorumWave: (waveId) => waveId === "quorum-wave", + }) + ).toBe("quorum"); + }); + + it("prefers explicit overrides over built-in variants", () => { + expect( + resolveWaveParticipationVariant({ + waveId: "meme-wave", + overrides: { "meme-wave": "quorum" }, + isMemesWave: (waveId) => waveId === "meme-wave", + isCurationWave: () => false, + isQuorumWave: () => false, + }) + ).toBe("quorum"); + }); +}); diff --git a/components/brain/my-stream/MyStreamWaveLeaderboard.tsx b/components/brain/my-stream/MyStreamWaveLeaderboard.tsx index 6796244087..8032b021b7 100644 --- a/components/brain/my-stream/MyStreamWaveLeaderboard.tsx +++ b/components/brain/my-stream/MyStreamWaveLeaderboard.tsx @@ -188,6 +188,7 @@ const MyStreamWaveLeaderboard: React.FC = ({ = ({ , document.body )} - {wave && ( + {wave && isResubmitModalOpen && ( = (props) => { + // Keep quorum on the default detail view until the proposal-specific design lands. + return ; +}; diff --git a/components/waves/drop/SingleWaveDrop.tsx b/components/waves/drop/SingleWaveDrop.tsx index 90d0f1d3e8..a932b4bde8 100644 --- a/components/waves/drop/SingleWaveDrop.tsx +++ b/components/waves/drop/SingleWaveDrop.tsx @@ -1,25 +1,13 @@ import React from "react"; -import type { ExtendedDrop } from "@/helpers/waves/drop.helpers"; -import { DefaultSingleWaveDrop } from "./DefaultSingleWaveDrop"; -import { MemesSingleWaveDrop } from "./MemesSingleWaveDrop"; -import { useSeizeSettings } from "@/contexts/SeizeSettingsContext"; - -interface SingleWaveDropProps { - readonly drop: ExtendedDrop; - readonly onClose: () => void; -} +import { useWaveParticipationRendererSet } from "../drops/participation/participationRendererRegistry"; +import type { SingleWaveDropProps } from "../drops/participation/participationRenderer.types"; export const SingleWaveDrop: React.FC = ({ drop: initialDrop, onClose, }) => { - // Check if this is the memes wave - const { isMemesWave } = useSeizeSettings(); - const isMemes = isMemesWave(initialDrop.wave.id); - - if (isMemes) { - return ; - } + const { SingleWaveDrop: SingleWaveDropRenderer } = + useWaveParticipationRendererSet(initialDrop.wave.id); - return ; + return ; }; diff --git a/components/waves/drops/WaveDropContent.tsx b/components/waves/drops/WaveDropContent.tsx index 4d7ec47803..72c192fdc8 100644 --- a/components/waves/drops/WaveDropContent.tsx +++ b/components/waves/drops/WaveDropContent.tsx @@ -7,6 +7,7 @@ import WaveDropPart from "./WaveDropPart"; import type { ExtendedDrop } from "@/helpers/waves/drop.helpers"; import { ImageScale } from "@/helpers/image.helpers"; import useIsTouchDevice from "@/hooks/useIsTouchDevice"; +import type { DropContentPresentation } from "./dropContentPresentation"; interface WaveDropContentProps { readonly drop: ExtendedDrop; @@ -34,6 +35,7 @@ interface WaveDropContentProps { readonly onLinkCardActionsActiveChange?: | ((href: string, active: boolean) => void) | undefined; + readonly contentPresentation?: DropContentPresentation | undefined; } const WaveDropContent: React.FC = ({ @@ -53,6 +55,7 @@ const WaveDropContent: React.FC = ({ fullWidthMedia = false, hasTouch, onLinkCardActionsActiveChange, + contentPresentation = "default", }) => { const isTouchDevice = useIsTouchDevice(); const effectiveHasTouch = hasTouch ?? isTouchDevice; @@ -75,6 +78,7 @@ const WaveDropContent: React.FC = ({ fullWidthMedia={fullWidthMedia} hasTouch={effectiveHasTouch} onLinkCardActionsActiveChange={onLinkCardActionsActiveChange} + contentPresentation={contentPresentation} /> ); }; diff --git a/components/waves/drops/WaveDropPart.tsx b/components/waves/drops/WaveDropPart.tsx index 0b2f1cbe1f..a30987c57e 100644 --- a/components/waves/drops/WaveDropPart.tsx +++ b/components/waves/drops/WaveDropPart.tsx @@ -8,6 +8,7 @@ import type { ApiMentionedWave } from "@/generated/models/ApiMentionedWave"; import WaveDropPartDrop from "./WaveDropPartDrop"; import type { ExtendedDrop } from "@/helpers/waves/drop.helpers"; import { ImageScale } from "@/helpers/image.helpers"; +import type { DropContentPresentation } from "./dropContentPresentation"; interface WaveDropPartProps { readonly drop: ExtendedDrop; @@ -35,6 +36,7 @@ interface WaveDropPartProps { readonly onLinkCardActionsActiveChange?: | ((href: string, active: boolean) => void) | undefined; + readonly contentPresentation?: DropContentPresentation | undefined; } const LONG_PRESS_DURATION = 500; // milliseconds @@ -58,6 +60,7 @@ const WaveDropPart: React.FC = memo( fullWidthMedia = false, hasTouch = false, onLinkCardActionsActiveChange, + contentPresentation = "default", }) => { const activePart = drop.parts[activePartIndex]; @@ -156,6 +159,7 @@ const WaveDropPart: React.FC = memo( mediaImageScale={mediaImageScale} fullWidthMedia={fullWidthMedia} onLinkCardActionsActiveChange={onLinkCardActionsActiveChange} + contentPresentation={contentPresentation} />
diff --git a/components/waves/drops/WaveDropPartContent.tsx b/components/waves/drops/WaveDropPartContent.tsx index fa48837894..1d38cc241d 100644 --- a/components/waves/drops/WaveDropPartContent.tsx +++ b/components/waves/drops/WaveDropPartContent.tsx @@ -11,6 +11,7 @@ import type { ApiWaveMin } from "@/generated/models/ApiWaveMin"; import type { ApiDrop } from "@/generated/models/ApiDrop"; import WaveDropPartContentMarkdown from "./WaveDropPartContentMarkdown"; import { ImageScale } from "@/helpers/image.helpers"; +import type { DropContentPresentation } from "./dropContentPresentation"; interface WaveDropPartContentProps { readonly mentionedUsers: ApiDropMentionedUser[]; @@ -43,6 +44,7 @@ interface WaveDropPartContentProps { readonly onLinkCardActionsActiveChange?: | ((href: string, active: boolean) => void) | undefined; + readonly contentPresentation?: DropContentPresentation | undefined; } const WaveDropPartContent: React.FC = ({ @@ -67,6 +69,7 @@ const WaveDropPartContent: React.FC = ({ mediaImageScale = ImageScale.AUTOx450, fullWidthMedia = false, onLinkCardActionsActiveChange, + contentPresentation = "default", }) => { const contentRef = React.useRef(null); @@ -162,6 +165,7 @@ const WaveDropPartContent: React.FC = ({ onCancel={onCancel} drop={drop} onLinkCardActionsActiveChange={onLinkCardActionsActiveChange} + contentPresentation={contentPresentation} />
{!!activePart.media.length && ( diff --git a/components/waves/drops/WaveDropPartContentMarkdown.tsx b/components/waves/drops/WaveDropPartContentMarkdown.tsx index df6362962f..fdddaa9c20 100644 --- a/components/waves/drops/WaveDropPartContentMarkdown.tsx +++ b/components/waves/drops/WaveDropPartContentMarkdown.tsx @@ -8,6 +8,9 @@ import type { ApiDropReferencedNFT } from "@/generated/models/ApiDropReferencedN import type { ApiMentionedWave } from "@/generated/models/ApiMentionedWave"; import type { ApiWaveMin } from "@/generated/models/ApiWaveMin"; import React from "react"; +import QuorumProposalCompactContent from "@/components/waves/quorum/QuorumProposalCompactContent"; +import { parseQuorumProposalMarkdown } from "@/components/waves/quorum/quorumProposalMarkdown"; +import type { DropContentPresentation } from "./dropContentPresentation"; import EditDropLexical from "./EditDropLexical"; import WaveDropQuoteWithDropId from "./WaveDropQuoteWithDropId"; @@ -34,6 +37,7 @@ interface WaveDropPartContentMarkdownProps { readonly onLinkCardActionsActiveChange?: | ((href: string, active: boolean) => void) | undefined; + readonly contentPresentation?: DropContentPresentation | undefined; } const WaveDropPartContentMarkdown: React.FC< @@ -52,10 +56,15 @@ const WaveDropPartContentMarkdown: React.FC< onCancel, drop, onLinkCardActionsActiveChange, + contentPresentation = "default", }) => { const linkPreviewToggleControl = useDropLinkPreviewToggleControl(drop); const currentQuotePath = drop?.serial_no === undefined ? [] : [`${wave.id}:${drop.serial_no}`]; + const compactProposal = + contentPresentation === "quorumCompact" + ? parseQuorumProposalMarkdown(part.content) + : null; if (isEditing) { return ( @@ -89,20 +98,37 @@ const WaveDropPartContentMarkdown: React.FC< return ( <>
- + {compactProposal ? ( + + ) : ( + + )} {typeof drop?.updated_at === "number" && drop.updated_at !== drop.created_at && (
diff --git a/components/waves/drops/WaveDropPartDrop.tsx b/components/waves/drops/WaveDropPartDrop.tsx index 556cb96ea9..729e30e469 100644 --- a/components/waves/drops/WaveDropPartDrop.tsx +++ b/components/waves/drops/WaveDropPartDrop.tsx @@ -7,6 +7,7 @@ import type { ApiMentionedWave } from "@/generated/models/ApiMentionedWave"; import WaveDropPartTitle from "./WaveDropPartTitle"; import WaveDropPartContent from "./WaveDropPartContent"; import { ImageScale } from "@/helpers/image.helpers"; +import type { DropContentPresentation } from "./dropContentPresentation"; interface WaveDropPartDropProps { drop: ApiDrop; @@ -34,6 +35,7 @@ interface WaveDropPartDropProps { readonly onLinkCardActionsActiveChange?: | ((href: string, active: boolean) => void) | undefined; + readonly contentPresentation?: DropContentPresentation | undefined; } const WaveDropPartDrop: React.FC = ({ @@ -53,12 +55,15 @@ const WaveDropPartDrop: React.FC = ({ mediaImageScale = ImageScale.AUTOx450, fullWidthMedia = false, onLinkCardActionsActiveChange, + contentPresentation = "default", }) => { + const showStandaloneTitle = contentPresentation !== "quorumCompact"; + return (
- + {showStandaloneTitle && } = ({ mediaImageScale={mediaImageScale} fullWidthMedia={fullWidthMedia} onLinkCardActionsActiveChange={onLinkCardActionsActiveChange} + contentPresentation={contentPresentation} />
diff --git a/components/waves/drops/dropContentPresentation.ts b/components/waves/drops/dropContentPresentation.ts new file mode 100644 index 0000000000..571957c85d --- /dev/null +++ b/components/waves/drops/dropContentPresentation.ts @@ -0,0 +1 @@ +export type DropContentPresentation = "default" | "quorumCompact"; diff --git a/components/waves/drops/participation/EndedParticipationDrop.tsx b/components/waves/drops/participation/EndedParticipationDrop.tsx index 5c8dd19c5c..a0ab8ec11f 100644 --- a/components/waves/drops/participation/EndedParticipationDrop.tsx +++ b/components/waves/drops/participation/EndedParticipationDrop.tsx @@ -27,6 +27,7 @@ import { getParticipationIdentityProfile, getParticipationVisibleMetadata, } from "./participationIdentityProfile.helpers"; +import type { DropContentPresentation } from "../dropContentPresentation"; import type { DropIdentityMode, DropInteractionParams } from "../drop.types"; import { DropLocation } from "../drop.types"; @@ -41,6 +42,7 @@ interface EndedParticipationDropProps { readonly onDropContentClick?: ((drop: ExtendedDrop) => void) | undefined; readonly identityMode?: DropIdentityMode | undefined; readonly showInteractions?: boolean | undefined; + readonly contentPresentation?: DropContentPresentation | undefined; } export default function EndedParticipationDrop({ @@ -54,6 +56,7 @@ export default function EndedParticipationDrop({ onDropContentClick, identityMode = "default", showInteractions = true, + contentPresentation = "default", }: EndedParticipationDropProps) { const isActiveDrop = activeDrop?.drop.id === drop.id; const router = useRouter(); @@ -214,6 +217,7 @@ export default function EndedParticipationDrop({ setLongPressTriggered={setLongPressTriggered} isCompetitionDrop={true} hasTouch={hasTouch} + contentPresentation={contentPresentation} />
diff --git a/components/waves/drops/participation/OngoingParticipationDrop.tsx b/components/waves/drops/participation/OngoingParticipationDrop.tsx index b7ed507d10..c8278302e7 100644 --- a/components/waves/drops/participation/OngoingParticipationDrop.tsx +++ b/components/waves/drops/participation/OngoingParticipationDrop.tsx @@ -25,6 +25,7 @@ import { getParticipationIdentityProfile, getParticipationVisibleMetadata, } from "./participationIdentityProfile.helpers"; +import type { DropContentPresentation } from "../dropContentPresentation"; import type { DropIdentityMode, DropInteractionParams, @@ -42,6 +43,7 @@ interface OngoingParticipationDropProps { readonly onDropContentClick?: ((drop: ExtendedDrop) => void) | undefined; readonly identityMode?: DropIdentityMode | undefined; readonly showInteractions?: boolean | undefined; + readonly contentPresentation?: DropContentPresentation | undefined; } export default function OngoingParticipationDrop({ @@ -55,6 +57,7 @@ export default function OngoingParticipationDrop({ onDropContentClick, identityMode = "default", showInteractions = true, + contentPresentation = "default", }: OngoingParticipationDropProps) { const isActiveDrop = activeDrop?.drop.id === drop.id; const { canShowVote } = useDropInteractionRules(drop); @@ -140,6 +143,7 @@ export default function OngoingParticipationDrop({ onQuoteClick={onQuoteClick} setLongPressTriggered={setLongPressTriggered} isCompetitionDrop={true} + contentPresentation={contentPresentation} />
diff --git a/components/waves/drops/participation/ParticipationDrop.tsx b/components/waves/drops/participation/ParticipationDrop.tsx index 1127ddc5f6..c37b487a5e 100644 --- a/components/waves/drops/participation/ParticipationDrop.tsx +++ b/components/waves/drops/participation/ParticipationDrop.tsx @@ -1,38 +1,9 @@ -import type { ExtendedDrop } from "@/helpers/waves/drop.helpers"; -import type { ActiveDropState } from "@/types/dropInteractionTypes"; -import type { ApiDrop } from "@/generated/models/ApiDrop"; -import React from "react"; -import DefaultParticipationDrop from "./DefaultParticipationDrop"; -import MemeParticipationDrop from "@/components/memes/drops/MemeParticipationDrop"; -import { useSeizeSettings } from "@/contexts/SeizeSettingsContext"; -import type { - DropIdentityMode, - DropInteractionParams, - DropLocation, -} from "../drop.types"; - -interface ParticipationDropProps { - readonly drop: ExtendedDrop; - readonly showWaveInfo: boolean; - readonly activeDrop: ActiveDropState | null; - readonly showReplyAndQuote: boolean; - readonly location: DropLocation; - readonly onReply: (param: DropInteractionParams) => void; - readonly onQuoteClick: (drop: ApiDrop) => void; - readonly onDropContentClick?: ((drop: ExtendedDrop) => void) | undefined; - readonly parentContainerRef?: React.RefObject | undefined; - readonly identityMode?: DropIdentityMode | undefined; - readonly showInteractions?: boolean | undefined; -} +import { useWaveParticipationRendererSet } from "./participationRendererRegistry"; +import type { ParticipationDropProps } from "./participationRenderer.types"; export default function ParticipationDrop(props: ParticipationDropProps) { - const { drop } = props; - - const { isMemesWave } = useSeizeSettings(); - - if (isMemesWave(drop.wave.id.toLowerCase())) { - return ; - } + const { ParticipationDrop: ParticipationDropRenderer } = + useWaveParticipationRendererSet(props.drop.wave.id); - return ; + return ; } diff --git a/components/waves/drops/participation/ParticipationDropContent.tsx b/components/waves/drops/participation/ParticipationDropContent.tsx index 131f16992e..4202914de2 100644 --- a/components/waves/drops/participation/ParticipationDropContent.tsx +++ b/components/waves/drops/participation/ParticipationDropContent.tsx @@ -2,6 +2,7 @@ import type { ExtendedDrop } from "@/helpers/waves/drop.helpers"; import type { ApiDrop } from "@/generated/models/ApiDrop"; import WaveDropContent from "../WaveDropContent"; import useIsTouchDevice from "@/hooks/useIsTouchDevice"; +import type { DropContentPresentation } from "../dropContentPresentation"; interface ParticipationDropContentProps { readonly drop: ExtendedDrop; @@ -12,6 +13,7 @@ interface ParticipationDropContentProps { readonly onQuoteClick: (drop: ApiDrop) => void; readonly setLongPressTriggered: (triggered: boolean) => void; readonly isCompetitionDrop?: boolean | undefined; + readonly contentPresentation?: DropContentPresentation | undefined; } export default function ParticipationDropContent({ @@ -23,6 +25,7 @@ export default function ParticipationDropContent({ onQuoteClick, setLongPressTriggered, isCompetitionDrop = false, + contentPresentation = "default", }: ParticipationDropContentProps) { const hasTouch = useIsTouchDevice(); @@ -38,6 +41,7 @@ export default function ParticipationDropContent({ setLongPressTriggered={setLongPressTriggered} isCompetitionDrop={isCompetitionDrop} hasTouch={hasTouch} + contentPresentation={contentPresentation} />
); diff --git a/components/waves/drops/participation/participationRenderer.types.ts b/components/waves/drops/participation/participationRenderer.types.ts new file mode 100644 index 0000000000..28cc71dd2a --- /dev/null +++ b/components/waves/drops/participation/participationRenderer.types.ts @@ -0,0 +1,38 @@ +import type { ComponentType, RefObject } from "react"; +import type { ApiDrop } from "@/generated/models/ApiDrop"; +import type { ExtendedDrop } from "@/helpers/waves/drop.helpers"; +import type { WaveParticipationVariant } from "@/helpers/waves/wave-participation-presentation.helpers"; +import type { ActiveDropState } from "@/types/dropInteractionTypes"; +import type { + DropIdentityMode, + DropInteractionParams, + DropLocation, +} from "../drop.types"; + +export interface ParticipationDropProps { + readonly drop: ExtendedDrop; + readonly showWaveInfo: boolean; + readonly activeDrop: ActiveDropState | null; + readonly showReplyAndQuote: boolean; + readonly location: DropLocation; + readonly onReply: (param: DropInteractionParams) => void; + readonly onQuoteClick: (drop: ApiDrop) => void; + readonly onDropContentClick?: ((drop: ExtendedDrop) => void) | undefined; + readonly parentContainerRef?: RefObject | undefined; + readonly identityMode?: DropIdentityMode | undefined; + readonly showInteractions?: boolean | undefined; +} + +export interface SingleWaveDropProps { + readonly drop: ExtendedDrop; + readonly onClose: () => void; +} + +export interface WaveParticipationRendererSet { + readonly ParticipationDrop: ComponentType; + readonly SingleWaveDrop: ComponentType; +} + +export interface ResolvedWaveParticipationRendererSet extends WaveParticipationRendererSet { + readonly variant: WaveParticipationVariant; +} diff --git a/components/waves/drops/participation/participationRendererRegistry.tsx b/components/waves/drops/participation/participationRendererRegistry.tsx new file mode 100644 index 0000000000..8b00575fc2 --- /dev/null +++ b/components/waves/drops/participation/participationRendererRegistry.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { useMemo } from "react"; +import { useSeizeSettings } from "@/contexts/SeizeSettingsContext"; +import { + resolveWaveParticipationVariant, + type WaveParticipationVariant, +} from "@/helpers/waves/wave-participation-presentation.helpers"; +import MemeParticipationDrop from "@/components/memes/drops/MemeParticipationDrop"; +import { DefaultSingleWaveDrop } from "@/components/waves/drop/DefaultSingleWaveDrop"; +import { MemesSingleWaveDrop } from "@/components/waves/drop/MemesSingleWaveDrop"; +import { QuorumSingleWaveDrop } from "@/components/waves/drop/QuorumSingleWaveDrop"; +import QuorumParticipationDrop from "@/components/waves/quorum/QuorumParticipationDrop"; +import DefaultParticipationDrop from "./DefaultParticipationDrop"; +import type { + ResolvedWaveParticipationRendererSet, + WaveParticipationRendererSet, +} from "./participationRenderer.types"; + +// Use this for one-off waves that should opt into a custom renderer before +// the API exposes a presentation variant. +const WAVE_PARTICIPATION_VARIANT_OVERRIDES: Readonly< + Partial> +> = {}; + +const WAVE_PARTICIPATION_RENDERERS: Readonly< + Record +> = { + default: { + ParticipationDrop: DefaultParticipationDrop, + SingleWaveDrop: DefaultSingleWaveDrop, + }, + memes: { + ParticipationDrop: MemeParticipationDrop, + SingleWaveDrop: MemesSingleWaveDrop, + }, + curation: { + ParticipationDrop: DefaultParticipationDrop, + SingleWaveDrop: DefaultSingleWaveDrop, + }, + quorum: { + ParticipationDrop: QuorumParticipationDrop, + SingleWaveDrop: QuorumSingleWaveDrop, + }, +}; + +export const useWaveParticipationRendererSet = ( + waveId: string | null | undefined +): ResolvedWaveParticipationRendererSet => { + const { isMemesWave, isCurationWave, isQuorumWave } = useSeizeSettings(); + + return useMemo(() => { + const variant = resolveWaveParticipationVariant({ + waveId, + overrides: WAVE_PARTICIPATION_VARIANT_OVERRIDES, + isMemesWave, + isCurationWave, + isQuorumWave, + }); + + return { + variant, + ...WAVE_PARTICIPATION_RENDERERS[variant], + }; + }, [isMemesWave, isCurationWave, isQuorumWave, waveId]); +}; diff --git a/components/waves/leaderboard/content/WaveLeaderboardDropContent.tsx b/components/waves/leaderboard/content/WaveLeaderboardDropContent.tsx index 41326442f4..3aa3c1bd9c 100644 --- a/components/waves/leaderboard/content/WaveLeaderboardDropContent.tsx +++ b/components/waves/leaderboard/content/WaveLeaderboardDropContent.tsx @@ -6,6 +6,7 @@ import WaveDropContent from "@/components/waves/drops/WaveDropContent"; import WaveDropMetadata from "@/components/waves/drops/WaveDropMetadata"; import { useRouter } from "next/navigation"; import WaveDropReactions from "@/components/waves/drops/WaveDropReactions"; +import type { DropContentPresentation } from "@/components/waves/drops/dropContentPresentation"; import { getDropIdentityProfile, getDropVisibleMetadata, @@ -17,11 +18,12 @@ import { WaveLeaderboardIdentity } from "../identity/WaveLeaderboardIdentity"; interface WaveLeaderboardDropContentProps { readonly drop: ExtendedDrop; readonly isCompetitionDrop?: boolean | undefined; + readonly contentPresentation?: DropContentPresentation | undefined; } export const WaveLeaderboardDropContent: React.FC< WaveLeaderboardDropContentProps -> = ({ drop, isCompetitionDrop = false }) => { +> = ({ drop, isCompetitionDrop = false, contentPresentation = "default" }) => { const router = useRouter(); const [activePartIndex, setActivePartIndex] = useState(0); const visibleMetadata = getDropVisibleMetadata({ @@ -60,6 +62,7 @@ export const WaveLeaderboardDropContent: React.FC< onQuoteClick={() => {}} setLongPressTriggered={() => {}} isCompetitionDrop={isCompetitionDrop} + contentPresentation={contentPresentation} /> void; + readonly contentPresentation?: DropContentPresentation | undefined; } export const DefaultWaveLeaderboardDrop: React.FC< DefaultWaveLeaderboardDropProps -> = ({ drop, onDropClick }) => { +> = ({ drop, onDropClick, contentPresentation = "default" }) => { const { canShowVote, canDelete } = useDropInteractionRules(drop); const [isVotingModalOpen, setIsVotingModalOpen] = useState(false); const { hasTouchScreen } = useDeviceInfo(); @@ -75,7 +77,11 @@ export const DefaultWaveLeaderboardDrop: React.FC<
- +
diff --git a/components/waves/leaderboard/drops/QuorumWaveLeaderboardDrop.tsx b/components/waves/leaderboard/drops/QuorumWaveLeaderboardDrop.tsx new file mode 100644 index 0000000000..16e79e3f59 --- /dev/null +++ b/components/waves/leaderboard/drops/QuorumWaveLeaderboardDrop.tsx @@ -0,0 +1,22 @@ +"use client"; + +import React from "react"; +import type { ExtendedDrop } from "@/helpers/waves/drop.helpers"; +import { DefaultWaveLeaderboardDrop } from "./DefaultWaveLeaderboardDrop"; + +interface QuorumWaveLeaderboardDropProps { + readonly drop: ExtendedDrop; + readonly onDropClick: (drop: ExtendedDrop) => void; +} + +export const QuorumWaveLeaderboardDrop: React.FC< + QuorumWaveLeaderboardDropProps +> = ({ drop, onDropClick }) => { + return ( + + ); +}; diff --git a/components/waves/leaderboard/drops/WaveLeaderboardDrop.tsx b/components/waves/leaderboard/drops/WaveLeaderboardDrop.tsx index 6945fc7384..3166b9f062 100644 --- a/components/waves/leaderboard/drops/WaveLeaderboardDrop.tsx +++ b/components/waves/leaderboard/drops/WaveLeaderboardDrop.tsx @@ -1,33 +1,20 @@ import React from "react"; import type { ExtendedDrop } from "@/helpers/waves/drop.helpers"; -import type { ApiWave } from "@/generated/models/ObjectSerializer"; -import { MemesLeaderboardDrop } from "@/components/memes/drops/MemesLeaderboardDrop"; -import { useWave } from "@/hooks/useWave"; -import { DefaultWaveLeaderboardDrop } from "./DefaultWaveLeaderboardDrop"; +import type { ApiWave } from "@/generated/models/ApiWave"; +import { useWaveLeaderboardRendererSet } from "../leaderboardRendererRegistry"; interface WaveLeaderboardDropProps { readonly drop: ExtendedDrop; readonly wave: ApiWave; readonly onDropClick: (drop: ExtendedDrop) => void; - readonly onSourceDropDeleted?: (() => void) | undefined; } export const WaveLeaderboardDrop: React.FC = ({ drop, wave, onDropClick, - onSourceDropDeleted, }) => { - const { isMemesWave } = useWave(wave); - if (isMemesWave) { - return ( - - ); - } - return ; + const { LeaderboardDrop } = useWaveLeaderboardRendererSet(wave.id); + + return ; }; diff --git a/components/waves/leaderboard/drops/WaveLeaderboardDrops.tsx b/components/waves/leaderboard/drops/WaveLeaderboardDrops.tsx index 945e359206..02030dca18 100644 --- a/components/waves/leaderboard/drops/WaveLeaderboardDrops.tsx +++ b/components/waves/leaderboard/drops/WaveLeaderboardDrops.tsx @@ -5,7 +5,6 @@ import type { ExtendedDrop } from "@/helpers/waves/drop.helpers"; import { useIntersectionObserver } from "@/hooks/useIntersectionObserver"; import type { WaveDropsLeaderboardSort } from "@/hooks/useWaveDropsLeaderboard"; import { useWaveDropsLeaderboard } from "@/hooks/useWaveDropsLeaderboard"; -import { usePathname, useRouter, useSearchParams } from "next/navigation"; import React from "react"; import { WaveLeaderboardDrop } from "./WaveLeaderboardDrop"; import { WaveLeaderboardEmptyState } from "./WaveLeaderboardEmptyState"; @@ -15,6 +14,7 @@ import { WaveLeaderboardLoadingBar } from "./WaveLeaderboardLoadingBar"; interface WaveLeaderboardDropsProps { readonly wave: ApiWave; readonly sort: WaveDropsLeaderboardSort; + readonly onDropClick: (drop: ExtendedDrop) => void; readonly onCreateDrop?: (() => void) | undefined; readonly curatedByGroupId?: string | undefined; readonly minPrice?: number | undefined; @@ -25,30 +25,22 @@ interface WaveLeaderboardDropsProps { export const WaveLeaderboardDrops: React.FC = ({ wave, sort, + onDropClick, onCreateDrop, curatedByGroupId, minPrice, maxPrice, priceCurrency, }) => { - const router = useRouter(); - const pathname = usePathname(); - const searchParams = useSearchParams(); - const { - drops, - fetchNextPage, - hasNextPage, - isFetching, - isFetchingNextPage, - refetch, - } = useWaveDropsLeaderboard({ - waveId: wave.id, - sort, - curatedByGroupId, - minPrice, - maxPrice, - priceCurrency, - }); + const { drops, fetchNextPage, hasNextPage, isFetching, isFetchingNextPage } = + useWaveDropsLeaderboard({ + waveId: wave.id, + sort, + curatedByGroupId, + minPrice, + maxPrice, + priceCurrency, + }); const intersectionElementRef = useIntersectionObserver(async () => { if (hasNextPage && !isFetching && !isFetchingNextPage) { @@ -56,16 +48,6 @@ export const WaveLeaderboardDrops: React.FC = ({ } }); - const onDropClick = (drop: ExtendedDrop) => { - const params = new URLSearchParams(searchParams.toString()); - params.set("drop", drop.id); - router.push(`${pathname}?${params.toString()}`); - }; - - const handleSourceDropDeleted = React.useCallback(() => { - void refetch(); - }, [refetch]); - if (isFetching && drops.length === 0) { return ; } @@ -84,7 +66,6 @@ export const WaveLeaderboardDrops: React.FC = ({ drop={drop} wave={wave} onDropClick={onDropClick} - onSourceDropDeleted={handleSourceDropDeleted} /> ))} {isFetchingNextPage && } diff --git a/components/waves/leaderboard/leaderboardRendererRegistry.tsx b/components/waves/leaderboard/leaderboardRendererRegistry.tsx new file mode 100644 index 0000000000..fd98c06b99 --- /dev/null +++ b/components/waves/leaderboard/leaderboardRendererRegistry.tsx @@ -0,0 +1,103 @@ +"use client"; + +import React, { useMemo } from "react"; +import type { ComponentType } from "react"; +import { useSeizeSettings } from "@/contexts/SeizeSettingsContext"; +import type { ApiWave } from "@/generated/models/ApiWave"; +import type { ExtendedDrop } from "@/helpers/waves/drop.helpers"; +import { + resolveWaveParticipationVariant, + type WaveParticipationVariant, +} from "@/helpers/waves/wave-participation-presentation.helpers"; +import { MemesLeaderboardDrop } from "@/components/memes/drops/MemesLeaderboardDrop"; +import { DefaultWaveSmallLeaderboardDrop } from "@/components/waves/small-leaderboard/DefaultWaveSmallLeaderboardDrop"; +import { MemesWaveSmallLeaderboardDrop } from "@/components/waves/small-leaderboard/MemesWaveSmallLeaderboardDrop"; +import { QuorumWaveSmallLeaderboardDrop } from "@/components/waves/small-leaderboard/QuorumWaveSmallLeaderboardDrop"; +import { DefaultWaveLeaderboardDrop } from "./drops/DefaultWaveLeaderboardDrop"; +import { QuorumWaveLeaderboardDrop } from "./drops/QuorumWaveLeaderboardDrop"; + +interface WaveLeaderboardDropRendererProps { + readonly drop: ExtendedDrop; + readonly wave: ApiWave; + readonly onDropClick: (drop: ExtendedDrop) => void; +} + +interface WaveSmallLeaderboardDropRendererProps { + readonly drop: ExtendedDrop; + readonly onDropClick: () => void; +} + +interface WaveLeaderboardRendererSet { + readonly LeaderboardDrop: ComponentType; + readonly SmallLeaderboardDrop: ComponentType; +} + +interface ResolvedWaveLeaderboardRendererSet extends WaveLeaderboardRendererSet { + readonly variant: WaveParticipationVariant; +} + +const WAVE_LEADERBOARD_VARIANT_OVERRIDES: Readonly< + Partial> +> = {}; + +const DefaultLeaderboardDropRenderer: React.FC< + WaveLeaderboardDropRendererProps +> = ({ drop, onDropClick }) => { + return ; +}; + +const QuorumLeaderboardDropRenderer: React.FC< + WaveLeaderboardDropRendererProps +> = ({ drop, onDropClick }) => { + return ; +}; + +const MemesLeaderboardDropRenderer: React.FC< + WaveLeaderboardDropRendererProps +> = ({ drop, wave, onDropClick }) => { + return ( + + ); +}; + +const WAVE_LEADERBOARD_RENDERERS: Readonly< + Record +> = { + default: { + LeaderboardDrop: DefaultLeaderboardDropRenderer, + SmallLeaderboardDrop: DefaultWaveSmallLeaderboardDrop, + }, + memes: { + LeaderboardDrop: MemesLeaderboardDropRenderer, + SmallLeaderboardDrop: MemesWaveSmallLeaderboardDrop, + }, + curation: { + LeaderboardDrop: DefaultLeaderboardDropRenderer, + SmallLeaderboardDrop: DefaultWaveSmallLeaderboardDrop, + }, + quorum: { + LeaderboardDrop: QuorumLeaderboardDropRenderer, + SmallLeaderboardDrop: QuorumWaveSmallLeaderboardDrop, + }, +}; + +export const useWaveLeaderboardRendererSet = ( + waveId: string | null | undefined +): ResolvedWaveLeaderboardRendererSet => { + const { isMemesWave, isCurationWave, isQuorumWave } = useSeizeSettings(); + + return useMemo(() => { + const variant = resolveWaveParticipationVariant({ + waveId, + overrides: WAVE_LEADERBOARD_VARIANT_OVERRIDES, + isMemesWave, + isCurationWave, + isQuorumWave, + }); + + return { + variant, + ...WAVE_LEADERBOARD_RENDERERS[variant], + }; + }, [isCurationWave, isMemesWave, isQuorumWave, waveId]); +}; diff --git a/components/waves/memes/MemesArtSubmissionModal.tsx b/components/waves/memes/MemesArtSubmissionModal.tsx index a928fdf41a..4f391d0c5a 100644 --- a/components/waves/memes/MemesArtSubmissionModal.tsx +++ b/components/waves/memes/MemesArtSubmissionModal.tsx @@ -1,9 +1,8 @@ "use client"; -import React, { useRef } from "react"; -import { motion, AnimatePresence } from "framer-motion"; +import React, { useEffect, useRef } from "react"; +import { AnimatePresence, LazyMotion, domAnimation, m } from "framer-motion"; import { createPortal } from "react-dom"; -import { useKeyPressEvent } from "react-use"; import type { ApiWave } from "@/generated/models/ApiWave"; import type { ExtendedDrop } from "@/helpers/waves/drop.helpers"; import MemesArtSubmissionContainer from "./submission/MemesArtSubmissionContainer"; @@ -25,14 +24,30 @@ const MemesArtSubmissionModal: React.FC = ({ }) => { const modalRef = useRef(null); - useKeyPressEvent("Escape", () => onClose()); + useEffect(() => { + if (!isOpen) { + return; + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + onClose(); + } + }; + + document.addEventListener("keydown", handleKeyDown); + + return () => { + document.removeEventListener("keydown", handleKeyDown); + }; + }, [isOpen, onClose]); if (!isOpen) return null; return createPortal( - - {isOpen && ( - + + = ({ onClick={onClose} >
- = ({ onSourceDropDeleted={onSourceDropDeleted} />
-
+
- - )} - , + + + , document.body ); }; diff --git a/components/waves/memes/submission/MemesArtResubmitAction.tsx b/components/waves/memes/submission/MemesArtResubmitAction.tsx index d92b18df8d..a4608b8f54 100644 --- a/components/waves/memes/submission/MemesArtResubmitAction.tsx +++ b/components/waves/memes/submission/MemesArtResubmitAction.tsx @@ -142,7 +142,7 @@ function MemesArtResubmitActionWithWave({ const title = disabledReason ?? "Resubmit"; const tooltipId = `resubmit-${drop.id}-${variant}`; - const modal = ( + const modal = isModalOpen ? ( - ); + ) : null; if (variant === "menu") { return ( diff --git a/components/waves/quorum/QuorumParticipationDrop.tsx b/components/waves/quorum/QuorumParticipationDrop.tsx new file mode 100644 index 0000000000..4b4348f3a9 --- /dev/null +++ b/components/waves/quorum/QuorumParticipationDrop.tsx @@ -0,0 +1,20 @@ +"use client"; + +import EndedParticipationDrop from "@/components/waves/drops/participation/EndedParticipationDrop"; +import OngoingParticipationDrop from "@/components/waves/drops/participation/OngoingParticipationDrop"; +import type { ParticipationDropProps } from "@/components/waves/drops/participation/participationRenderer.types"; +import { useDropInteractionRules } from "@/hooks/drops/useDropInteractionRules"; + +export default function QuorumParticipationDrop(props: ParticipationDropProps) { + const { isVotingEnded } = useDropInteractionRules(props.drop); + + if (isVotingEnded) { + return ( + + ); + } + + return ( + + ); +} diff --git a/components/waves/quorum/QuorumProposalCompactContent.tsx b/components/waves/quorum/QuorumProposalCompactContent.tsx new file mode 100644 index 0000000000..40a830c77e --- /dev/null +++ b/components/waves/quorum/QuorumProposalCompactContent.tsx @@ -0,0 +1,159 @@ +"use client"; + +import DropPartMarkdownWithPropLogger from "@/components/drops/view/part/DropPartMarkdownWithPropLogger"; +import type { DropPartMarkdownProps } from "@/components/drops/view/part/DropPartMarkdown"; +import { ChevronRightIcon } from "@heroicons/react/20/solid"; +import { useId, useState } from "react"; +import type { + ParsedQuorumProposalMarkdown, + ParsedQuorumProposalSection, +} from "./quorumProposalMarkdown"; + +type CompactMarkdownProps = Pick< + DropPartMarkdownProps, + | "mentionedUsers" + | "mentionedGroups" + | "mentionedWaves" + | "referencedNfts" + | "nftLinks" + | "onQuoteClick" + | "currentDropId" + | "hideLinkPreviews" + | "quotePath" + | "linkPreviewToggleControl" + | "onLinkCardActionsActiveChange" +>; + +interface QuorumProposalCompactContentProps extends CompactMarkdownProps { + readonly proposal: ParsedQuorumProposalMarkdown; +} + +function stopPropagation(event: { stopPropagation: () => void }): void { + event.stopPropagation(); +} + +function ProposalMarkdownBlock({ + markdown, + markdownProps, +}: Readonly<{ + markdown: string; + markdownProps: CompactMarkdownProps; +}>) { + return ( + + ); +} + +function ProposalSectionCard({ + section, + markdownProps, +}: Readonly<{ + section: ParsedQuorumProposalSection; + markdownProps: CompactMarkdownProps; +}>) { + const [isOpen, setIsOpen] = useState(false); + + return ( +
setIsOpen(event.currentTarget.open)} + className="tw-rounded-xl tw-border tw-border-solid tw-border-iron-800 tw-bg-iron-950/70" + > + + + {section.heading} + + + + {isOpen && ( +
+ +
+ )} +
+ ); +} + +export default function QuorumProposalCompactContent({ + proposal, + ...markdownProps +}: QuorumProposalCompactContentProps) { + const [areDetailsVisible, setAreDetailsVisible] = useState(false); + const detailsContainerId = useId(); + const sectionCount = proposal.sections.length; + const detailsToggleLabel = areDetailsVisible + ? "Hide details" + : `Show details (${sectionCount})`; + + return ( +
+
+

+ Proposal +

+

+ {proposal.title} +

+
+

+ Summary +

+ +
+ {sectionCount > 0 && ( +
+ +
+ )} +
+ + {areDetailsVisible && ( +
+ {proposal.sections.map((section) => ( + + ))} +
+ )} +
+ ); +} diff --git a/components/waves/quorum/quorumProposalMarkdown.ts b/components/waves/quorum/quorumProposalMarkdown.ts index 8a53ea4585..2f46b8cb83 100644 --- a/components/waves/quorum/quorumProposalMarkdown.ts +++ b/components/waves/quorum/quorumProposalMarkdown.ts @@ -18,6 +18,17 @@ export interface QuorumProposalFormValues { readonly risksTradeoffs: string; } +export interface ParsedQuorumProposalSection { + readonly heading: string; + readonly markdown: string; +} + +export interface ParsedQuorumProposalMarkdown { + readonly title: string; + readonly summaryMarkdown: string; + readonly sections: readonly ParsedQuorumProposalSection[]; +} + export const EMPTY_QUORUM_PROPOSAL_FORM_VALUES: QuorumProposalFormValues = { title: "", summary: "", @@ -66,6 +77,540 @@ const normalizeTitle = (title: string): string => { return normalizedTitle.length ? normalizedTitle : "Untitled QUORUM Proposal"; }; +const normalizeSectionHeading = (heading: string): string => + heading.trim().toLowerCase(); + +const QUORUM_PROPOSAL_TOP_LEVEL_HEADINGS = [ + "Summary", + "Problem Statement", + "Proposed Solution", + "Working Spec (Required)", + "Implementation Path", + "Impact & Priority", + "Success Criteria", + "Risks & Trade-offs", +] as const; + +type QuorumProposalTopLevelHeading = + (typeof QUORUM_PROPOSAL_TOP_LEVEL_HEADINGS)[number]; + +const QUORUM_PROPOSAL_TOP_LEVEL_HEADING_SET = + new Set(QUORUM_PROPOSAL_TOP_LEVEL_HEADINGS); + +const QUORUM_PROPOSAL_TOP_LEVEL_HEADING_INDEX = new Map< + QuorumProposalTopLevelHeading, + number +>( + QUORUM_PROPOSAL_TOP_LEVEL_HEADINGS.map( + (heading, index) => [heading, index] as const + ) +); + +const parseTopLevelHeading = (line: string): string | null => { + const headingMatch = /^##\s+(.+?)\s*$/.exec(line); + const heading = headingMatch?.[1]?.trim(); + return heading && heading.length > 0 ? heading : null; +}; + +const parseCanonicalTopLevelHeading = ( + line: string +): QuorumProposalTopLevelHeading | null => { + const heading = parseTopLevelHeading(line); + + if ( + !heading || + !QUORUM_PROPOSAL_TOP_LEVEL_HEADING_SET.has( + heading as QuorumProposalTopLevelHeading + ) + ) { + return null; + } + + return heading as QuorumProposalTopLevelHeading; +}; + +const formatTopLevelHeading = ( + heading: QuorumProposalTopLevelHeading +): string => `## ${heading}`; + +const normalizeMarkdownBlock = (lines: readonly string[]): string => + normalizeMarkdownValue(lines.join("\n")); + +const skipLeadingEmptyLines = (lines: readonly string[]): number => { + let lineIndex = 0; + while (lineIndex < lines.length && lines[lineIndex]?.trim() === "") { + lineIndex++; + } + return lineIndex; +}; + +const pushParsedSection = ( + parsedSections: ParsedQuorumProposalSection[], + heading: string, + lines: readonly string[] +): void => { + parsedSections.push({ + heading, + markdown: normalizeMarkdownBlock(lines), + }); +}; + +interface MarkdownFenceState { + marker: "`" | "~"; + markerLength: number; +} + +interface ParsedMarkdownFenceLine extends MarkdownFenceState { + trailingText: string; +} + +interface QuorumProposalSectionBoundaryCandidate { + heading: QuorumProposalTopLevelHeading; + headingIndex: number; + lineIndex: number; +} + +const parseFenceLine = (line: string): ParsedMarkdownFenceLine | null => { + const fenceMatch = /^\s*([`~]{3,})(.*)$/.exec(line); + const marker = fenceMatch?.[1]; + if (!marker) { + return null; + } + + const fenceCharacter = marker[0]; + if ( + (fenceCharacter !== "`" && fenceCharacter !== "~") || + !marker.split("").every((character) => character === fenceCharacter) + ) { + return null; + } + + return { + marker: fenceCharacter, + markerLength: marker.length, + trailingText: fenceMatch[2] ?? "", + }; +}; + +const getNextFenceState = ( + currentFenceState: MarkdownFenceState | null, + line: string +): MarkdownFenceState | null => { + const parsedFenceState = parseFenceLine(line); + if (!parsedFenceState) { + return currentFenceState; + } + + if (!currentFenceState) { + return { + marker: parsedFenceState.marker, + markerLength: parsedFenceState.markerLength, + }; + } + + return currentFenceState.marker === parsedFenceState.marker && + parsedFenceState.markerLength >= currentFenceState.markerLength && + parsedFenceState.trailingText.trim() === "" + ? null + : currentFenceState; +}; + +const linesAreBlank = ( + lines: readonly string[], + startIndex: number, + endIndex: number +): boolean => { + for (let lineIndex = startIndex; lineIndex < endIndex; lineIndex++) { + if ((lines[lineIndex] ?? "").trim().length > 0) { + return false; + } + } + + return true; +}; + +const collectSectionBoundaryCandidates = ( + lines: readonly string[], + startIndex: number +): QuorumProposalSectionBoundaryCandidate[] => { + const candidates: QuorumProposalSectionBoundaryCandidate[] = []; + let fenceState: MarkdownFenceState | null = null; + + for (let lineIndex = startIndex; lineIndex < lines.length; lineIndex++) { + const line = lines[lineIndex] ?? ""; + + if (!fenceState) { + const heading = parseCanonicalTopLevelHeading(line); + const headingIndex = heading + ? QUORUM_PROPOSAL_TOP_LEVEL_HEADING_INDEX.get(heading) + : undefined; + + if (heading && headingIndex !== undefined) { + candidates.push({ + heading, + headingIndex, + lineIndex, + }); + } + } + + fenceState = getNextFenceState(fenceState, line); + } + + return candidates; +}; + +const compareCandidatePaths = ( + left: readonly QuorumProposalSectionBoundaryCandidate[], + right: readonly QuorumProposalSectionBoundaryCandidate[] +): number => { + if (left.length !== right.length) { + return left.length - right.length; + } + + for (let index = 0; index < left.length; index++) { + const lineDifference = + (left[index]?.lineIndex ?? -1) - (right[index]?.lineIndex ?? -1); + + if (lineDifference !== 0) { + return lineDifference; + } + } + + return 0; +}; + +const collectRequiredFutureHeadingIndexes = ( + candidates: readonly QuorumProposalSectionBoundaryCandidate[], + currentCandidateIndex: number, + nextCandidateIndex: number +): Set | null => { + const currentCandidate = candidates[currentCandidateIndex]; + const nextCandidate = candidates[nextCandidateIndex]; + + if (!currentCandidate || !nextCandidate) { + return null; + } + + const requiredFutureHeadingIndexes = new Set(); + + for ( + let candidateIndex = currentCandidateIndex + 1; + candidateIndex < nextCandidateIndex; + candidateIndex++ + ) { + const candidate = candidates[candidateIndex]; + if (!candidate) { + continue; + } + + if (candidate.headingIndex < currentCandidate.headingIndex) { + return null; + } + + if ( + candidate.headingIndex > currentCandidate.headingIndex && + candidate.headingIndex < nextCandidate.headingIndex + ) { + return null; + } + + if (candidate.headingIndex > nextCandidate.headingIndex) { + requiredFutureHeadingIndexes.add(candidate.headingIndex); + } + } + + return requiredFutureHeadingIndexes; +}; + +const isTerminalCandidatePath = ( + candidates: readonly QuorumProposalSectionBoundaryCandidate[], + lastCandidateIndex: number +): boolean => { + const lastCandidate = candidates[lastCandidateIndex]; + if (!lastCandidate) { + return false; + } + + // Repeated copies of the last chosen heading can stay in the final section + // body, but any different canonical heading still makes the tail ambiguous. + for ( + let candidateIndex = lastCandidateIndex + 1; + candidateIndex < candidates.length; + candidateIndex++ + ) { + const candidate = candidates[candidateIndex]; + if (!candidate) { + continue; + } + + if (candidate.headingIndex !== lastCandidate.headingIndex) { + return false; + } + } + + return true; +}; + +const shouldPruneCandidatePath = ( + candidatePathLength: number, + lastCandidate: QuorumProposalSectionBoundaryCandidate, + bestPath: readonly QuorumProposalSectionBoundaryCandidate[] | null +): boolean => { + if (!bestPath) { + return false; + } + + const maximumPossiblePathLength = + candidatePathLength + + (QUORUM_PROPOSAL_TOP_LEVEL_HEADINGS.length - + lastCandidate.headingIndex - + 1); + + return maximumPossiblePathLength < bestPath.length; +}; + +const materializeCandidatePath = ( + candidates: readonly QuorumProposalSectionBoundaryCandidate[], + candidatePathIndexes: readonly number[] +): readonly QuorumProposalSectionBoundaryCandidate[] => + candidatePathIndexes + .map((index) => candidates[index]) + .filter( + (candidate): candidate is QuorumProposalSectionBoundaryCandidate => + !!candidate + ); + +const updateBestCandidatePath = ( + candidates: readonly QuorumProposalSectionBoundaryCandidate[], + candidatePathIndexes: readonly number[], + requiredFutureHeadingIndexes: ReadonlySet, + lastCandidateIndex: number, + bestPath: readonly QuorumProposalSectionBoundaryCandidate[] | null +): readonly QuorumProposalSectionBoundaryCandidate[] | null => { + if ( + candidatePathIndexes.length < 2 || + requiredFutureHeadingIndexes.size > 0 || + !isTerminalCandidatePath(candidates, lastCandidateIndex) + ) { + return bestPath; + } + + const candidatePath = materializeCandidatePath( + candidates, + candidatePathIndexes + ); + return !bestPath || compareCandidatePaths(candidatePath, bestPath) > 0 + ? candidatePath + : bestPath; +}; + +const buildNextRequiredFutureHeadingIndexes = ( + candidates: readonly QuorumProposalSectionBoundaryCandidate[], + lastCandidateIndex: number, + followingCandidate: QuorumProposalSectionBoundaryCandidate, + followingCandidateIndex: number, + requiredFutureHeadingIndexes: ReadonlySet +): Set | null => { + const gapRequirements = collectRequiredFutureHeadingIndexes( + candidates, + lastCandidateIndex, + followingCandidateIndex + ); + if (!gapRequirements) { + return null; + } + + const nextRequiredFutureHeadingIndexes = new Set( + requiredFutureHeadingIndexes + ); + for (const headingIndex of gapRequirements) { + nextRequiredFutureHeadingIndexes.add(headingIndex); + } + nextRequiredFutureHeadingIndexes.delete(followingCandidate.headingIndex); + + return Array.from(nextRequiredFutureHeadingIndexes).some( + (headingIndex) => headingIndex < followingCandidate.headingIndex + ) + ? null + : nextRequiredFutureHeadingIndexes; +}; + +const selectSectionBoundaryCandidates = ( + lines: readonly string[], + startIndex: number, + candidates: readonly QuorumProposalSectionBoundaryCandidate[] +): readonly QuorumProposalSectionBoundaryCandidate[] | null => { + let bestPath: readonly QuorumProposalSectionBoundaryCandidate[] | null = null; + + const visitCandidatePath = ( + candidatePathIndexes: readonly number[], + nextCandidateIndex: number, + requiredFutureHeadingIndexes: ReadonlySet + ): void => { + const lastCandidateIndex = candidatePathIndexes.at(-1); + if (lastCandidateIndex === undefined) { + return; + } + + const lastCandidate = candidates[lastCandidateIndex]; + + if ( + !lastCandidate || + shouldPruneCandidatePath( + candidatePathIndexes.length, + lastCandidate, + bestPath + ) + ) { + return; + } + + // A skipped canonical heading can stay inside the current section body only + // if a later chosen section still accounts for that heading in order. + bestPath = updateBestCandidatePath( + candidates, + candidatePathIndexes, + requiredFutureHeadingIndexes, + lastCandidateIndex, + bestPath + ); + + for ( + let followingCandidateIndex = nextCandidateIndex; + followingCandidateIndex < candidates.length; + followingCandidateIndex++ + ) { + const followingCandidate = candidates[followingCandidateIndex]; + if ( + !followingCandidate || + followingCandidate.headingIndex <= lastCandidate.headingIndex + ) { + continue; + } + + const nextRequiredFutureHeadingIndexes = + buildNextRequiredFutureHeadingIndexes( + candidates, + lastCandidateIndex, + followingCandidate, + followingCandidateIndex, + requiredFutureHeadingIndexes + ); + if (!nextRequiredFutureHeadingIndexes) { + continue; + } + + visitCandidatePath( + [...candidatePathIndexes, followingCandidateIndex], + followingCandidateIndex + 1, + nextRequiredFutureHeadingIndexes + ); + } + }; + + for ( + let candidateIndex = 0; + candidateIndex < candidates.length; + candidateIndex++ + ) { + const candidate = candidates[candidateIndex]; + if ( + candidate?.heading !== "Summary" || + !linesAreBlank(lines, startIndex, candidate.lineIndex) + ) { + continue; + } + + visitCandidatePath([candidateIndex], candidateIndex + 1, new Set()); + } + + return bestPath; +}; + +const buildParsedSectionsFromCandidates = ( + lines: readonly string[], + startIndex: number, + candidates: readonly QuorumProposalSectionBoundaryCandidate[] +): ParsedQuorumProposalSection[] | null => { + if ( + candidates.length === 0 || + !linesAreBlank(lines, startIndex, candidates[0]?.lineIndex ?? startIndex) + ) { + return null; + } + + const parsedSections: ParsedQuorumProposalSection[] = []; + + for ( + let candidateIndex = 0; + candidateIndex < candidates.length; + candidateIndex++ + ) { + const candidate = candidates[candidateIndex]; + if (!candidate) { + continue; + } + + const nextLineIndex = + candidates[candidateIndex + 1]?.lineIndex ?? lines.length; + pushParsedSection( + parsedSections, + candidate.heading, + lines.slice(candidate.lineIndex + 1, nextLineIndex) + ); + } + + return parsedSections; +}; + +const parseQuorumProposalSections = ( + lines: readonly string[], + startIndex: number +): ParsedQuorumProposalSection[] | null => { + const candidates = collectSectionBoundaryCandidates(lines, startIndex); + const selectedCandidates = selectSectionBoundaryCandidates( + lines, + startIndex, + candidates + ); + + if (!selectedCandidates) { + return null; + } + + return buildParsedSectionsFromCandidates( + lines, + startIndex, + selectedCandidates + ); +}; + +const splitSummarySection = ( + parsedSections: readonly ParsedQuorumProposalSection[] +): Omit | null => { + const summarySection = parsedSections.find( + (section) => normalizeSectionHeading(section.heading) === "summary" + ); + + if (!summarySection) { + return null; + } + + const sections = parsedSections.filter( + (section) => normalizeSectionHeading(section.heading) !== "summary" + ); + + if (sections.length === 0) { + return null; + } + + return { + summaryMarkdown: summarySection.markdown, + sections, + }; +}; + export const hasQuorumProposalContent = ( values: QuorumProposalFormValues ): boolean => @@ -73,6 +618,43 @@ export const hasQuorumProposalContent = ( return values[field].trim().length > 0; }); +export const parseQuorumProposalMarkdown = ( + markdown: string | null | undefined +): ParsedQuorumProposalMarkdown | null => { + if (!markdown?.trim()) { + return null; + } + + const normalizedMarkdown = markdown.replaceAll(/\r\n?/g, "\n"); + const lines = normalizedMarkdown.split("\n"); + const lineIndex = skipLeadingEmptyLines(lines); + + const titleMatch = /^#\s+(.+?)\s*$/.exec(lines[lineIndex]?.trim() ?? ""); + if (!titleMatch) { + return null; + } + + const title = titleMatch[1]; + if (!title) { + return null; + } + + const parsedSections = parseQuorumProposalSections(lines, lineIndex + 1); + if (!parsedSections) { + return null; + } + + const contentSections = splitSummarySection(parsedSections); + if (!contentSections) { + return null; + } + + return { + title: normalizeTitle(title), + ...contentSections, + }; +}; + export const buildQuorumProposalMarkdown = ( values: QuorumProposalFormValues ): string => { @@ -81,19 +663,19 @@ export const buildQuorumProposalMarkdown = ( return [ `# ${normalizeTitle(values.title)}`, "", - "## Summary", + formatTopLevelHeading(QUORUM_PROPOSAL_TOP_LEVEL_HEADINGS[0]), "", normalizeMarkdownValue(values.summary), "", - "## Problem Statement", + formatTopLevelHeading(QUORUM_PROPOSAL_TOP_LEVEL_HEADINGS[1]), "", normalizeMarkdownValue(values.problemStatement), "", - "## Proposed Solution", + formatTopLevelHeading(QUORUM_PROPOSAL_TOP_LEVEL_HEADINGS[2]), "", normalizeMarkdownValue(values.proposedSolution), "", - "## Working Spec (Required)", + formatTopLevelHeading(QUORUM_PROPOSAL_TOP_LEVEL_HEADINGS[3]), "", "### Core features", "", @@ -111,11 +693,11 @@ export const buildQuorumProposalMarkdown = ( "", normalizeMarkdownValue(values.scopeBoundaries), "", - "## Implementation Path", + formatTopLevelHeading(QUORUM_PROPOSAL_TOP_LEVEL_HEADINGS[4]), "", normalizeMarkdownValue(values.implementationPath), "", - "## Impact & Priority", + formatTopLevelHeading(QUORUM_PROPOSAL_TOP_LEVEL_HEADINGS[5]), "", "### Who benefits", "", @@ -129,7 +711,7 @@ export const buildQuorumProposalMarkdown = ( "", urgency, "", - "## Success Criteria", + formatTopLevelHeading(QUORUM_PROPOSAL_TOP_LEVEL_HEADINGS[6]), "", "### Observable outcome", "", @@ -139,7 +721,7 @@ export const buildQuorumProposalMarkdown = ( "", normalizeMarkdownValue(values.measurableSignal), "", - "## Risks & Trade-offs", + formatTopLevelHeading(QUORUM_PROPOSAL_TOP_LEVEL_HEADINGS[7]), "", normalizeMarkdownValue(values.risksTradeoffs), ].join("\n"); diff --git a/components/waves/small-leaderboard/DefaultWaveSmallLeaderboardDrop.tsx b/components/waves/small-leaderboard/DefaultWaveSmallLeaderboardDrop.tsx index 24ef5c1aa7..ad0705dc4d 100644 --- a/components/waves/small-leaderboard/DefaultWaveSmallLeaderboardDrop.tsx +++ b/components/waves/small-leaderboard/DefaultWaveSmallLeaderboardDrop.tsx @@ -1,27 +1,31 @@ import React from "react"; import type { ExtendedDrop } from "@/helpers/waves/drop.helpers"; +import type { DropContentPresentation } from "@/components/waves/drops/dropContentPresentation"; import { WaveSmallLeaderboardTopThreeDrop } from "./WaveSmallLeaderboardTopThreeDrop"; import { WaveSmallLeaderboardDefaultDrop } from "./WaveSmallLeaderboardDefaultDrop"; interface DefaultWaveSmallLeaderboardDropProps { readonly drop: ExtendedDrop; readonly onDropClick: () => void; + readonly contentPresentation?: DropContentPresentation | undefined; } export const DefaultWaveSmallLeaderboardDrop: React.FC< DefaultWaveSmallLeaderboardDropProps -> = ({ drop, onDropClick }) => { +> = ({ drop, onDropClick, contentPresentation = "default" }) => { return (
{typeof drop.rank === "number" && drop.rank <= 3 ? ( ) : ( )}
diff --git a/components/waves/small-leaderboard/QuorumWaveSmallLeaderboardDrop.tsx b/components/waves/small-leaderboard/QuorumWaveSmallLeaderboardDrop.tsx new file mode 100644 index 0000000000..ae718c6c54 --- /dev/null +++ b/components/waves/small-leaderboard/QuorumWaveSmallLeaderboardDrop.tsx @@ -0,0 +1,20 @@ +import React from "react"; +import type { ExtendedDrop } from "@/helpers/waves/drop.helpers"; +import { DefaultWaveSmallLeaderboardDrop } from "./DefaultWaveSmallLeaderboardDrop"; + +interface QuorumWaveSmallLeaderboardDropProps { + readonly drop: ExtendedDrop; + readonly onDropClick: () => void; +} + +export const QuorumWaveSmallLeaderboardDrop: React.FC< + QuorumWaveSmallLeaderboardDropProps +> = ({ drop, onDropClick }) => { + return ( + + ); +}; diff --git a/components/waves/small-leaderboard/WaveSmallLeaderboardDefaultDrop.tsx b/components/waves/small-leaderboard/WaveSmallLeaderboardDefaultDrop.tsx index 25d651947e..98baa59b10 100644 --- a/components/waves/small-leaderboard/WaveSmallLeaderboardDefaultDrop.tsx +++ b/components/waves/small-leaderboard/WaveSmallLeaderboardDefaultDrop.tsx @@ -1,9 +1,12 @@ import React from "react"; import type { ExtendedDrop } from "@/helpers/waves/drop.helpers"; import Link from "next/link"; +import Image from "next/image"; +import type { DropContentPresentation } from "@/components/waves/drops/dropContentPresentation"; import { CICType } from "@/entities/IProfile"; import { cicToType, formatNumberWithCommas } from "@/helpers/Helpers"; import { assertUnreachable } from "@/helpers/AllowlistToolHelpers"; +import { getScaledImageUri, ImageScale } from "@/helpers/image.helpers"; import { WaveSmallLeaderboardItemContent } from "./WaveSmallLeaderboardItemContent"; import { WaveSmallLeaderboardItemOutcomes } from "./WaveSmallLeaderboardItemOutcomes"; import WaveDropActionsRate from "../drops/WaveDropActionsRate"; @@ -14,11 +17,14 @@ import UserProfileTooltipWrapper from "@/components/utils/tooltip/UserProfileToo interface WaveSmallLeaderboardDefaultDropProps { readonly drop: ExtendedDrop; readonly onDropClick: () => void; + readonly contentPresentation?: DropContentPresentation | undefined; } export const WaveSmallLeaderboardDefaultDrop: React.FC< WaveSmallLeaderboardDefaultDropProps -> = ({ drop, onDropClick }) => { +> = ({ drop, onDropClick, contentPresentation = "default" }) => { + const authorLabel = drop.author.handle ?? drop.author.primary_address; + const getCICColor = (cic: number): string => { const cicType = cicToType(cic); switch (cicType) { @@ -75,17 +81,25 @@ export const WaveSmallLeaderboardDefaultDrop: React.FC<
{drop.author.pfp ? ( - ) : (
diff --git a/components/waves/small-leaderboard/WaveSmallLeaderboardDrop.tsx b/components/waves/small-leaderboard/WaveSmallLeaderboardDrop.tsx index 7ec2e922c2..c9b19fd655 100644 --- a/components/waves/small-leaderboard/WaveSmallLeaderboardDrop.tsx +++ b/components/waves/small-leaderboard/WaveSmallLeaderboardDrop.tsx @@ -1,9 +1,7 @@ import React from "react"; import type { ExtendedDrop } from "@/helpers/waves/drop.helpers"; import type { ApiWave } from "@/generated/models/ApiWave"; -import { useWave } from "@/hooks/useWave"; -import { MemesWaveSmallLeaderboardDrop } from "./MemesWaveSmallLeaderboardDrop"; -import { DefaultWaveSmallLeaderboardDrop } from "./DefaultWaveSmallLeaderboardDrop"; +import { useWaveLeaderboardRendererSet } from "../leaderboard/leaderboardRendererRegistry"; interface WaveSmallLeaderboardDropProps { readonly drop: ExtendedDrop; @@ -14,13 +12,7 @@ interface WaveSmallLeaderboardDropProps { export const WaveSmallLeaderboardDrop: React.FC< WaveSmallLeaderboardDropProps > = ({ drop, wave, onDropClick }) => { - const { isMemesWave } = useWave(wave); - if (isMemesWave) { - return ( - - ); - } - return ( - - ); + const { SmallLeaderboardDrop } = useWaveLeaderboardRendererSet(wave.id); + + return ; }; diff --git a/components/waves/small-leaderboard/WaveSmallLeaderboardItemContent.tsx b/components/waves/small-leaderboard/WaveSmallLeaderboardItemContent.tsx index 1a249c9e6f..5fef0623da 100644 --- a/components/waves/small-leaderboard/WaveSmallLeaderboardItemContent.tsx +++ b/components/waves/small-leaderboard/WaveSmallLeaderboardItemContent.tsx @@ -5,8 +5,10 @@ import { ExtendedDrop, getDropPreviewImageUrl, } from "@/helpers/waves/drop.helpers"; +import type { DropContentPresentation } from "@/components/waves/drops/dropContentPresentation"; import { useEffect, useMemo, useRef, useState } from "react"; import { Tooltip } from "react-tooltip"; +import Image from "next/image"; import WaveDropPartContentMedias from "../drops/WaveDropPartContentMedias"; import WaveDropPartContentMarkdown from "../drops/WaveDropPartContentMarkdown"; import { ImageScale, getScaledImageUri } from "@/helpers/image.helpers"; @@ -14,11 +16,12 @@ import { ImageScale, getScaledImageUri } from "@/helpers/image.helpers"; interface WaveSmallLeaderboardItemContentProps { readonly drop: ExtendedDrop; readonly onDropClick: () => void; + readonly contentPresentation?: DropContentPresentation | undefined; } export const WaveSmallLeaderboardItemContent: React.FC< WaveSmallLeaderboardItemContentProps -> = ({ drop, onDropClick }) => { +> = ({ drop, onDropClick, contentPresentation = "default" }) => { const contentRef = useRef(null); const [showGradient, setShowGradient] = useState(false); @@ -46,11 +49,15 @@ export const WaveSmallLeaderboardItemContent: React.FC< > {previewImageUrl ? (
- Preview +
+ Preview image +
) : ( !!drop.parts[0]?.media.length && ( @@ -70,6 +77,7 @@ export const WaveSmallLeaderboardItemContent: React.FC< wave={drop.wave} drop={drop} onQuoteClick={() => {}} + contentPresentation={contentPresentation} /> {showGradient && (
diff --git a/components/waves/small-leaderboard/WaveSmallLeaderboardTopThreeDrop.tsx b/components/waves/small-leaderboard/WaveSmallLeaderboardTopThreeDrop.tsx index 0c15173d8a..4a95fa1038 100644 --- a/components/waves/small-leaderboard/WaveSmallLeaderboardTopThreeDrop.tsx +++ b/components/waves/small-leaderboard/WaveSmallLeaderboardTopThreeDrop.tsx @@ -1,9 +1,12 @@ import React from "react"; import type { ExtendedDrop } from "@/helpers/waves/drop.helpers"; import Link from "next/link"; +import Image from "next/image"; +import type { DropContentPresentation } from "@/components/waves/drops/dropContentPresentation"; import { cicToType, formatNumberWithCommas } from "@/helpers/Helpers"; import { CICType } from "@/entities/IProfile"; import { assertUnreachable } from "@/helpers/AllowlistToolHelpers"; +import { getScaledImageUri, ImageScale } from "@/helpers/image.helpers"; import { WaveSmallLeaderboardItemContent } from "./WaveSmallLeaderboardItemContent"; import { WaveSmallLeaderboardItemOutcomes } from "./WaveSmallLeaderboardItemOutcomes"; import WinnerDropBadge from "../drops/winner/WinnerDropBadge"; @@ -13,11 +16,14 @@ import UserProfileTooltipWrapper from "@/components/utils/tooltip/UserProfileToo interface WaveSmallLeaderboardTopThreeDropProps { readonly drop: ExtendedDrop; readonly onDropClick: () => void; + readonly contentPresentation?: DropContentPresentation | undefined; } export const WaveSmallLeaderboardTopThreeDrop: React.FC< WaveSmallLeaderboardTopThreeDropProps -> = ({ drop, onDropClick }) => { +> = ({ drop, onDropClick, contentPresentation = "default" }) => { + const authorLabel = drop.author.handle ?? drop.author.primary_address; + const getRankTextColor = (rank: number | null): string | null => { if (rank === 1) return "tw-text-[#E8D48A]"; if (rank === 2) return "tw-text-[#DDDDDD]"; @@ -100,18 +106,26 @@ export const WaveSmallLeaderboardTopThreeDrop: React.FC<
e.stopPropagation()} className="tw-flex tw-items-center tw-gap-x-2 tw-no-underline" > {drop.author.pfp ? ( - {drop.author.handle ) : (
diff --git a/helpers/waves/wave-participation-presentation.helpers.ts b/helpers/waves/wave-participation-presentation.helpers.ts new file mode 100644 index 0000000000..99fe2ccad4 --- /dev/null +++ b/helpers/waves/wave-participation-presentation.helpers.ts @@ -0,0 +1,52 @@ +import { normalizeOptionalWaveId } from "./wave.helpers"; + +export type WaveParticipationVariant = + | "default" + | "memes" + | "curation" + | "quorum"; + +const DEFAULT_WAVE_PARTICIPATION_VARIANT_OVERRIDES: Readonly< + Partial> +> = {}; + +export const resolveWaveParticipationVariant = ({ + waveId, + overrides = DEFAULT_WAVE_PARTICIPATION_VARIANT_OVERRIDES, + isMemesWave, + isCurationWave, + isQuorumWave, +}: { + readonly waveId: string | null | undefined; + readonly overrides?: + | Readonly>> + | undefined; + readonly isMemesWave: (waveId: string | undefined | null) => boolean; + readonly isCurationWave: (waveId: string | undefined | null) => boolean; + readonly isQuorumWave: (waveId: string | undefined | null) => boolean; +}): WaveParticipationVariant => { + const normalizedWaveId = normalizeOptionalWaveId(waveId); + + if (!normalizedWaveId) { + return "default"; + } + + const overrideVariant = overrides[normalizedWaveId]; + if (overrideVariant) { + return overrideVariant; + } + + if (isMemesWave(normalizedWaveId)) { + return "memes"; + } + + if (isCurationWave(normalizedWaveId)) { + return "curation"; + } + + if (isQuorumWave(normalizedWaveId)) { + return "quorum"; + } + + return "default"; +};