From 10fe8c2d568d5900e040616ab2159d7e2979c82a Mon Sep 17 00:00:00 2001 From: Simo Date: Mon, 9 Feb 2026 11:43:59 -0400 Subject: [PATCH 1/3] wip Signed-off-by: Simo --- .../drops/view/part/DropPartMarkdown.test.tsx | 10 ++++++++++ .../drops/view/part/dropPartMarkdown/renderers.tsx | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/__tests__/components/drops/view/part/DropPartMarkdown.test.tsx b/__tests__/components/drops/view/part/DropPartMarkdown.test.tsx index c596ca3c4e..04315fc14b 100644 --- a/__tests__/components/drops/view/part/DropPartMarkdown.test.tsx +++ b/__tests__/components/drops/view/part/DropPartMarkdown.test.tsx @@ -321,6 +321,11 @@ describe("DropPartMarkdown", () => { expect(fallbackLink).toHaveAttribute("target", "_blank"); expect(fallbackLink).toHaveTextContent(/Tweet unavailable/i); expect(fallbackLink).toHaveTextContent(/Open on X/i); + const tweetWrapper = fallbackLink.parentElement; + if (!tweetWrapper) { + throw new Error("Expected tweet fallback wrapper"); + } + expect(tweetWrapper).toHaveClass("tw-w-full", "lg:tw-max-w-[520px]"); }); it("renders a fallback link when the tweet embed throws", async () => { @@ -349,6 +354,11 @@ describe("DropPartMarkdown", () => { "href", "https://twitter.com/foo/status/1111111111" ); + const tweetWrapper = fallbackLink.parentElement; + if (!tweetWrapper) { + throw new Error("Expected tweet fallback wrapper"); + } + expect(tweetWrapper).toHaveClass("tw-w-full", "lg:tw-max-w-[520px]"); } finally { consoleErrorSpy.mockRestore(); } diff --git a/components/drops/view/part/dropPartMarkdown/renderers.tsx b/components/drops/view/part/dropPartMarkdown/renderers.tsx index a9d2c6d36c..7fe52a3dc4 100644 --- a/components/drops/view/part/dropPartMarkdown/renderers.tsx +++ b/components/drops/view/part/dropPartMarkdown/renderers.tsx @@ -34,7 +34,7 @@ const renderTweetEmbed = ( const renderFallback = () => ; return ( -
+
renderFallback()}> Date: Mon, 9 Feb 2026 12:16:36 -0400 Subject: [PATCH 2/3] wip Signed-off-by: Simo --- .../drops/view/part/DropPartMarkdown.test.tsx | 4 +- .../dropPartMarkdown/handlers/gif.test.tsx | 67 +++++++++++++++++++ .../linkHandlersRegistry.test.tsx | 23 +++++-- .../part/dropPartMarkdown/handlers/gif.tsx | 10 ++- .../part/dropPartMarkdown/handlers/index.ts | 4 +- .../part/dropPartMarkdown/linkHandlers.tsx | 7 +- .../view/part/dropPartMarkdown/renderers.tsx | 42 +++++++++--- components/waves/SmartLinkPreview.tsx | 8 ++- 8 files changed, 143 insertions(+), 22 deletions(-) create mode 100644 __tests__/components/drops/view/part/dropPartMarkdown/handlers/gif.test.tsx diff --git a/__tests__/components/drops/view/part/DropPartMarkdown.test.tsx b/__tests__/components/drops/view/part/DropPartMarkdown.test.tsx index 04315fc14b..5bf532e1b7 100644 --- a/__tests__/components/drops/view/part/DropPartMarkdown.test.tsx +++ b/__tests__/components/drops/view/part/DropPartMarkdown.test.tsx @@ -325,7 +325,7 @@ describe("DropPartMarkdown", () => { if (!tweetWrapper) { throw new Error("Expected tweet fallback wrapper"); } - expect(tweetWrapper).toHaveClass("tw-w-full", "lg:tw-max-w-[520px]"); + expect(tweetWrapper).toHaveClass("tw-w-full", "lg:tw-max-w-[480px]"); }); it("renders a fallback link when the tweet embed throws", async () => { @@ -358,7 +358,7 @@ describe("DropPartMarkdown", () => { if (!tweetWrapper) { throw new Error("Expected tweet fallback wrapper"); } - expect(tweetWrapper).toHaveClass("tw-w-full", "lg:tw-max-w-[520px]"); + expect(tweetWrapper).toHaveClass("tw-w-full", "lg:tw-max-w-[480px]"); } finally { consoleErrorSpy.mockRestore(); } diff --git a/__tests__/components/drops/view/part/dropPartMarkdown/handlers/gif.test.tsx b/__tests__/components/drops/view/part/dropPartMarkdown/handlers/gif.test.tsx new file mode 100644 index 0000000000..0fc2198683 --- /dev/null +++ b/__tests__/components/drops/view/part/dropPartMarkdown/handlers/gif.test.tsx @@ -0,0 +1,67 @@ +import { render, screen } from "@testing-library/react"; + +import { createGifHandler } from "@/components/drops/view/part/dropPartMarkdown/handlers/gif"; +import { renderGifEmbed } from "@/components/drops/view/part/dropPartMarkdown/renderers"; + +jest.mock("@/components/drops/view/part/dropPartMarkdown/renderers", () => ({ + renderGifEmbed: jest.fn((url: string, options?: { fixedSize?: boolean }) => ( +
+ )), +})); + +const mockRenderGifEmbed = renderGifEmbed as jest.MockedFunction< + typeof renderGifEmbed +>; + +describe("createGifHandler", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("matches only Tenor GIF URLs", () => { + const handler = createGifHandler(); + + expect(handler.match("https://media.tenor.com/abc/tenor.gif")).toBe(true); + expect(handler.match("https://media.tenor.com/abc/tenor.jpg")).toBe(false); + expect(handler.match("https://media.giphy.com/media/abc/giphy.gif")).toBe( + false + ); + expect(handler.match("https://example.com/image.gif")).toBe(false); + }); + + it("renders fixed-size GIFs for chat/default variant", () => { + const handler = createGifHandler({ linkPreviewVariant: "chat" }); + const element = handler.render("https://media.tenor.com/abc/tenor.gif"); + + render(<>{element}); + + expect(screen.getByTestId("gif-embed")).toHaveAttribute( + "data-fixed-size", + "true" + ); + expect(mockRenderGifEmbed).toHaveBeenCalledWith( + "https://media.tenor.com/abc/tenor.gif", + { fixedSize: true } + ); + }); + + it("renders non-fixed GIFs for home variant", () => { + const handler = createGifHandler({ linkPreviewVariant: "home" }); + const element = handler.render("https://media.tenor.com/abc/tenor.gif"); + + render(<>{element}); + + expect(screen.getByTestId("gif-embed")).toHaveAttribute( + "data-fixed-size", + "false" + ); + expect(mockRenderGifEmbed).toHaveBeenCalledWith( + "https://media.tenor.com/abc/tenor.gif", + { fixedSize: false } + ); + }); +}); diff --git a/__tests__/components/drops/view/part/dropPartMarkdown/linkHandlersRegistry.test.tsx b/__tests__/components/drops/view/part/dropPartMarkdown/linkHandlersRegistry.test.tsx index 094d6b731d..863c9fd301 100644 --- a/__tests__/components/drops/view/part/dropPartMarkdown/linkHandlersRegistry.test.tsx +++ b/__tests__/components/drops/view/part/dropPartMarkdown/linkHandlersRegistry.test.tsx @@ -3,6 +3,7 @@ import type { ReactNode } from "react"; import { render, screen } from "@testing-library/react"; import { createLinkRenderer } from "@/components/drops/view/part/dropPartMarkdown/linkHandlers"; +import { renderGifEmbed } from "@/components/drops/view/part/dropPartMarkdown/renderers"; import { ensureStableSeizeLink } from "@/helpers/SeizeLinkParser"; import { publicEnv } from "@/config/env"; @@ -28,8 +29,12 @@ jest.mock("@/components/drops/view/part/DropPartMarkdownImage", () => ({ })); jest.mock("@/components/drops/view/part/dropPartMarkdown/renderers", () => ({ - renderGifEmbed: jest.fn((url: string) => ( -
+ renderGifEmbed: jest.fn((url: string, options?: { fixedSize?: boolean }) => ( +
)), renderSeizeQuote: jest.fn(() =>
), renderTweetEmbed: jest.fn((href: string) => ( @@ -152,6 +157,9 @@ jest.mock("@/helpers/SeizeLinkParser", () => { }); const onQuoteClick = jest.fn(); +const mockRenderGifEmbed = renderGifEmbed as jest.MockedFunction< + typeof renderGifEmbed +>; const baseRenderer = () => createLinkRenderer({ onQuoteClick }); @@ -343,9 +351,14 @@ describe("createLinkRenderer", () => { href: "https://media.tenor.com/test.gif", } as any); render(<>{element}); - expect(screen.getByTestId("gif")).toHaveAttribute( - "data-url", - "https://media.tenor.com/test.gif" + const gif = screen.getByTestId("gif"); + expect(gif).toHaveAttribute("data-url", "https://media.tenor.com/test.gif"); + expect(gif).toHaveAttribute("data-fixed-size", "true"); + expect(mockRenderGifEmbed).toHaveBeenCalledWith( + "https://media.tenor.com/test.gif", + { + fixedSize: true, + } ); }); diff --git a/components/drops/view/part/dropPartMarkdown/handlers/gif.tsx b/components/drops/view/part/dropPartMarkdown/handlers/gif.tsx index 9a6cf254e0..1c4c17ec1e 100644 --- a/components/drops/view/part/dropPartMarkdown/handlers/gif.tsx +++ b/components/drops/view/part/dropPartMarkdown/handlers/gif.tsx @@ -1,5 +1,6 @@ import { renderGifEmbed } from "../renderers"; import type { LinkHandler } from "../linkTypes"; +import type { LinkPreviewVariant } from "@/components/waves/LinkPreviewContext"; const TENOR_HOST = "media.tenor.com"; @@ -18,8 +19,13 @@ const isTenorGif = (href: string): boolean => { } }; -export const createGifHandler = (): LinkHandler => ({ +export const createGifHandler = (options?: { + readonly linkPreviewVariant?: LinkPreviewVariant; +}): LinkHandler => ({ match: isTenorGif, - render: (href) => renderGifEmbed(href), + render: (href) => + renderGifEmbed(href, { + fixedSize: options?.linkPreviewVariant !== "home", + }), display: "block", }); diff --git a/components/drops/view/part/dropPartMarkdown/handlers/index.ts b/components/drops/view/part/dropPartMarkdown/handlers/index.ts index 0a1645c168..226aa57eb1 100644 --- a/components/drops/view/part/dropPartMarkdown/handlers/index.ts +++ b/components/drops/view/part/dropPartMarkdown/handlers/index.ts @@ -11,9 +11,11 @@ import { createTwitterHandler } from "./twitter"; import { createWikimediaHandler } from "./wikimedia"; import { createYoutubeHandler } from "./youtube"; import type { TweetPreviewMode } from "@/components/tweets/TweetPreviewModeContext"; +import type { LinkPreviewVariant } from "@/components/waves/LinkPreviewContext"; export const createLinkHandlers = (options?: { readonly tweetPreviewMode?: TweetPreviewMode; + readonly linkPreviewVariant?: LinkPreviewVariant; }): LinkHandler[] => [ createYoutubeHandler(), createTikTokHandler(), @@ -22,7 +24,7 @@ export const createLinkHandlers = (options?: { createCompoundHandler(), createTwitterHandler(options), createWikimediaHandler(), - createGifHandler(), + createGifHandler(options), createArtBlocksHandler(), createPepeHandler(), createFarcasterHandler(), diff --git a/components/drops/view/part/dropPartMarkdown/linkHandlers.tsx b/components/drops/view/part/dropPartMarkdown/linkHandlers.tsx index 4e8aec2358..6628d19ea8 100644 --- a/components/drops/view/part/dropPartMarkdown/linkHandlers.tsx +++ b/components/drops/view/part/dropPartMarkdown/linkHandlers.tsx @@ -64,9 +64,10 @@ export const createLinkRenderer = ({ tweetPreviewMode, }: LinkRendererConfig): LinkRenderer => { const seizeHandlers = createSeizeHandlers({ onQuoteClick, currentDropId }); - const handlers = createLinkHandlers( - tweetPreviewMode ? { tweetPreviewMode } : undefined - ); + const handlers = createLinkHandlers({ + tweetPreviewMode, + linkPreviewVariant: "chat", + }); const renderImage: LinkRenderer["renderImage"] = ({ src }) => { if (typeof src !== "string") { diff --git a/components/drops/view/part/dropPartMarkdown/renderers.tsx b/components/drops/view/part/dropPartMarkdown/renderers.tsx index 7fe52a3dc4..1d81d2286f 100644 --- a/components/drops/view/part/dropPartMarkdown/renderers.tsx +++ b/components/drops/view/part/dropPartMarkdown/renderers.tsx @@ -34,7 +34,7 @@ const renderTweetEmbed = ( const renderFallback = () => ; return ( -
+
renderFallback()}> ( - {url} -); +const CHAT_GIF_HEIGHT = 180; + +interface GifEmbedOptions { + readonly fixedSize?: boolean | undefined; +} + +const renderGifEmbed = ( + url: string, + options?: GifEmbedOptions +): ReactElement => { + if (options?.fixedSize) { + return ( + // Use fixed height to prevent vertical layout shift while preserving GIF aspect ratio. + Embedded GIF + ); + } + + return ( + // Markdown-style GIF embeds intentionally keep for animation support. + Embedded GIF + ); +}; const renderSeizeQuote = ( quoteLinkInfo: SeizeQuoteLinkInfo, diff --git a/components/waves/SmartLinkPreview.tsx b/components/waves/SmartLinkPreview.tsx index e777c5e1c7..83591acb34 100644 --- a/components/waves/SmartLinkPreview.tsx +++ b/components/waves/SmartLinkPreview.tsx @@ -84,7 +84,13 @@ export default function SmartLinkPreview({ const { variant: contextVariant } = useLinkPreviewContext(); const resolvedVariant = variant ?? contextVariant; const stableHref = ensureStableSeizeLink(href); - const handlers = useMemo(() => createLinkHandlers(), []); + const handlers = useMemo( + () => + createLinkHandlers({ + linkPreviewVariant: resolvedVariant, + }), + [resolvedVariant] + ); const parsedUrl = parseUrl(stableHref); const fallbackRenderer = renderFallback ?? (() => ); From 01c478d063691daf1f03881ca69458a5c2637570 Mon Sep 17 00:00:00 2001 From: Simo Date: Tue, 10 Feb 2026 07:24:56 -0400 Subject: [PATCH 3/3] wip Signed-off-by: Simo --- .../drops/view/part/dropPartMarkdown/linkHandlers.tsx | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/components/drops/view/part/dropPartMarkdown/linkHandlers.tsx b/components/drops/view/part/dropPartMarkdown/linkHandlers.tsx index 6628d19ea8..3cea8a4e89 100644 --- a/components/drops/view/part/dropPartMarkdown/linkHandlers.tsx +++ b/components/drops/view/part/dropPartMarkdown/linkHandlers.tsx @@ -61,7 +61,7 @@ export const createLinkRenderer = ({ onQuoteClick, currentDropId, hideLinkPreviews = false, - tweetPreviewMode, + tweetPreviewMode = "auto", }: LinkRendererConfig): LinkRenderer => { const seizeHandlers = createSeizeHandlers({ onQuoteClick, currentDropId }); const handlers = createLinkHandlers({ @@ -108,10 +108,7 @@ export const createLinkRenderer = ({ const tryRenderOpenGraph = () => { try { - const ogContent = renderOpenGraph(); - if (ogContent) { - return ogContent; - } + return renderOpenGraph(); } catch { // swallow and fall back to default anchor } @@ -136,9 +133,6 @@ export const createLinkRenderer = ({ const renderFromHandler = (handler: LinkHandler): ReactElement | null => { try { const rendered = handler.render(stableHref); - if (rendered === null || rendered === undefined) { - throw new Error("Link handler returned no content"); - } return renderHandlerContent(rendered); } catch { const ogContent = tryRenderOpenGraph();