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