diff --git a/__tests__/components/waves/drops/ContentDisplay.test.tsx b/__tests__/components/waves/drops/ContentDisplay.test.tsx index 1307ed5802..2ef16ec39e 100644 --- a/__tests__/components/waves/drops/ContentDisplay.test.tsx +++ b/__tests__/components/waves/drops/ContentDisplay.test.tsx @@ -1,46 +1,69 @@ -import React from 'react'; -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import ContentDisplay from '@/components/waves/drops/ContentDisplay'; +import React from "react"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import ContentDisplay from "@/components/waves/drops/ContentDisplay"; let segmentProps: any[] = []; -jest.mock('@/components/waves/drops/ContentSegmentComponent', () => - (props: any) => { segmentProps.push(props); return
{props.segment.content}
; }); +jest.mock( + "@/components/waves/drops/ContentSegmentComponent", + () => (props: any) => { + segmentProps.push(props); + return ( +
{props.segment.content}
+ ); + } +); -jest.mock('@/components/waves/drops/MediaThumbnail', () => - (props: any) =>
); +jest.mock("@/components/waves/drops/MediaThumbnail", () => (props: any) => ( +
+)); -describe('ContentDisplay', () => { - beforeEach(() => { segmentProps = []; }); +describe("ContentDisplay", () => { + beforeEach(() => { + segmentProps = []; + }); const content = { segments: [ - { type: 'text', content: 'hello' }, - { type: 'text', content: 'world' } + { type: "text", content: "hello" }, + { type: "text", content: "world" }, ], apiMedia: [ - { url: 'img1', mime_type: 'image/png', alt: '', type: 'image' }, - { url: 'img2', mime_type: 'image/png', alt: '', type: 'image' } - ] + { url: "img1", mime_type: "image/png", alt: "", type: "image" }, + { url: "img2", mime_type: "image/png", alt: "", type: "image" }, + ], } as any; - it('calls onReplyClick when clicked with serial number', async () => { - const onReplyClick = jest.fn(); - render(); - await userEvent.click(screen.getByText('hello').closest('span')!); - expect(onReplyClick).toHaveBeenCalledWith(5); + it("calls onClick when container is clicked", async () => { + const onClick = jest.fn(); + render(); + await userEvent.click(screen.getByText("hello").closest("span")!); + expect(onClick).toHaveBeenCalledTimes(1); }); - it('does not call onReplyClick without serial number', async () => { - const onReplyClick = jest.fn(); - render(); - await userEvent.click(screen.getByText('world').closest('span')!); - expect(onReplyClick).not.toHaveBeenCalled(); + it("does not call onClick when not provided", async () => { + render(); + await userEvent.click(screen.getByText("world").closest("span")!); + expect(screen.getByText("world")).toBeInTheDocument(); }); - it('renders all segments and media', () => { - render(); + it("renders all segments and media", () => { + render(); expect(segmentProps).toHaveLength(2); - expect(screen.getByTestId('media-img1')).toBeInTheDocument(); - expect(screen.getByTestId('media-img2')).toBeInTheDocument(); + expect(screen.getByTestId("media-img1")).toBeInTheDocument(); + expect(screen.getByTestId("media-img2")).toBeInTheDocument(); + }); + + it("renders media when there are no text segments", () => { + render( + + ); + expect(screen.getByTestId("media-gif-only")).toBeInTheDocument(); }); }); diff --git a/__tests__/components/waves/drops/media-utils.test.ts b/__tests__/components/waves/drops/media-utils.test.ts index f49d96a641..984aee82eb 100644 --- a/__tests__/components/waves/drops/media-utils.test.ts +++ b/__tests__/components/waves/drops/media-utils.test.ts @@ -1,4 +1,5 @@ import { + parseStandaloneMediaUrl, isVideoMimeType, processContent, } from "@/components/waves/drops/media-utils"; @@ -21,4 +22,48 @@ describe("media-utils", () => { expect(result.segments[1]?.mediaInfo?.type).toBe("video"); expect(result.segments[2]).toEqual({ type: "text", content: "end" }); }); + + it("parses standalone gif URLs into image media", () => { + expect(parseStandaloneMediaUrl("https://cdn.example.com/a.gif")).toEqual({ + alt: "Media", + url: "https://cdn.example.com/a.gif", + type: "image", + }); + }); + + it("parses gif hosts with query parameters as image media", () => { + expect( + parseStandaloneMediaUrl("https://media.tenor.com/abc/tenor.gif?itemid=1") + ).toEqual({ + alt: "Media", + url: "https://media.tenor.com/abc/tenor.gif?itemid=1", + type: "image", + }); + + expect( + parseStandaloneMediaUrl("https://media1.giphy.com/media/abc/giphy.gif") + ).toEqual({ + alt: "Media", + url: "https://media1.giphy.com/media/abc/giphy.gif", + type: "image", + }); + }); + + it("parses standalone video URLs into video media", () => { + expect(parseStandaloneMediaUrl("https://cdn.example.com/a.mp4")).toEqual({ + alt: "Media", + url: "https://cdn.example.com/a.mp4", + type: "video", + }); + }); + + it("returns null for non-media URLs", () => { + expect(parseStandaloneMediaUrl("https://example.com/page")).toBeNull(); + }); + + it("returns null when text contains non-url content", () => { + expect( + parseStandaloneMediaUrl("look https://cdn.example.com/a.gif") + ).toBeNull(); + }); }); diff --git a/components/waves/drops/WaveDropReply.tsx b/components/waves/drops/WaveDropReply.tsx index d7681af0c0..fa01694b5b 100644 --- a/components/waves/drops/WaveDropReply.tsx +++ b/components/waves/drops/WaveDropReply.tsx @@ -1,5 +1,6 @@ "use client"; +import { useMemo } from "react"; import Link from "next/link"; import Image from "next/image"; import type { ApiDrop } from "@/generated/models/ApiDrop"; @@ -8,6 +9,7 @@ import DropLoading from "./DropLoading"; import DropNotFound from "./DropNotFound"; import ContentDisplay from "./ContentDisplay"; import { resolveIpfsUrlSync } from "@/components/ipfs/IPFSContext"; +import { parseStandaloneMediaUrl } from "./media-utils"; interface WaveDropReplyProps { readonly dropId: string; @@ -31,6 +33,26 @@ export default function WaveDropReply({ dropPartId, maybeDrop ); + const replyPreviewContent = useMemo(() => { + if (content.apiMedia.length > 0 || content.segments.length !== 1) { + return content; + } + + const [segment] = content.segments; + if (segment?.type !== "text") { + return content; + } + + const standaloneMedia = parseStandaloneMediaUrl(segment.content); + if (!standaloneMedia) { + return content; + } + + return { + segments: [], + apiMedia: [standaloneMedia], + }; + }, [content]); const renderDropContent = () => { if (isLoading) { @@ -65,7 +87,7 @@ export default function WaveDropReply({ {drop.author.handle} onReplyClick(drop.serial_no)} className="tw-min-w-0 tw-flex-1 tw-overflow-hidden" textClassName="tw-min-w-0 tw-overflow-hidden" diff --git a/components/waves/drops/media-utils.ts b/components/waves/drops/media-utils.ts index cdd1172e77..12ac80286a 100644 --- a/components/waves/drops/media-utils.ts +++ b/components/waves/drops/media-utils.ts @@ -2,25 +2,27 @@ * Utility functions for handling media in wave drops */ +const STANDALONE_URL_REGEX = /^https?:\/\/[^\s<]+$/i; +const IMAGE_EXTENSIONS = [".gif", ".png", ".jpg", ".jpeg", ".webp", ".avif"]; +const VIDEO_EXTENSIONS = [ + ".mp4", + ".webm", + ".ogg", + ".mov", + ".avi", + ".wmv", + ".flv", + ".mkv", +]; + /** * Determines if a URL is pointing to a video based on extension or source */ const isVideoUrl = (url: string): boolean => { - // Check file extension - const videoExtensions = [ - ".mp4", - ".webm", - ".ogg", - ".mov", - ".avi", - ".wmv", - ".flv", - ".mkv", - ]; const lowercaseUrl = url.toLowerCase(); // Check if URL ends with a video extension - if (videoExtensions.some((ext) => lowercaseUrl.endsWith(ext))) { + if (VIDEO_EXTENSIONS.some((ext) => lowercaseUrl.endsWith(ext))) { return true; } @@ -139,6 +141,46 @@ const processContent = ( return result; }; +/** + * Converts a single-URL text segment into media preview metadata when possible. + */ +export const parseStandaloneMediaUrl = (text: string): MediaItem | null => { + const trimmed = text.trim(); + + if (!STANDALONE_URL_REGEX.test(trimmed)) { + return null; + } + + let parsedUrl: URL; + try { + parsedUrl = new URL(trimmed); + } catch { + return null; + } + + const host = parsedUrl.hostname.toLowerCase(); + const pathname = parsedUrl.pathname.toLowerCase(); + const isGifHost = host === "media.tenor.com" || host.endsWith(".giphy.com"); + + if (isGifHost || IMAGE_EXTENSIONS.some((ext) => pathname.endsWith(ext))) { + return { + alt: "Media", + url: trimmed, + type: "image", + }; + } + + if (VIDEO_EXTENSIONS.some((ext) => pathname.endsWith(ext))) { + return { + alt: "Media", + url: trimmed, + type: "video", + }; + } + + return null; +}; + export const buildProcessedContent = ( content: string | null | undefined, media: DropMediaInput[] | null | undefined,