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,