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
81 changes: 52 additions & 29 deletions __tests__/components/waves/drops/ContentDisplay.test.tsx
Original file line number Diff line number Diff line change
@@ -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 <div data-testid={`segment-${props.index}`}>{props.segment.content}</div>; });
jest.mock(
"@/components/waves/drops/ContentSegmentComponent",
() => (props: any) => {
segmentProps.push(props);
return (
<div data-testid={`segment-${props.index}`}>{props.segment.content}</div>
);
}
);

jest.mock('@/components/waves/drops/MediaThumbnail', () =>
(props: any) => <div data-testid={`media-${props.media.url}`} />);
jest.mock("@/components/waves/drops/MediaThumbnail", () => (props: any) => (
<div data-testid={`media-${props.media.url}`} />
));

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(<ContentDisplay content={content} onReplyClick={onReplyClick} serialNo={5} />);
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(<ContentDisplay content={content} onClick={onClick} />);
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(<ContentDisplay content={content} onReplyClick={onReplyClick} />);
await userEvent.click(screen.getByText('world').closest('span')!);
expect(onReplyClick).not.toHaveBeenCalled();
it("does not call onClick when not provided", async () => {
render(<ContentDisplay content={content} />);
await userEvent.click(screen.getByText("world").closest("span")!);
expect(screen.getByText("world")).toBeInTheDocument();
});

it('renders all segments and media', () => {
render(<ContentDisplay content={content} onReplyClick={jest.fn()} />);
it("renders all segments and media", () => {
render(<ContentDisplay content={content} onClick={jest.fn()} />);
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(
<ContentDisplay
content={
{
segments: [],
apiMedia: [{ url: "gif-only", alt: "Media", type: "image" }],
} as any
}
/>
);
expect(screen.getByTestId("media-gif-only")).toBeInTheDocument();
});
});
45 changes: 45 additions & 0 deletions __tests__/components/waves/drops/media-utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
parseStandaloneMediaUrl,
isVideoMimeType,
processContent,
} from "@/components/waves/drops/media-utils";
Expand All @@ -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();
});
});
24 changes: 23 additions & 1 deletion components/waves/drops/WaveDropReply.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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;
Expand All @@ -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) {
Expand Down Expand Up @@ -65,7 +87,7 @@ export default function WaveDropReply({
{drop.author.handle}
</Link>
<ContentDisplay
content={content}
content={replyPreviewContent}
onClick={() => onReplyClick(drop.serial_no)}
className="tw-min-w-0 tw-flex-1 tw-overflow-hidden"
textClassName="tw-min-w-0 tw-overflow-hidden"
Expand Down
66 changes: 54 additions & 12 deletions components/waves/drops/media-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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");
Comment thread
simo6529 marked this conversation as resolved.

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,
Expand Down