Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
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";

const mockDropItemChat = jest.fn(
({ href, dropId }: { href: string; dropId: string }) => (
<div data-testid="drop-item-chat" data-href={href} data-drop-id={dropId} />
)
);

const mockRenderSeizeQuote = jest.fn(() => (
<div data-testid="seize-quote-content" />
));

jest.mock("@/helpers/SeizeLinkParser", () => ({
parseSeizeDropLink: jest.fn(),
parseSeizeQueryLink: jest.fn(() => null),
parseSeizeWaveLink: jest.fn(() => null),
parseSeizeQuoteLink: jest.fn(() => null),
}));

jest.mock("@/components/waves/drops/DropItemChat", () => ({
__esModule: true,
default: (props: any) => mockDropItemChat(props),
}));

jest.mock("@/components/drops/view/part/dropPartMarkdown/renderers", () => ({
renderSeizeQuote: (...args: any[]) => mockRenderSeizeQuote(...args),
}));

const mockedParseSeizeDropLink = parseSeizeDropLink as jest.MockedFunction<
typeof parseSeizeDropLink
>;

const getDropHandler = (options?: {
readonly onQuoteClick?: ((drop: any) => void) | undefined;
readonly currentDropId?: string | undefined;
readonly isMemesWaveById?:
| ((waveId: string | undefined | null) => boolean)
| undefined;
}) =>
createSeizeHandlers({
onQuoteClick: options?.onQuoteClick ?? jest.fn(),
currentDropId: options?.currentDropId,
embedPath: [],
quotePath: [],
embedDepth: 0,
maxEmbedDepth: 4,
isMemesWaveById: options?.isMemesWaveById,
})[3];

