diff --git a/__tests__/components/drops/view/part/dropPartMarkdown/handlers/seize.test.tsx b/__tests__/components/drops/view/part/dropPartMarkdown/handlers/seize.test.tsx index ef7769a4e2..4cf2349d18 100644 --- a/__tests__/components/drops/view/part/dropPartMarkdown/handlers/seize.test.tsx +++ b/__tests__/components/drops/view/part/dropPartMarkdown/handlers/seize.test.tsx @@ -2,7 +2,10 @@ import { render, screen } from "@testing-library/react"; import React from "react"; import { createSeizeHandlers } from "@/components/drops/view/part/dropPartMarkdown/handlers/seize"; -import { parseSeizeDropLink } from "@/helpers/SeizeLinkParser"; +import { + parseSeizeDropLink, + parseSeizeQuoteLink, +} from "@/helpers/SeizeLinkParser"; const mockDropItemChat = jest.fn( ({ href, dropId }: { href: string; dropId: string }) => ( @@ -13,6 +16,13 @@ const mockDropItemChat = jest.fn( const mockRenderSeizeQuote = jest.fn(() => (
)); +const mockQuorumParticipationDropLinkPreview = jest.fn((props: any) => ( +
+)); jest.mock("@/helpers/SeizeLinkParser", () => ({ parseSeizeDropLink: jest.fn(), @@ -26,6 +36,14 @@ jest.mock("@/components/waves/drops/DropItemChat", () => ({ default: (props: any) => mockDropItemChat(props), })); +jest.mock( + "@/components/waves/quorum/QuorumParticipationDropLinkPreview", + () => ({ + __esModule: true, + default: (props: any) => mockQuorumParticipationDropLinkPreview(props), + }) +); + jest.mock("@/components/drops/view/part/dropPartMarkdown/renderers", () => ({ renderSeizeQuote: (...args: any[]) => mockRenderSeizeQuote(...args), })); @@ -33,13 +51,19 @@ jest.mock("@/components/drops/view/part/dropPartMarkdown/renderers", () => ({ const mockedParseSeizeDropLink = parseSeizeDropLink as jest.MockedFunction< typeof parseSeizeDropLink >; +const mockedParseSeizeQuoteLink = parseSeizeQuoteLink as jest.MockedFunction< + typeof parseSeizeQuoteLink +>; -const getDropHandler = (options?: { +const getHandlers = (options?: { readonly onQuoteClick?: ((drop: any) => void) | undefined; readonly currentDropId?: string | undefined; readonly isMemesWaveById?: | ((waveId: string | undefined | null) => boolean) | undefined; + readonly isQuorumWaveById?: + | ((waveId: string | undefined | null) => boolean) + | undefined; }) => createSeizeHandlers({ onQuoteClick: options?.onQuoteClick ?? jest.fn(), @@ -49,11 +73,20 @@ const getDropHandler = (options?: { embedDepth: 0, maxEmbedDepth: 4, isMemesWaveById: options?.isMemesWaveById, - })[3]; + isQuorumWaveById: options?.isQuorumWaveById, + }); + +const getDropHandler = (options?: Parameters[0]) => + getHandlers(options)[3]!; + +const getQuoteHandler = (options?: Parameters[0]) => + getHandlers(options)[0]!; describe("createSeizeHandlers drop handler", () => { beforeEach(() => { jest.clearAllMocks(); + mockedParseSeizeDropLink.mockReturnValue(null); + mockedParseSeizeQuoteLink.mockReturnValue(null); }); it("keeps DropItemChat rendering for memes waves", () => { @@ -77,6 +110,42 @@ describe("createSeizeHandlers drop handler", () => { expect(mockRenderSeizeQuote).not.toHaveBeenCalled(); }); + it("renders quorum participation preview for quorum drop links", () => { + const onQuoteClick = jest.fn(); + mockedParseSeizeDropLink.mockReturnValue({ + waveId: "quorum-wave-id", + dropId: "drop-1", + }); + const handler = getDropHandler({ + onQuoteClick, + isMemesWaveById: () => false, + isQuorumWaveById: (waveId) => waveId === "quorum-wave-id", + }); + + const href = "https://site.com/waves/quorum-wave-id?drop=drop-1"; + const element = handler.render(href); + render(<>{element}); + + expect(screen.getByTestId("quorum-participation-preview")).toHaveAttribute( + "data-drop-id", + "drop-1" + ); + expect(mockQuorumParticipationDropLinkPreview).toHaveBeenCalledWith( + expect.objectContaining({ + href, + waveId: "quorum-wave-id", + dropId: "drop-1", + onQuoteClick, + embedPath: [], + quotePath: [], + embedDepth: 1, + maxEmbedDepth: 4, + }) + ); + expect(mockRenderSeizeQuote).not.toHaveBeenCalled(); + expect(mockDropItemChat).not.toHaveBeenCalled(); + }); + it("renders quote-style preview for non-memes waves", () => { const onQuoteClick = jest.fn(); mockedParseSeizeDropLink.mockReturnValue({ @@ -143,4 +212,39 @@ describe("createSeizeHandlers drop handler", () => { handler.render("https://site.com/waves/normal-wave-id?drop=drop-4") ).toThrow("Seize drop link matches current drop"); }); + + it("renders quorum participation preview for quorum serial links", () => { + const onQuoteClick = jest.fn(); + mockedParseSeizeQuoteLink.mockReturnValue({ + waveId: "quorum-wave-id", + serialNo: "7", + }); + const handler = getQuoteHandler({ + onQuoteClick, + isQuorumWaveById: (waveId) => waveId === "quorum-wave-id", + }); + + const href = "https://site.com/waves/quorum-wave-id?serialNo=7"; + const element = handler.render(href); + render(<>{element}); + + expect(screen.getByTestId("quorum-participation-preview")).toHaveAttribute( + "data-serial-no", + "7" + ); + expect(mockQuorumParticipationDropLinkPreview).toHaveBeenCalledWith( + expect.objectContaining({ + href, + waveId: "quorum-wave-id", + serialNo: "7", + onQuoteClick, + embedPath: [], + quotePath: ["quorum-wave-id:7"], + embedDepth: 1, + maxEmbedDepth: 4, + hideLink: true, + }) + ); + expect(mockRenderSeizeQuote).not.toHaveBeenCalled(); + }); }); diff --git a/__tests__/components/waves/drops/WaveDropActionsCopyLink.test.tsx b/__tests__/components/waves/drops/WaveDropActionsCopyLink.test.tsx index c456e42e8b..aca753bcdc 100644 --- a/__tests__/components/waves/drops/WaveDropActionsCopyLink.test.tsx +++ b/__tests__/components/waves/drops/WaveDropActionsCopyLink.test.tsx @@ -4,6 +4,7 @@ import "@testing-library/jest-dom"; import { fireEvent, render } from "@testing-library/react"; const mockIsMemesWave = jest.fn(); +const mockIsQuorumWave = jest.fn(); jest.mock("@/config/env", () => ({ publicEnv: { @@ -11,7 +12,10 @@ jest.mock("@/config/env", () => ({ }, })); jest.mock("@/contexts/SeizeSettingsContext", () => ({ - useSeizeSettings: () => ({ isMemesWave: mockIsMemesWave }), + useSeizeSettings: () => ({ + isMemesWave: mockIsMemesWave, + isQuorumWave: mockIsQuorumWave, + }), })); const writeText = jest.fn().mockResolvedValue(undefined); @@ -21,6 +25,7 @@ describe("WaveDropActionsCopyLink", () => { beforeEach(() => { jest.clearAllMocks(); mockIsMemesWave.mockReturnValue(false); + mockIsQuorumWave.mockReturnValue(false); }); it("copies serial jump links for non-memes drops", () => { @@ -49,6 +54,20 @@ describe("WaveDropActionsCopyLink", () => { expect(writeText).toHaveBeenCalledWith("https://base/waves/w1?drop=d1"); }); + it("copies canonical drop links for quorum participation drops", () => { + mockIsQuorumWave.mockReturnValue(true); + + const drop: any = { + id: "d1", + wave: { id: "w1" }, + serial_no: 5, + drop_type: ApiDropType.Participatory, + }; + const { getByRole } = render(); + fireEvent.click(getByRole("button")); + expect(writeText).toHaveBeenCalledWith("https://base/waves/w1?drop=d1"); + }); + it("disables button for temporary drop", () => { const drop: any = { id: "temp-1", wave: { id: "w1" }, serial_no: 1 }; const { getByRole } = render(); diff --git a/__tests__/components/waves/drops/WaveDropMobileMenu.test.tsx b/__tests__/components/waves/drops/WaveDropMobileMenu.test.tsx index a5765307f1..dc875e9a4e 100644 --- a/__tests__/components/waves/drops/WaveDropMobileMenu.test.tsx +++ b/__tests__/components/waves/drops/WaveDropMobileMenu.test.tsx @@ -7,6 +7,7 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; const mockIsMemesWave = jest.fn(); +const mockIsQuorumWave = jest.fn(); const writeText = jest.fn().mockResolvedValue(undefined); const addReactionMock = jest.fn((props: any) => (
jest.mock("@/hooks/drops/useDropInteractionRules", () => ({ useDropInteractionRules: jest.fn(), })); +jest.mock("@/hooks/drops/useCanShowDropCurationsAction", () => ({ + useCanShowDropCurationsAction: jest.fn(() => false), +})); jest.mock("@/components/waves/drops/WaveDropMobileMenuDelete", () => () => (
)); @@ -60,7 +64,10 @@ jest.mock( ); jest.mock("@/contexts/SeizeSettingsContext", () => ({ - useSeizeSettings: () => ({ isMemesWave: mockIsMemesWave }), + useSeizeSettings: () => ({ + isMemesWave: mockIsMemesWave, + isQuorumWave: mockIsQuorumWave, + }), })); jest.mock("@/contexts/EmojiContext", () => ({ useEmoji: () => ({ @@ -90,6 +97,7 @@ beforeEach(() => { addReactionMock.mockClear(); mobileWrapperMock.mockClear(); mockIsMemesWave.mockReturnValue(false); + mockIsQuorumWave.mockReturnValue(false); mockedUseDropInteractionRules.mockReturnValue({ canShowVote: true, canVote: true, @@ -168,6 +176,40 @@ test("copies canonical drop links for memes submissions", async () => { expect(writeText).toHaveBeenCalledWith("https://base/waves/w?drop=1"); }); +test("copies canonical drop links for quorum participation drops", async () => { + mockIsQuorumWave.mockReturnValue(true); + + const drop = { + id: "1", + serial_no: 1, + wave: { id: "w" }, + drop_type: ApiDropType.Participatory, + author: { handle: "alice" }, + } as any; + render( + + + + ); + await userEvent.click(screen.getByText("Copy link")); + expect(writeText).toHaveBeenCalledWith("https://base/waves/w?drop=1"); +}); + test("hides follow and clap when author and memes wave", () => { mockIsMemesWave.mockReturnValue(true); diff --git a/__tests__/components/waves/quorum/QuorumParticipationDropLinkPreview.test.tsx b/__tests__/components/waves/quorum/QuorumParticipationDropLinkPreview.test.tsx new file mode 100644 index 0000000000..1fc80e59be --- /dev/null +++ b/__tests__/components/waves/quorum/QuorumParticipationDropLinkPreview.test.tsx @@ -0,0 +1,144 @@ +import { waitFor } from "@testing-library/react"; + +import QuorumParticipationDropLinkPreview from "@/components/waves/quorum/QuorumParticipationDropLinkPreview"; +import { ApiDropType } from "@/generated/models/ApiDropType"; +import { commonApiFetch } from "@/services/api/common-api"; +import { renderWithQueryClient } from "../../../utils/reactQuery"; + +const mockQuorumParticipationDrop = jest.fn(() => ( +
+)); +const mockWaveDropQuote = jest.fn(() =>
); +const mockLinkHandlerFrame = jest.fn(({ children }: any) => ( +
{children}
+)); + +jest.mock("@/services/api/common-api", () => ({ + commonApiFetch: jest.fn(), +})); + +jest.mock("@/components/waves/quorum/QuorumParticipationDrop", () => ({ + __esModule: true, + default: (props: any) => mockQuorumParticipationDrop(props), +})); + +jest.mock("@/components/waves/drops/WaveDropQuote", () => ({ + __esModule: true, + default: (props: any) => mockWaveDropQuote(props), +})); + +jest.mock("@/components/waves/LinkHandlerFrame", () => ({ + __esModule: true, + default: (props: any) => mockLinkHandlerFrame(props), +})); + +const commonApiFetchMock = commonApiFetch as jest.MockedFunction< + typeof commonApiFetch +>; + +const buildDrop = (overrides: Record = {}) => + ({ + id: "drop-1", + serial_no: 7, + drop_type: ApiDropType.Participatory, + wave: { id: "quorum-wave", name: "Quorum" }, + reply_to: null, + author: { handle: "alice" }, + title: "Proposal", + parts: [{ part_id: 1, content: "Proposal body", media: [] }], + metadata: [], + created_at: 1000, + rank: null, + ...overrides, + }) as any; + +describe("QuorumParticipationDropLinkPreview", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("loads a drop id and renders the quorum participation design", async () => { + const drop = buildDrop(); + commonApiFetchMock.mockResolvedValue(drop); + + renderWithQueryClient( + + ); + + await waitFor(() => { + expect(mockQuorumParticipationDrop).toHaveBeenCalledWith( + expect.objectContaining({ + drop: expect.objectContaining({ id: "drop-1" }), + showInteractions: false, + showReplyAndQuote: false, + }) + ); + }); + expect(commonApiFetchMock).toHaveBeenCalledWith({ + endpoint: "drops/drop-1", + }); + }); + + it("loads a serial number and renders the quorum participation design", async () => { + const drop = buildDrop(); + commonApiFetchMock.mockResolvedValue({ + drops: [drop], + wave: { id: "quorum-wave", name: "Quorum" }, + }); + + renderWithQueryClient( + + ); + + await waitFor(() => { + expect(mockQuorumParticipationDrop).toHaveBeenCalledWith( + expect.objectContaining({ + drop: expect.objectContaining({ id: "drop-1" }), + showInteractions: false, + }) + ); + }); + expect(commonApiFetchMock).toHaveBeenCalledWith({ + endpoint: "waves/quorum-wave/drops", + params: { + limit: "1", + serial_no_limit: "7", + search_strategy: "FIND_BOTH", + }, + }); + }); + + it("falls back to the normal quote preview for non-participatory drops", async () => { + const drop = buildDrop({ drop_type: ApiDropType.Chat }); + commonApiFetchMock.mockResolvedValue(drop); + + renderWithQueryClient( + + ); + + await waitFor(() => { + expect(mockWaveDropQuote).toHaveBeenLastCalledWith( + expect.objectContaining({ + drop: expect.objectContaining({ drop_type: ApiDropType.Chat }), + partId: 1, + }) + ); + }); + expect(mockQuorumParticipationDrop).not.toHaveBeenCalled(); + }); +}); diff --git a/components/drops/view/part/DropPartMarkdown.tsx b/components/drops/view/part/DropPartMarkdown.tsx index 6a89a36696..c9ec66183f 100644 --- a/components/drops/view/part/DropPartMarkdown.tsx +++ b/components/drops/view/part/DropPartMarkdown.tsx @@ -47,6 +47,8 @@ import { const BreakComponent = () =>
; +const EMPTY_MENTIONED_GROUPS: ApiDropGroupMention[] = []; + const mergeClassNames = (...classes: Array): string => classes.filter(Boolean).join(" "); @@ -268,7 +270,7 @@ export interface DropPartMarkdownProps { function DropPartMarkdown({ mentionedUsers, - mentionedGroups = [], + mentionedGroups = EMPTY_MENTIONED_GROUPS, mentionedWaves, referencedNfts, nftLinks, @@ -330,6 +332,7 @@ function DropPartMarkdown({ hideLinkPreviews, tweetPreviewMode, isMemesWaveById: seizeSettings?.isMemesWave, + isQuorumWaveById: seizeSettings?.isQuorumWave, embedPath: normalizedEmbedPath, quotePath: normalizedQuotePath, embedDepth, @@ -341,6 +344,7 @@ function DropPartMarkdown({ hideLinkPreviews, tweetPreviewMode, seizeSettings?.isMemesWave, + seizeSettings?.isQuorumWave, normalizedEmbedPath, normalizedQuotePath, embedDepth, diff --git a/components/drops/view/part/dropPartMarkdown/handlers/seize.tsx b/components/drops/view/part/dropPartMarkdown/handlers/seize.tsx index 6bc0b702c6..882f98e782 100644 --- a/components/drops/view/part/dropPartMarkdown/handlers/seize.tsx +++ b/components/drops/view/part/dropPartMarkdown/handlers/seize.tsx @@ -12,6 +12,7 @@ import { import GroupCardChat from "@/components/groups/page/list/card/GroupCardChat"; import DropItemChat from "@/components/waves/drops/DropItemChat"; import WaveItemChat from "@/components/waves/list/WaveItemChat"; +import QuorumParticipationDropLinkPreview from "@/components/waves/quorum/QuorumParticipationDropLinkPreview"; import type { LinkHandler } from "../linkTypes"; import { renderSeizeQuote } from "../renderers"; @@ -25,6 +26,9 @@ interface CreateSeizeHandlersConfig { readonly isMemesWaveById?: | ((waveId: string | undefined | null) => boolean) | undefined; + readonly isQuorumWaveById?: + | ((waveId: string | undefined | null) => boolean) + | undefined; } type SeizeGuardConfig = Omit; @@ -74,6 +78,22 @@ const createSeizeQuoteHandler = ( ? [...config.quotePath, quoteCycleKey] : config.quotePath; + if (config.isQuorumWaveById?.(quoteInfo.waveId)) { + return ( + + ); + } + const content = renderSeizeQuote(quoteInfo, onQuoteClick, href, { embedPath: config.embedPath, quotePath: nextQuotePath, @@ -153,6 +173,22 @@ const createSeizeDropHandler = ( throw new Error("Seize drop link matches current drop"); } const isMemesWave = config.isMemesWaveById?.(waveId) ?? false; + const isQuorumWave = config.isQuorumWaveById?.(waveId) ?? false; + + if (!isMemesWave && isQuorumWave && waveId) { + return ( + + ); + } if (!isMemesWave && waveId) { const content = renderSeizeQuote( diff --git a/components/drops/view/part/dropPartMarkdown/linkHandlers.tsx b/components/drops/view/part/dropPartMarkdown/linkHandlers.tsx index f729a7f7bd..dca11cbafb 100644 --- a/components/drops/view/part/dropPartMarkdown/linkHandlers.tsx +++ b/components/drops/view/part/dropPartMarkdown/linkHandlers.tsx @@ -32,6 +32,9 @@ interface LinkRendererConfig { readonly isMemesWaveById?: | ((waveId: string | undefined | null) => boolean) | undefined; + readonly isQuorumWaveById?: + | ((waveId: string | undefined | null) => boolean) + | undefined; readonly embedPath?: readonly string[] | undefined; readonly quotePath?: readonly string[] | undefined; readonly embedDepth?: number | undefined; @@ -74,6 +77,7 @@ export const createLinkRenderer = ({ hideLinkPreviews = false, tweetPreviewMode = "auto", isMemesWaveById, + isQuorumWaveById, embedPath, quotePath, embedDepth = 0, @@ -88,6 +92,7 @@ export const createLinkRenderer = ({ embedDepth, maxEmbedDepth, isMemesWaveById, + isQuorumWaveById, }); const handlers = createLinkHandlers({ tweetPreviewMode, diff --git a/components/waves/drops/WaveDropActionsCopyLink.tsx b/components/waves/drops/WaveDropActionsCopyLink.tsx index 9e3a561ebd..a962a8f595 100644 --- a/components/waves/drops/WaveDropActionsCopyLink.tsx +++ b/components/waves/drops/WaveDropActionsCopyLink.tsx @@ -20,7 +20,7 @@ const WaveDropActionsCopyLink: React.FC = ({ onCopy, }) => { const [copied, setCopied] = useState(false); - const { isMemesWave } = useSeizeSettings(); + const { isMemesWave, isQuorumWave } = useSeizeSettings(); const myStream = useMyStreamOptional(); const directMessageWaves = myStream?.directMessages.list ?? []; @@ -54,6 +54,7 @@ const WaveDropActionsCopyLink: React.FC = ({ drop, isDirectMessage, isMemesWave, + isQuorumWave, }); navigator.clipboard.writeText(dropLink).then(() => { setCopied(true); diff --git a/components/waves/drops/WaveDropContent.tsx b/components/waves/drops/WaveDropContent.tsx index 72c192fdc8..c5752835a2 100644 --- a/components/waves/drops/WaveDropContent.tsx +++ b/components/waves/drops/WaveDropContent.tsx @@ -36,6 +36,10 @@ interface WaveDropContentProps { | ((href: string, active: boolean) => void) | undefined; readonly contentPresentation?: DropContentPresentation | undefined; + readonly embedPath?: readonly string[] | undefined; + readonly quotePath?: readonly string[] | undefined; + readonly embedDepth?: number | undefined; + readonly maxEmbedDepth?: number | undefined; } const WaveDropContent: React.FC = ({ @@ -56,6 +60,10 @@ const WaveDropContent: React.FC = ({ hasTouch, onLinkCardActionsActiveChange, contentPresentation = "default", + embedPath, + quotePath, + embedDepth, + maxEmbedDepth, }) => { const isTouchDevice = useIsTouchDevice(); const effectiveHasTouch = hasTouch ?? isTouchDevice; @@ -79,6 +87,10 @@ const WaveDropContent: React.FC = ({ hasTouch={effectiveHasTouch} onLinkCardActionsActiveChange={onLinkCardActionsActiveChange} contentPresentation={contentPresentation} + embedPath={embedPath} + quotePath={quotePath} + embedDepth={embedDepth} + maxEmbedDepth={maxEmbedDepth} /> ); }; diff --git a/components/waves/drops/WaveDropMobileMenu.tsx b/components/waves/drops/WaveDropMobileMenu.tsx index dddf5f35fe..f19fb9e8e2 100644 --- a/components/waves/drops/WaveDropMobileMenu.tsx +++ b/components/waves/drops/WaveDropMobileMenu.tsx @@ -70,12 +70,14 @@ const getIsDirectMessage = (drop: ApiDrop): boolean => { const copyDropLinkToClipboard = ({ drop, isMemesWave, + isQuorumWave, isTemporaryDrop, closeMenu, setCopied, }: { readonly drop: ApiDrop; readonly isMemesWave: (waveId: string | null | undefined) => boolean; + readonly isQuorumWave: (waveId: string | null | undefined) => boolean; readonly isTemporaryDrop: boolean; readonly closeMenu: () => void; readonly setCopied: (copied: boolean) => void; @@ -88,6 +90,7 @@ const copyDropLinkToClipboard = ({ drop, isDirectMessage: getIsDirectMessage(drop), isMemesWave, + isQuorumWave, }); if (typeof navigator.clipboard.writeText !== "function") { @@ -339,7 +342,7 @@ const WaveDropMobileMenu: FC = ({ showCopyOption = true, }) => { const { connectedProfile, activeProfileProxy } = useContext(AuthContext); - const { isMemesWave } = useSeizeSettings(); + const { isMemesWave, isQuorumWave } = useSeizeSettings(); const isTemporaryDrop = drop.id.startsWith("temp-"); const { canDelete, canSetPinnedDrop } = useDropInteractionRules(drop); const { mobileMenuZIndexClassName, mobileDialogZIndexClassName } = @@ -371,6 +374,7 @@ const WaveDropMobileMenu: FC = ({ copyDropLinkToClipboard({ drop, isMemesWave, + isQuorumWave, isTemporaryDrop, closeMenu, setCopied, diff --git a/components/waves/drops/WaveDropPart.tsx b/components/waves/drops/WaveDropPart.tsx index a30987c57e..0c1bf1a455 100644 --- a/components/waves/drops/WaveDropPart.tsx +++ b/components/waves/drops/WaveDropPart.tsx @@ -37,6 +37,10 @@ interface WaveDropPartProps { | ((href: string, active: boolean) => void) | undefined; readonly contentPresentation?: DropContentPresentation | undefined; + readonly embedPath?: readonly string[] | undefined; + readonly quotePath?: readonly string[] | undefined; + readonly embedDepth?: number | undefined; + readonly maxEmbedDepth?: number | undefined; } const LONG_PRESS_DURATION = 500; // milliseconds @@ -61,6 +65,10 @@ const WaveDropPart: React.FC = memo( hasTouch = false, onLinkCardActionsActiveChange, contentPresentation = "default", + embedPath, + quotePath, + embedDepth, + maxEmbedDepth, }) => { const activePart = drop.parts[activePartIndex]; @@ -160,6 +168,10 @@ const WaveDropPart: React.FC = memo( fullWidthMedia={fullWidthMedia} onLinkCardActionsActiveChange={onLinkCardActionsActiveChange} contentPresentation={contentPresentation} + embedPath={embedPath} + quotePath={quotePath} + embedDepth={embedDepth} + maxEmbedDepth={maxEmbedDepth} />
diff --git a/components/waves/drops/WaveDropPartContent.tsx b/components/waves/drops/WaveDropPartContent.tsx index 1d38cc241d..5ecf9ed290 100644 --- a/components/waves/drops/WaveDropPartContent.tsx +++ b/components/waves/drops/WaveDropPartContent.tsx @@ -45,6 +45,10 @@ interface WaveDropPartContentProps { | ((href: string, active: boolean) => void) | undefined; readonly contentPresentation?: DropContentPresentation | undefined; + readonly embedPath?: readonly string[] | undefined; + readonly quotePath?: readonly string[] | undefined; + readonly embedDepth?: number | undefined; + readonly maxEmbedDepth?: number | undefined; } const WaveDropPartContent: React.FC = ({ @@ -70,6 +74,10 @@ const WaveDropPartContent: React.FC = ({ fullWidthMedia = false, onLinkCardActionsActiveChange, contentPresentation = "default", + embedPath, + quotePath, + embedDepth, + maxEmbedDepth, }) => { const contentRef = React.useRef(null); @@ -166,6 +174,10 @@ const WaveDropPartContent: React.FC = ({ drop={drop} onLinkCardActionsActiveChange={onLinkCardActionsActiveChange} contentPresentation={contentPresentation} + embedPath={embedPath} + quotePath={quotePath} + embedDepth={embedDepth} + maxEmbedDepth={maxEmbedDepth} />
{!!activePart.media.length && ( diff --git a/components/waves/drops/WaveDropPartContentMarkdown.tsx b/components/waves/drops/WaveDropPartContentMarkdown.tsx index fdddaa9c20..679e5c83a9 100644 --- a/components/waves/drops/WaveDropPartContentMarkdown.tsx +++ b/components/waves/drops/WaveDropPartContentMarkdown.tsx @@ -38,6 +38,10 @@ interface WaveDropPartContentMarkdownProps { | ((href: string, active: boolean) => void) | undefined; readonly contentPresentation?: DropContentPresentation | undefined; + readonly embedPath?: readonly string[] | undefined; + readonly quotePath?: readonly string[] | undefined; + readonly embedDepth?: number | undefined; + readonly maxEmbedDepth?: number | undefined; } const WaveDropPartContentMarkdown: React.FC< @@ -57,10 +61,36 @@ const WaveDropPartContentMarkdown: React.FC< drop, onLinkCardActionsActiveChange, contentPresentation = "default", + embedPath, + quotePath, + embedDepth, + maxEmbedDepth, }) => { const linkPreviewToggleControl = useDropLinkPreviewToggleControl(drop); - const currentQuotePath = - drop?.serial_no === undefined ? [] : [`${wave.id}:${drop.serial_no}`]; + const dropId = drop?.id; + const dropSerialNo = drop?.serial_no; + const waveId = wave.id; + const currentDropEmbedPath = React.useMemo(() => { + const path = embedPath ? [...embedPath] : []; + if (!dropId || path.includes(dropId)) { + return path; + } + + return [...path, dropId]; + }, [dropId, embedPath]); + const currentQuotePath = React.useMemo(() => { + const path = quotePath ? [...quotePath] : []; + if (dropSerialNo === undefined) { + return path; + } + + const currentQuoteKey = `${waveId}:${dropSerialNo}`; + if (!path.includes(currentQuoteKey)) { + path.push(currentQuoteKey); + } + + return path; + }, [dropSerialNo, quotePath, waveId]); const compactProposal = contentPresentation === "quorumCompact" ? parseQuorumProposalMarkdown(part.content) @@ -109,7 +139,10 @@ const WaveDropPartContentMarkdown: React.FC< onQuoteClick={onQuoteClick} currentDropId={drop?.id} hideLinkPreviews={drop?.hide_link_preview} + embedPath={currentDropEmbedPath} quotePath={currentQuotePath} + embedDepth={embedDepth} + maxEmbedDepth={maxEmbedDepth} linkPreviewToggleControl={linkPreviewToggleControl} onLinkCardActionsActiveChange={onLinkCardActionsActiveChange} /> @@ -124,7 +157,10 @@ const WaveDropPartContentMarkdown: React.FC< onQuoteClick={onQuoteClick} currentDropId={drop?.id} hideLinkPreviews={drop?.hide_link_preview} + embedPath={currentDropEmbedPath} quotePath={currentQuotePath} + embedDepth={embedDepth} + maxEmbedDepth={maxEmbedDepth} linkPreviewToggleControl={linkPreviewToggleControl} onLinkCardActionsActiveChange={onLinkCardActionsActiveChange} /> @@ -147,9 +183,10 @@ const WaveDropPartContentMarkdown: React.FC< : null } onQuoteClick={onQuoteClick} - embedPath={drop?.id ? [drop.id] : []} + embedPath={currentDropEmbedPath} quotePath={currentQuotePath} - embedDepth={1} + embedDepth={(embedDepth ?? 0) + 1} + maxEmbedDepth={maxEmbedDepth} onLinkCardActionsActiveChange={onLinkCardActionsActiveChange} />
diff --git a/components/waves/drops/WaveDropPartDrop.tsx b/components/waves/drops/WaveDropPartDrop.tsx index 729e30e469..b41fe84150 100644 --- a/components/waves/drops/WaveDropPartDrop.tsx +++ b/components/waves/drops/WaveDropPartDrop.tsx @@ -36,6 +36,10 @@ interface WaveDropPartDropProps { | ((href: string, active: boolean) => void) | undefined; readonly contentPresentation?: DropContentPresentation | undefined; + readonly embedPath?: readonly string[] | undefined; + readonly quotePath?: readonly string[] | undefined; + readonly embedDepth?: number | undefined; + readonly maxEmbedDepth?: number | undefined; } const WaveDropPartDrop: React.FC = ({ @@ -56,6 +60,10 @@ const WaveDropPartDrop: React.FC = ({ fullWidthMedia = false, onLinkCardActionsActiveChange, contentPresentation = "default", + embedPath, + quotePath, + embedDepth, + maxEmbedDepth, }) => { const showStandaloneTitle = contentPresentation !== "quorumCompact"; @@ -87,6 +95,10 @@ const WaveDropPartDrop: React.FC = ({ fullWidthMedia={fullWidthMedia} onLinkCardActionsActiveChange={onLinkCardActionsActiveChange} contentPresentation={contentPresentation} + embedPath={embedPath} + quotePath={quotePath} + embedDepth={embedDepth} + maxEmbedDepth={maxEmbedDepth} />
diff --git a/components/waves/drops/participation/EndedParticipationDrop.tsx b/components/waves/drops/participation/EndedParticipationDrop.tsx index a0ab8ec11f..78d0da90f2 100644 --- a/components/waves/drops/participation/EndedParticipationDrop.tsx +++ b/components/waves/drops/participation/EndedParticipationDrop.tsx @@ -43,6 +43,10 @@ interface EndedParticipationDropProps { readonly identityMode?: DropIdentityMode | undefined; readonly showInteractions?: boolean | undefined; readonly contentPresentation?: DropContentPresentation | undefined; + readonly embedPath?: readonly string[] | undefined; + readonly quotePath?: readonly string[] | undefined; + readonly embedDepth?: number | undefined; + readonly maxEmbedDepth?: number | undefined; } export default function EndedParticipationDrop({ @@ -57,6 +61,10 @@ export default function EndedParticipationDrop({ identityMode = "default", showInteractions = true, contentPresentation = "default", + embedPath, + quotePath, + embedDepth, + maxEmbedDepth, }: EndedParticipationDropProps) { const isActiveDrop = activeDrop?.drop.id === drop.id; const router = useRouter(); @@ -218,6 +226,10 @@ export default function EndedParticipationDrop({ isCompetitionDrop={true} hasTouch={hasTouch} contentPresentation={contentPresentation} + embedPath={embedPath} + quotePath={quotePath} + embedDepth={embedDepth} + maxEmbedDepth={maxEmbedDepth} /> diff --git a/components/waves/drops/participation/OngoingParticipationDrop.tsx b/components/waves/drops/participation/OngoingParticipationDrop.tsx index c8278302e7..f675037f25 100644 --- a/components/waves/drops/participation/OngoingParticipationDrop.tsx +++ b/components/waves/drops/participation/OngoingParticipationDrop.tsx @@ -44,6 +44,10 @@ interface OngoingParticipationDropProps { readonly identityMode?: DropIdentityMode | undefined; readonly showInteractions?: boolean | undefined; readonly contentPresentation?: DropContentPresentation | undefined; + readonly embedPath?: readonly string[] | undefined; + readonly quotePath?: readonly string[] | undefined; + readonly embedDepth?: number | undefined; + readonly maxEmbedDepth?: number | undefined; } export default function OngoingParticipationDrop({ @@ -58,6 +62,10 @@ export default function OngoingParticipationDrop({ identityMode = "default", showInteractions = true, contentPresentation = "default", + embedPath, + quotePath, + embedDepth, + maxEmbedDepth, }: OngoingParticipationDropProps) { const isActiveDrop = activeDrop?.drop.id === drop.id; const { canShowVote } = useDropInteractionRules(drop); @@ -144,6 +152,10 @@ export default function OngoingParticipationDrop({ setLongPressTriggered={setLongPressTriggered} isCompetitionDrop={true} contentPresentation={contentPresentation} + embedPath={embedPath} + quotePath={quotePath} + embedDepth={embedDepth} + maxEmbedDepth={maxEmbedDepth} /> diff --git a/components/waves/drops/participation/ParticipationDropContent.tsx b/components/waves/drops/participation/ParticipationDropContent.tsx index 4202914de2..246ae191eb 100644 --- a/components/waves/drops/participation/ParticipationDropContent.tsx +++ b/components/waves/drops/participation/ParticipationDropContent.tsx @@ -14,6 +14,10 @@ interface ParticipationDropContentProps { readonly setLongPressTriggered: (triggered: boolean) => void; readonly isCompetitionDrop?: boolean | undefined; readonly contentPresentation?: DropContentPresentation | undefined; + readonly embedPath?: readonly string[] | undefined; + readonly quotePath?: readonly string[] | undefined; + readonly embedDepth?: number | undefined; + readonly maxEmbedDepth?: number | undefined; } export default function ParticipationDropContent({ @@ -26,6 +30,10 @@ export default function ParticipationDropContent({ setLongPressTriggered, isCompetitionDrop = false, contentPresentation = "default", + embedPath, + quotePath, + embedDepth, + maxEmbedDepth, }: ParticipationDropContentProps) { const hasTouch = useIsTouchDevice(); @@ -42,6 +50,10 @@ export default function ParticipationDropContent({ isCompetitionDrop={isCompetitionDrop} hasTouch={hasTouch} contentPresentation={contentPresentation} + embedPath={embedPath} + quotePath={quotePath} + embedDepth={embedDepth} + maxEmbedDepth={maxEmbedDepth} /> ); diff --git a/components/waves/drops/participation/participationRenderer.types.ts b/components/waves/drops/participation/participationRenderer.types.ts index 28cc71dd2a..9ac108d2fd 100644 --- a/components/waves/drops/participation/participationRenderer.types.ts +++ b/components/waves/drops/participation/participationRenderer.types.ts @@ -21,6 +21,10 @@ export interface ParticipationDropProps { readonly parentContainerRef?: RefObject | undefined; readonly identityMode?: DropIdentityMode | undefined; readonly showInteractions?: boolean | undefined; + readonly embedPath?: readonly string[] | undefined; + readonly quotePath?: readonly string[] | undefined; + readonly embedDepth?: number | undefined; + readonly maxEmbedDepth?: number | undefined; } export interface SingleWaveDropProps { diff --git a/components/waves/quorum/QuorumParticipationDropLinkPreview.tsx b/components/waves/quorum/QuorumParticipationDropLinkPreview.tsx new file mode 100644 index 0000000000..8b344a13ec --- /dev/null +++ b/components/waves/quorum/QuorumParticipationDropLinkPreview.tsx @@ -0,0 +1,169 @@ +"use client"; + +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { useCallback, useMemo } from "react"; + +import { QueryKey } from "@/components/react-query-wrapper/ReactQueryWrapper"; +import LinkHandlerFrame from "@/components/waves/LinkHandlerFrame"; +import { DropLocation } from "@/components/waves/drops/drop.types"; +import type { DropInteractionParams } from "@/components/waves/drops/drop.types"; +import WaveDropQuote from "@/components/waves/drops/WaveDropQuote"; +import { WaveDropsSearchStrategy } from "@/contexts/wave/hooks/types"; +import type { ApiDrop } from "@/generated/models/ApiDrop"; +import { ApiDropType } from "@/generated/models/ApiDropType"; +import type { ApiWaveDropsFeed } from "@/generated/models/ApiWaveDropsFeed"; +import { + convertApiDropToExtendedDrop, + type ExtendedDrop, +} from "@/helpers/waves/drop.helpers"; +import { commonApiFetch } from "@/services/api/common-api"; +import QuorumParticipationDrop from "./QuorumParticipationDrop"; + +interface QuorumParticipationDropLinkPreviewProps { + readonly href: string; + readonly waveId: string; + readonly dropId?: string | undefined; + readonly serialNo?: string | number | undefined; + readonly onQuoteClick: (drop: ApiDrop) => void; + readonly embedPath?: readonly string[] | undefined; + readonly quotePath?: readonly string[] | undefined; + readonly embedDepth?: number | undefined; + readonly maxEmbedDepth?: number | undefined; + readonly hideLink?: boolean | undefined; +} + +const toSerialNumber = ( + serialNo: string | number | undefined +): number | null => { + if (typeof serialNo === "number") { + return Number.isFinite(serialNo) ? serialNo : null; + } + + if (!serialNo) { + return null; + } + + const parsed = Number.parseInt(serialNo, 10); + return Number.isFinite(parsed) ? parsed : null; +}; + +const fetchDropBySerialNo = async ({ + waveId, + serialNo, +}: { + readonly waveId: string; + readonly serialNo: number; +}): Promise => { + const results = await commonApiFetch({ + endpoint: `waves/${waveId}/drops`, + params: { + limit: "1", + serial_no_limit: `${serialNo}`, + search_strategy: WaveDropsSearchStrategy.Both, + }, + }); + + const drop = results.drops.find( + (candidate) => candidate.serial_no === serialNo + ); + if (!drop) { + return null; + } + + return { ...drop, wave: results.wave } as ApiDrop; +}; + +export default function QuorumParticipationDropLinkPreview({ + href, + waveId, + dropId, + serialNo, + onQuoteClick, + embedPath, + quotePath, + embedDepth, + maxEmbedDepth, + hideLink, +}: QuorumParticipationDropLinkPreviewProps) { + const parsedSerialNo = toSerialNumber(serialNo); + const normalizedDropId = + dropId === undefined || dropId.length === 0 ? null : dropId; + const { data: drop } = useQuery({ + queryKey: [ + QueryKey.DROP, + "quorum-participation-link-preview", + { + dropId: normalizedDropId, + waveId, + serialNo: parsedSerialNo, + }, + ], + queryFn: async () => { + if (normalizedDropId !== null) { + return await commonApiFetch({ + endpoint: `drops/${normalizedDropId}`, + }); + } + + if (parsedSerialNo !== null) { + return await fetchDropBySerialNo({ + waveId, + serialNo: parsedSerialNo, + }); + } + + return null; + }, + enabled: normalizedDropId !== null || parsedSerialNo !== null, + placeholderData: keepPreviousData, + }); + + const extendedDrop = useMemo(() => { + if (drop?.drop_type !== ApiDropType.Participatory) { + return null; + } + + return convertApiDropToExtendedDrop(drop); + }, [drop]); + + const handleReply = useCallback((_params: DropInteractionParams) => { + return undefined; + }, []); + + const handleDropContentClick = useCallback( + (selectedDrop: ExtendedDrop) => onQuoteClick(selectedDrop), + [onQuoteClick] + ); + + return ( + + {extendedDrop ? ( + + ) : ( + + )} + + ); +} diff --git a/components/waves/quorum/QuorumProposalCompactContent.tsx b/components/waves/quorum/QuorumProposalCompactContent.tsx index 40a830c77e..7fa615e8b3 100644 --- a/components/waves/quorum/QuorumProposalCompactContent.tsx +++ b/components/waves/quorum/QuorumProposalCompactContent.tsx @@ -19,7 +19,10 @@ type CompactMarkdownProps = Pick< | "onQuoteClick" | "currentDropId" | "hideLinkPreviews" + | "embedPath" | "quotePath" + | "embedDepth" + | "maxEmbedDepth" | "linkPreviewToggleControl" | "onLinkCardActionsActiveChange" >; diff --git a/helpers/waves/drop-copy-link.helpers.ts b/helpers/waves/drop-copy-link.helpers.ts index b291d960ef..8ad6656867 100644 --- a/helpers/waves/drop-copy-link.helpers.ts +++ b/helpers/waves/drop-copy-link.helpers.ts @@ -12,16 +12,30 @@ const isMemesSubmissionCopyLinkDrop = ({ }): boolean => isMemesWave(drop.wave.id) && drop.drop_type === ApiDropType.Participatory; +const isQuorumParticipationCopyLinkDrop = ({ + drop, + isQuorumWave, +}: { + drop: ApiDrop; + isQuorumWave: (waveId: string | undefined | null) => boolean; +}): boolean => + isQuorumWave(drop.wave.id) && drop.drop_type === ApiDropType.Participatory; + export const getCopiedDropLink = ({ drop, isDirectMessage, isMemesWave, + isQuorumWave, }: { drop: ApiDrop; isDirectMessage: boolean; isMemesWave: (waveId: string | undefined | null) => boolean; + isQuorumWave: (waveId: string | undefined | null) => boolean; }): string => { - if (isMemesSubmissionCopyLinkDrop({ drop, isMemesWave })) { + if ( + isMemesSubmissionCopyLinkDrop({ drop, isMemesWave }) || + isQuorumParticipationCopyLinkDrop({ drop, isQuorumWave }) + ) { return `${publicEnv.BASE_ENDPOINT}${getWaveRoute({ waveId: drop.wave.id, extraParams: { drop: drop.id },