describe("createSeizeHandlers drop handler", () => {
beforeEach(() => {
jest.clearAllMocks();
});

it("keeps DropItemChat rendering for memes waves", () => {
mockedParseSeizeDropLink.mockReturnValue({
waveId: "memes-wave-id",
dropId: "drop-1",
});
const handler = getDropHandler({
isMemesWaveById: (waveId) => waveId === "memes-wave-id",
});

const element = handler.render(
"https://site.com/waves/memes-wave-id?drop=drop-1"
);
render(<>{element}</>);

expect(screen.getByTestId("drop-item-chat")).toHaveAttribute(
"data-drop-id",
"drop-1"
);
expect(mockRenderSeizeQuote).not.toHaveBeenCalled();
});

it("renders quote-style preview for non-memes waves", () => {
const onQuoteClick = jest.fn();
mockedParseSeizeDropLink.mockReturnValue({
waveId: "normal-wave-id",
dropId: "drop-2",
});
const handler = getDropHandler({
onQuoteClick,
isMemesWaveById: () => false,
});

const href = "https://site.com/waves/normal-wave-id?drop=drop-2";
const element = handler.render(href);
render(<>{element}</>);

expect(screen.getByTestId("seize-quote-content")).toBeInTheDocument();
expect(mockRenderSeizeQuote).toHaveBeenCalledWith(
{
waveId: "normal-wave-id",
dropId: "drop-2",
},
onQuoteClick,
href,
{
embedPath: [],
quotePath: [],
embedDepth: 1,
maxEmbedDepth: 4,
}
);
expect(mockDropItemChat).not.toHaveBeenCalled();
});

it("falls back to DropItemChat when wave id is unavailable", () => {
mockedParseSeizeDropLink.mockReturnValue({
waveId: null,
dropId: "drop-3",
});
const handler = getDropHandler({
isMemesWaveById: () => false,
});

const element = handler.render("https://site.com/messages?drop=drop-3");
render(<>{element}</>);

expect(screen.getByTestId("drop-item-chat")).toHaveAttribute(
"data-drop-id",
"drop-3"
);
expect(mockRenderSeizeQuote).not.toHaveBeenCalled();
});

it("keeps recursion guard for current drop id", () => {
mockedParseSeizeDropLink.mockReturnValue({
waveId: "normal-wave-id",
dropId: "drop-4",
});
const handler = getDropHandler({
currentDropId: "drop-4",
isMemesWaveById: () => false,
});

expect(() =>
handler.render("https://site.com/waves/normal-wave-id?drop=drop-4")
).toThrow("Seize drop link matches current drop");
});
});
44 changes: 44 additions & 0 deletions __tests__/helpers/SeizeLinkParser.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
describe("SeizeLinkParser with mocked BASE_ENDPOINT", () => {
let parseSeizeQuoteLink: any;
let parseSeizeDropLink: any;
let parseSeizeWaveLink: any;
let parseSeizeQueryLink: any;
let ensureStableSeizeLink: any;
Expand All @@ -14,6 +15,7 @@ describe("SeizeLinkParser with mocked BASE_ENDPOINT", () => {
// Now import the module under test AFTER the mock is in place
({
parseSeizeQuoteLink,
parseSeizeDropLink,
parseSeizeWaveLink,
parseSeizeQueryLink,
ensureStableSeizeLink,
Expand Down Expand Up @@ -130,6 +132,48 @@ describe("SeizeLinkParser with mocked BASE_ENDPOINT", () => {
});
});

describe("parseSeizeDropLink", () => {
const uuid = "123e4567-e89b-12d3-a456-426614174000";

it("parses canonical wave drop links", () => {
expect(parseSeizeDropLink(`/waves/${uuid}?drop=drop-1`)).toEqual({
waveId: uuid,
dropId: "drop-1",
});
});

it("parses legacy query-based wave drop links", () => {
expect(parseSeizeDropLink(`/waves?wave=${uuid}&drop=drop-1`)).toEqual({
waveId: uuid,
dropId: "drop-1",
});
});

it("parses drop links rebased to messages route with wave query", () => {
expect(parseSeizeDropLink(`/messages?wave=${uuid}&drop=drop-1`)).toEqual({
waveId: uuid,
dropId: "drop-1",
});
});

it("sanitizes trailing slash in drop id", () => {
expect(parseSeizeDropLink(`/waves/${uuid}?drop=drop-1/`)).toEqual({
waveId: uuid,
dropId: "drop-1",
});
});

it("returns null when drop query is missing", () => {
expect(parseSeizeDropLink(`/waves/${uuid}`)).toBeNull();
});

it("returns null for foreign origin", () => {
expect(
parseSeizeDropLink(`https://example.com/waves/${uuid}?drop=drop-1`)
).toBeNull();
});
});

describe("ensureStableSeizeLink", () => {
it("returns original href for non-base URLs", () => {
const incoming = "https://othersite.com/?drop=drop-id";
Expand Down
4 changes: 4 additions & 0 deletions components/drops/view/part/DropPartMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { useQueryClient } from "@tanstack/react-query";
import type { PluggableList } from "unified";

import { useEmoji } from "@/contexts/EmojiContext";
import { useSeizeSettingsOptional } from "@/contexts/SeizeSettingsContext";
import type { ApiDrop } from "@/generated/models/ApiDrop";
import type { ApiDropNftLink } from "@/generated/models/ApiDropNftLink";
import type { ApiDropMentionedUser } from "@/generated/models/ApiDropMentionedUser";
Expand Down Expand Up @@ -279,6 +280,7 @@ function DropPartMarkdown({
const queryClient = useQueryClient();
const isMobile = useIsMobileScreen();
const { emojiMap, findNativeEmoji } = useEmoji();
const seizeSettings = useSeizeSettingsOptional();
const tweetPreviewMode = useTweetPreviewMode();
const { variant: linkPreviewVariant } = useLinkPreviewContext();

Expand Down Expand Up @@ -333,6 +335,7 @@ function DropPartMarkdown({
currentDropId,
hideLinkPreviews,
tweetPreviewMode,
isMemesWaveById: seizeSettings?.isMemesWave,
embedPath: normalizedEmbedPath,
quotePath: normalizedQuotePath,
embedDepth,
Expand All @@ -344,6 +347,7 @@ function DropPartMarkdown({
currentDropId,
hideLinkPreviews,
tweetPreviewMode,
seizeSettings?.isMemesWave,
normalizedEmbedPath,
normalizedQuotePath,
embedDepth,
Expand Down
58 changes: 36 additions & 22 deletions components/drops/view/part/dropPartMarkdown/handlers/seize.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { ReactElement } from "react";

import type { ApiDrop } from "@/generated/models/ApiDrop";
import {
getSeizeBaseOrigin,
parseSeizeDropLink,
parseSeizeQueryLink,
parseSeizeWaveLink,
parseSeizeQuoteLink,
Expand All @@ -22,6 +22,9 @@ interface CreateSeizeHandlersConfig {
readonly quotePath: readonly string[];
readonly embedDepth: number;
readonly maxEmbedDepth: number;
readonly isMemesWaveById?:
| ((waveId: string | undefined | null) => boolean)
| undefined;
}

type SeizeGuardConfig = Omit<CreateSeizeHandlersConfig, "onQuoteClick">;
Expand Down Expand Up @@ -128,29 +131,17 @@ const createSeizeWaveHandler = (): LinkHandler =>
(waveId, href) => <WaveItemChat href={href} waveId={waveId} />
);

const getDropId = (href: string): string | null => {
const baseOrigin = getSeizeBaseOrigin();
if (!baseOrigin) {
return null;
}
const getDropInfo = parseSeizeDropLink;

try {
const url = new URL(href, baseOrigin);
if (url.origin !== baseOrigin) {
return null;
}
const dropId = url.searchParams.get("drop");
return dropId ?? null;
} catch {
return null;
}
};

const createSeizeDropHandler = (config: SeizeGuardConfig): LinkHandler =>
const createSeizeDropHandler = (
onQuoteClick: (drop: ApiDrop) => void,
config: SeizeGuardConfig
): LinkHandler =>
createSeizeQueryHandler(
getDropId,
getDropInfo,
"Invalid seize drop link",
(dropId, href) => {
(dropInfo, href) => {
const { dropId, waveId } = dropInfo;
if (config.embedDepth >= config.maxEmbedDepth) {
throw new Error("Seize drop link exceeded max embed depth");
}
Expand All @@ -162,6 +153,29 @@ const createSeizeDropHandler = (config: SeizeGuardConfig): LinkHandler =>
throw new Error("Seize drop link matches current drop");
}

const isMemesWave = config.isMemesWaveById?.(waveId) ?? false;

if (!isMemesWave && waveId) {
Comment thread
simo6529 marked this conversation as resolved.
const content = renderSeizeQuote(
{
waveId,
dropId,
},
onQuoteClick,
href,
{
embedPath: config.embedPath,
quotePath: config.quotePath,
embedDepth: config.embedDepth + 1,
maxEmbedDepth: config.maxEmbedDepth,
}
);

if (content) {
return content;
}
}

return <DropItemChat href={href} dropId={dropId} />;
}
);
Expand All @@ -173,5 +187,5 @@ export const createSeizeHandlers = ({
createSeizeQuoteHandler(onQuoteClick, config),
createSeizeGroupHandler(),
createSeizeWaveHandler(),
createSeizeDropHandler(config),
createSeizeDropHandler(onQuoteClick, config),
];
5 changes: 5 additions & 0 deletions components/drops/view/part/dropPartMarkdown/linkHandlers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ interface LinkRendererConfig {
readonly currentDropId?: string | undefined;
readonly hideLinkPreviews?: boolean | undefined;
readonly tweetPreviewMode?: TweetPreviewMode | undefined;
readonly isMemesWaveById?:
| ((waveId: string | undefined | null) => boolean)
| undefined;
readonly embedPath?: readonly string[] | undefined;
readonly quotePath?: readonly string[] | undefined;
readonly embedDepth?: number | undefined;
Expand Down Expand Up @@ -70,6 +73,7 @@ export const createLinkRenderer = ({
currentDropId,
hideLinkPreviews = false,
tweetPreviewMode = "auto",
isMemesWaveById,
embedPath,
quotePath,
embedDepth = 0,
Expand All @@ -83,6 +87,7 @@ export const createLinkRenderer = ({
quotePath: quotePath ?? [],
embedDepth,
maxEmbedDepth,
isMemesWaveById,
});
const handlers = createLinkHandlers({
tweetPreviewMode,
Expand Down
11 changes: 1 addition & 10 deletions components/drops/view/part/dropPartMarkdown/renderers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { ErrorBoundary } from "react-error-boundary";

import type { ApiDrop } from "@/generated/models/ApiDrop";
import type { SeizeQuoteLinkInfo } from "@/helpers/SeizeLinkParser";
import { getWaveRoute } from "@/helpers/navigation.helpers";

import LinkHandlerFrame from "@/components/waves/LinkHandlerFrame";
import WaveDropQuoteWithDropId from "@/components/waves/drops/WaveDropQuoteWithDropId";
Expand Down Expand Up @@ -116,15 +115,7 @@ const renderSeizeQuote = (

if (dropId) {
return (
<LinkHandlerFrame
href={href}
relativeHref={getWaveRoute({
waveId,
extraParams: { drop: dropId },
isDirectMessage: false,
isApp: false,
})}
>
<LinkHandlerFrame href={href}>
<WaveDropQuoteWithDropId
dropId={dropId}
partId={1}
Expand Down
5 changes: 5 additions & 0 deletions contexts/SeizeSettingsContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -167,3 +167,8 @@ export const useSeizeSettings = (): SeizeSettingsContextType => {
}
return context;
};

export const useSeizeSettingsOptional = (): SeizeSettingsContextType | null => {
const context = useContext(SeizeSettingsContext);
return context ?? null;
};
Loading