diff --git a/__tests__/components/drops/view/part/DropPartMarkdown.test.tsx b/__tests__/components/drops/view/part/DropPartMarkdown.test.tsx index c25d20de75..c596ca3c4e 100644 --- a/__tests__/components/drops/view/part/DropPartMarkdown.test.tsx +++ b/__tests__/components/drops/view/part/DropPartMarkdown.test.tsx @@ -355,13 +355,21 @@ describe("DropPartMarkdown", () => { }); it("renders YouTube previews with thumbnail and iframe interaction", async () => { + let resolvePreview: + | ((value: YoutubeOEmbedResponse | null) => void) + | undefined; + const previewPromise = new Promise( + (resolve) => { + resolvePreview = resolve; + } + ); const preview: YoutubeOEmbedResponse = { title: "Sample Video", author_name: "Sample Creator", thumbnail_url: "https://img.youtube.com/vi/sample/hqdefault.jpg", html: '', }; - mockFetchYoutubePreview.mockResolvedValue(preview); + mockFetchYoutubePreview.mockReturnValue(previewPromise); const content = "Watch this https://youtu.be/sample"; render( @@ -374,12 +382,22 @@ describe("DropPartMarkdown", () => { /> ); + const stableFrame = screen.getByTestId("youtube-preview-stable-frame"); + expect(stableFrame).toBeInTheDocument(); + expect(stableFrame.className).toContain("tw-h-[13rem]"); + expect(stableFrame.className).toContain("md:tw-h-[15rem]"); + + resolvePreview?.(preview); + const previewButton = await screen.findByRole("button", { name: /sample video/i, }); - expect( - await screen.findByRole("img", { name: /sample video/i }) - ).toHaveAttribute("src", preview.thumbnail_url); + const thumbnailImage = await screen.findByRole("img", { + name: /sample video/i, + }); + expect(thumbnailImage.getAttribute("src")).toContain( + encodeURIComponent(preview.thumbnail_url) + ); await userEvent.click(previewButton); @@ -388,6 +406,11 @@ describe("DropPartMarkdown", () => { expect(mockFetchYoutubePreview.mock.calls[0]?.[0]).toBe( "https://www.youtube.com/watch?v=sample" ); + + const title = screen.getByText("Sample Video"); + const author = screen.getByText("Sample Creator"); + expect(title.className).toContain("tw-line-clamp-2"); + expect(author.className).toContain("tw-line-clamp-1"); }); it("normalizes YouTube URLs before fetching preview data", async () => { @@ -467,8 +490,15 @@ describe("DropPartMarkdown", () => { /> ); - const fallbackLink = await screen.findByRole("link", { name: url }); + const stableFrame = screen.getByTestId("youtube-preview-stable-frame"); + expect(stableFrame.className).toContain("tw-h-[13rem]"); + expect(stableFrame.className).toContain("md:tw-h-[15rem]"); + + const fallbackLink = await screen.findByTestId( + "youtube-preview-fallback-link" + ); expect(fallbackLink).toHaveAttribute("href", url); + expect(fallbackLink).toHaveTextContent(/failed to load youtube preview/i); }); it("falls back to a link when YouTube preview rejects", async () => { @@ -485,8 +515,15 @@ describe("DropPartMarkdown", () => { /> ); - const fallbackLink = await screen.findByRole("link", { name: url }); + const stableFrame = screen.getByTestId("youtube-preview-stable-frame"); + expect(stableFrame.className).toContain("tw-h-[13rem]"); + expect(stableFrame.className).toContain("md:tw-h-[15rem]"); + + const fallbackLink = await screen.findByTestId( + "youtube-preview-fallback-link" + ); expect(fallbackLink).toHaveAttribute("href", url); + expect(fallbackLink).toHaveTextContent(/failed to load youtube preview/i); }); it("falls back to a link when YouTube preview returns null", async () => { @@ -503,8 +540,15 @@ describe("DropPartMarkdown", () => { /> ); - const fallbackLink = await screen.findByRole("link", { name: url }); + const stableFrame = screen.getByTestId("youtube-preview-stable-frame"); + expect(stableFrame.className).toContain("tw-h-[13rem]"); + expect(stableFrame.className).toContain("md:tw-h-[15rem]"); + + const fallbackLink = await screen.findByTestId( + "youtube-preview-fallback-link" + ); expect(fallbackLink).toHaveAttribute("href", url); + expect(fallbackLink).toHaveTextContent(/youtube preview unavailable/i); }); it("lazy loads tweet embeds with a loading skeleton", async () => { diff --git a/__tests__/components/waves/LinkPreviewCard.test.tsx b/__tests__/components/waves/LinkPreviewCard.test.tsx index c40fac6171..1e6546daec 100644 --- a/__tests__/components/waves/LinkPreviewCard.test.tsx +++ b/__tests__/components/waves/LinkPreviewCard.test.tsx @@ -4,7 +4,11 @@ import React from "react"; import LinkPreviewCard from "@/components/waves/LinkPreviewCard"; const mockOpenGraphPreview = jest.fn(({ href, preview }: any) => ( -
+
)); const mockEnsPreviewCard = jest.fn(({ preview }: any) => ( @@ -12,7 +16,9 @@ const mockEnsPreviewCard = jest.fn(({ preview }: any) => ( )); jest.mock("@/components/waves/OpenGraphPreview", () => { - const actual = jest.requireActual("../../../components/waves/OpenGraphPreview"); + const actual = jest.requireActual( + "../../../components/waves/OpenGraphPreview" + ); return { __esModule: true, ...actual, @@ -31,6 +37,18 @@ jest.mock("@/services/api/link-preview-api", () => ({ describe("LinkPreviewCard", () => { const { fetchLinkPreview } = require("@/services/api/link-preview-api"); + const assertStableFrame = () => { + const frame = screen.getByTestId("link-preview-card-stable-frame"); + expect(frame).toHaveClass( + "tw-h-[10rem]", + "tw-min-h-[10rem]", + "tw-max-h-[10rem]", + "md:tw-h-[11rem]", + "md:tw-min-h-[11rem]", + "md:tw-max-h-[11rem]" + ); + return frame; + }; beforeEach(() => { jest.clearAllMocks(); @@ -50,8 +68,12 @@ describe("LinkPreviewCard", () => { /> ); + assertStableFrame(); expect(mockOpenGraphPreview).toHaveBeenCalledWith( - expect.objectContaining({ href: "https://example.com/article", preview: undefined }) + expect.objectContaining({ + href: "https://example.com/article", + preview: undefined, + }) ); await waitFor(() => @@ -63,8 +85,11 @@ describe("LinkPreviewCard", () => { ) ); - expect(fetchLinkPreview).toHaveBeenCalledWith("https://example.com/article"); + expect(fetchLinkPreview).toHaveBeenCalledWith( + "https://example.com/article" + ); expect(screen.queryByTestId("fallback")).toBeNull(); + assertStableFrame(); }); it("renders fallback when preview lacks useful content", async () => { @@ -80,6 +105,7 @@ describe("LinkPreviewCard", () => { await waitFor(() => { expect(screen.getByTestId("fallback")).toBeInTheDocument(); }); + assertStableFrame(); }); it("renders fallback when request fails", async () => { @@ -95,10 +121,14 @@ describe("LinkPreviewCard", () => { await waitFor(() => { expect(screen.getByTestId("fallback")).toBeInTheDocument(); }); + assertStableFrame(); }); it("renders ENS previews when ENS data is returned", async () => { - fetchLinkPreview.mockResolvedValue({ type: "ens.name", name: "vitalik.eth" }); + fetchLinkPreview.mockResolvedValue({ + type: "ens.name", + name: "vitalik.eth", + }); render( { await waitFor(() => { expect(mockEnsPreviewCard).toHaveBeenCalledWith( - expect.objectContaining({ preview: expect.objectContaining({ type: "ens.name" }) }) + expect.objectContaining({ + preview: expect.objectContaining({ type: "ens.name" }), + }) ); }); expect(screen.queryByTestId("fallback")).toBeNull(); + assertStableFrame(); + }); + + it("does not enforce chat stable frame for home variant", async () => { + fetchLinkPreview.mockResolvedValue({}); + + render( +
fallback
} + /> + ); + + await waitFor(() => { + expect(screen.getByTestId("fallback")).toBeInTheDocument(); + }); + + expect(screen.queryByTestId("link-preview-card-stable-frame")).toBeNull(); }); }); diff --git a/__tests__/components/waves/OpenGraphPreview.test.tsx b/__tests__/components/waves/OpenGraphPreview.test.tsx index c24221c82c..9a4a5b5730 100644 --- a/__tests__/components/waves/OpenGraphPreview.test.tsx +++ b/__tests__/components/waves/OpenGraphPreview.test.tsx @@ -1,21 +1,24 @@ -import { render, screen } from '@testing-library/react'; -import React from 'react'; +import { render, screen } from "@testing-library/react"; +import React from "react"; -import OpenGraphPreview from '@/components/waves/OpenGraphPreview'; +import OpenGraphPreview from "@/components/waves/OpenGraphPreview"; -jest.mock('next/link', () => ({ +jest.mock("next/link", () => ({ __esModule: true, default: ({ href, children, ...rest }: any) => ( - + {children} ), })); -jest.mock('next/image', () => ({ +jest.mock("next/image", () => ({ __esModule: true, default: function MockNextImage({ - alt = '', + alt = "", unoptimized: _unoptimized, fill: _fill, ...rest @@ -24,125 +27,144 @@ jest.mock('next/image', () => ({ }, })); -jest.mock('@/helpers/Helpers', () => ({ - removeBaseEndpoint: jest.fn((url: string) => url.replace('https://example.com', '')), +jest.mock("@/helpers/Helpers", () => ({ + removeBaseEndpoint: jest.fn((url: string) => + url.replace("https://example.com", "") + ), })); -jest.mock('@/components/waves/ChatItemHrefButtons', () => ({ +jest.mock("@/components/waves/ChatItemHrefButtons", () => ({ __esModule: true, default: function MockChatItemHrefButtons(props: any) { return ( -
{props.relativeHref ?? 'undefined'}
+
{props.relativeHref ?? "undefined"}
); }, })); -const { removeBaseEndpoint } = require('@/helpers/Helpers'); +const { removeBaseEndpoint } = require("@/helpers/Helpers"); -describe('OpenGraphPreview', () => { +describe("OpenGraphPreview", () => { beforeEach(() => { jest.clearAllMocks(); }); - it('renders loading skeleton when preview is undefined', () => { - (removeBaseEndpoint as jest.Mock).mockReturnValue('/article'); + it("renders loading skeleton when preview is undefined", () => { + (removeBaseEndpoint as jest.Mock).mockReturnValue("/article"); render(); - expect(screen.getByTestId('og-preview-skeleton')).toBeInTheDocument(); - expect(screen.getByTestId('href-buttons')).toHaveTextContent('/article'); - expect(removeBaseEndpoint).toHaveBeenCalledWith('https://example.com/article'); + const skeleton = screen.getByTestId("og-preview-skeleton"); + expect(skeleton).toBeInTheDocument(); + expect(skeleton.parentElement).toHaveClass("tw-h-full"); + expect(screen.getByTestId("href-buttons")).toHaveTextContent("/article"); + expect(removeBaseEndpoint).toHaveBeenCalledWith( + "https://example.com/article" + ); }); - it('renders fallback when preview is unavailable', () => { - (removeBaseEndpoint as jest.Mock).mockReturnValue('/article'); + it("renders fallback when preview is unavailable", () => { + (removeBaseEndpoint as jest.Mock).mockReturnValue("/article"); - render(); + render( + + ); - expect(screen.getByTestId('og-preview-unavailable')).toBeInTheDocument(); - const link = screen.getByRole('link', { name: 'example.com' }); - expect(link).toHaveAttribute('href', '/article'); - expect(link).not.toHaveAttribute('target'); - expect(screen.getByTestId('href-buttons')).toHaveTextContent('/article'); + const unavailable = screen.getByTestId("og-preview-unavailable"); + expect(unavailable).toBeInTheDocument(); + expect(unavailable).toHaveClass("tw-h-full"); + const link = screen.getByRole("link", { name: "example.com" }); + expect(link).toHaveAttribute("href", "/article"); + expect(link).not.toHaveAttribute("target"); + expect(screen.getByTestId("href-buttons")).toHaveTextContent("/article"); }); - it('renders preview details when data is provided', () => { - (removeBaseEndpoint as jest.Mock).mockReturnValue('/article'); + it("renders preview details when data is provided", () => { + (removeBaseEndpoint as jest.Mock).mockReturnValue("/article"); render( ); - expect(screen.getByTestId('og-preview-card')).toBeInTheDocument(); - expect(screen.getByText('Example.com')).toBeInTheDocument(); - const titleLinks = screen.getAllByRole('link', { name: 'Example Title' }); + const card = screen.getByTestId("og-preview-card"); + expect(card).toBeInTheDocument(); + expect(card).toHaveClass("tw-h-full"); + expect(screen.getByText("Example.com")).toBeInTheDocument(); + const titleLinks = screen.getAllByRole("link", { name: "Example Title" }); expect(titleLinks).toHaveLength(2); const titleLink = titleLinks[1]; - expect(titleLink).toHaveAttribute('href', '/article'); - expect(titleLink).not.toHaveAttribute('target'); - expect(screen.getByAltText('Example Title')).toHaveAttribute('src', 'https://cdn.example.com/preview.png'); - expect(screen.getByText('An example description')).toBeInTheDocument(); - expect(screen.getByTestId('href-buttons')).toHaveTextContent('/article'); + expect(titleLink).toHaveAttribute("href", "/article"); + expect(titleLink).not.toHaveAttribute("target"); + const image = screen.getByAltText("Example Title"); + expect(image).toHaveAttribute("src", "https://cdn.example.com/preview.png"); + expect(image.parentElement).toHaveClass("tw-aspect-[16/9]"); + expect(screen.getByText("An example description")).toBeInTheDocument(); + expect(screen.getByTestId("href-buttons")).toHaveTextContent("/article"); }); - it('handles external links and image arrays', () => { - (removeBaseEndpoint as jest.Mock).mockReturnValue('https://othersite.com/post'); + it("handles external links and image arrays", () => { + (removeBaseEndpoint as jest.Mock).mockReturnValue( + "https://othersite.com/post" + ); render( ); - const card = screen.getByTestId('og-preview-card'); + const card = screen.getByTestId("og-preview-card"); expect(card).toBeInTheDocument(); - const links = screen.getAllByRole('link'); + const links = screen.getAllByRole("link"); links.forEach((link) => { - expect(link).toHaveAttribute('href', 'https://othersite.com/post'); - expect(link).toHaveAttribute('target', '_blank'); - expect(link).toHaveAttribute('rel', 'noopener noreferrer'); + expect(link).toHaveAttribute("href", "https://othersite.com/post"); + expect(link).toHaveAttribute("target", "_blank"); + expect(link).toHaveAttribute("rel", "noopener noreferrer"); }); - expect(screen.getByAltText('othersite.com')).toHaveAttribute('src', 'https://cdn.othersite.com/img.jpg'); - expect(screen.getByTestId('href-buttons')).toHaveTextContent('undefined'); + expect(screen.getByAltText("othersite.com")).toHaveAttribute( + "src", + "https://cdn.othersite.com/img.jpg" + ); + expect(screen.getByTestId("href-buttons")).toHaveTextContent("undefined"); }); - it('uses secureUrl fields when provided', () => { - (removeBaseEndpoint as jest.Mock).mockReturnValue('/article'); + it("uses secureUrl fields when provided", () => { + (removeBaseEndpoint as jest.Mock).mockReturnValue("/article"); render( ); - expect(screen.getByAltText('Secure Image')).toHaveAttribute( - 'src', - 'https://cdn.example.com/secure.png' + expect(screen.getByAltText("Secure Image")).toHaveAttribute( + "src", + "https://cdn.example.com/secure.png" ); }); - it('wraps long unbroken segments to keep layout consistent', () => { - (removeBaseEndpoint as jest.Mock).mockReturnValue('/article'); + it("wraps long unbroken segments to keep layout consistent", () => { + (removeBaseEndpoint as jest.Mock).mockReturnValue("/article"); - const longUrl = `https://example.com/${'a'.repeat(48)}`; + const longUrl = `https://example.com/${"a".repeat(48)}`; render( { ); const wrappedSegment = screen.getByText(longUrl); - expect(wrappedSegment.tagName).toBe('SPAN'); - expect(wrappedSegment).toHaveClass('tw-break-all'); + expect(wrappedSegment.tagName).toBe("SPAN"); + expect(wrappedSegment).toHaveClass("tw-break-all"); }); - }); diff --git a/__tests__/components/waves/drops/WaveDropReply.test.tsx b/__tests__/components/waves/drops/WaveDropReply.test.tsx index 88ffc087ee..4ae227686a 100644 --- a/__tests__/components/waves/drops/WaveDropReply.test.tsx +++ b/__tests__/components/waves/drops/WaveDropReply.test.tsx @@ -1,37 +1,87 @@ -import { render, screen } from '@testing-library/react'; -import React from 'react'; -import WaveDropReply from '@/components/waves/drops/WaveDropReply'; +import { render, screen, within } from "@testing-library/react"; +import React from "react"; +import WaveDropReply from "@/components/waves/drops/WaveDropReply"; -jest.mock('@/components/waves/drops/DropLoading', () => () =>
); -jest.mock('@/components/waves/drops/DropNotFound', () => () =>
); -jest.mock('@/components/waves/drops/ContentDisplay', () => () =>
); +jest.mock("@/components/waves/drops/DropLoading", () => () => ( +
+)); +jest.mock("@/components/waves/drops/DropNotFound", () => () => ( +
+)); +const mockContentDisplaySpy = jest.fn(); +jest.mock("@/components/waves/drops/ContentDisplay", () => (props: any) => { + mockContentDisplaySpy(props); + return
; +}); -const hookData: any = { drop: null, content: { segments: [] }, isLoading: false }; +const hookData: any = { + drop: null, + content: { segments: [] }, + isLoading: false, +}; -jest.mock('@/components/waves/drops/useDropContent', () => ({ +jest.mock("@/components/waves/drops/useDropContent", () => ({ useDropContent: () => hookData, })); -describe('WaveDropReply', () => { - const baseProps = { dropId: 'd', dropPartId: 1, maybeDrop: null, onReplyClick: jest.fn() }; +describe("WaveDropReply", () => { + const baseProps = { + dropId: "d", + dropPartId: 1, + maybeDrop: null, + onReplyClick: jest.fn(), + }; + + beforeEach(() => { + mockContentDisplaySpy.mockClear(); + }); + + const expectFixedContainer = () => { + const container = screen.getByTestId("wave-drop-reply-fixed-container"); + expect(container).toHaveClass("tw-h-[24px]"); + expect(container).toHaveClass("tw-min-h-[24px]"); + expect(container).toHaveClass("tw-max-h-[24px]"); + expect(container).toHaveClass("tw-overflow-hidden"); + return container; + }; - it('shows loader when loading', () => { + it("shows loader when loading", () => { hookData.isLoading = true; - const { getByTestId } = render(); - expect(getByTestId('loading')).toBeInTheDocument(); + render(); + const fixedContainer = expectFixedContainer(); + expect(within(fixedContainer).getByTestId("loading")).toBeInTheDocument(); }); - it('shows not found when author missing', () => { + it("shows not found when author missing", () => { hookData.isLoading = false; hookData.drop = { author: { handle: null } } as any; - const { getByTestId } = render(); - expect(getByTestId('not-found')).toBeInTheDocument(); + render(); + const fixedContainer = expectFixedContainer(); + expect(within(fixedContainer).getByTestId("not-found")).toBeInTheDocument(); + }); + + it("shows not found when drop is empty", () => { + hookData.isLoading = false; + hookData.drop = null; + render(); + const fixedContainer = expectFixedContainer(); + expect(within(fixedContainer).getByTestId("not-found")).toBeInTheDocument(); }); - it('renders content when drop valid', () => { - hookData.drop = { author: { handle: 'alice', pfp: null }, serial_no: 1 } as any; - const { getByTestId } = render(); - expect(getByTestId('content')).toBeInTheDocument(); - expect(screen.getByText('alice')).toBeInTheDocument(); + it("renders content when drop valid", () => { + hookData.drop = { + author: { handle: "alice", pfp: null }, + serial_no: 1, + } as any; + render(); + const fixedContainer = expectFixedContainer(); + expect(within(fixedContainer).getByTestId("content")).toBeInTheDocument(); + expect(screen.getByText("alice")).toBeInTheDocument(); + expect(mockContentDisplaySpy).toHaveBeenCalled(); + const lastCallProps = + mockContentDisplaySpy.mock.calls[ + mockContentDisplaySpy.mock.calls.length - 1 + ][0]; + expect(lastCallProps.textClassName).not.toContain("tw-block"); }); }); diff --git a/components/drops/view/part/dropPartMarkdown/youtubePreview.tsx b/components/drops/view/part/dropPartMarkdown/youtubePreview.tsx index d8bf11f916..8eecef7b97 100644 --- a/components/drops/view/part/dropPartMarkdown/youtubePreview.tsx +++ b/components/drops/view/part/dropPartMarkdown/youtubePreview.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useState, type ReactElement } from "react"; import Image from "next/image"; import type { YoutubeOEmbedResponse } from "@/services/api/youtube"; import { fetchYoutubePreview } from "@/services/api/youtube"; @@ -221,13 +221,24 @@ interface YoutubePreviewProps { readonly href: string; } +type PreviewStatus = "loading" | "success" | "error" | "empty"; + type PreviewState = { readonly href: string; + readonly status: PreviewStatus; readonly preview: YoutubeOEmbedResponse | null; - readonly hasError: boolean; readonly showEmbed: boolean; }; +const YOUTUBE_STABLE_FRAME_CLASSES = + "tw-h-[13rem] tw-min-h-[13rem] tw-max-h-[13rem] tw-w-full md:tw-h-[15rem] md:tw-min-h-[15rem] md:tw-max-h-[15rem]"; + +const YOUTUBE_CARD_CLASSES = + "tw-flex tw-h-full tw-w-full tw-flex-col tw-overflow-hidden tw-rounded-lg"; + +const YOUTUBE_META_CLASSES = + "tw-flex tw-h-11 tw-shrink-0 tw-flex-col tw-justify-center tw-gap-y-0.5 tw-overflow-hidden tw-border-0 tw-border-t tw-border-solid tw-border-iron-800 tw-bg-iron-900/95 tw-px-3"; + const YoutubePreview = ({ href }: YoutubePreviewProps) => { const linkInfo = useMemo(() => parseYoutubeLink(href), [href]); if (!linkInfo) { @@ -238,21 +249,23 @@ const YoutubePreview = ({ href }: YoutubePreviewProps) => { const { hideActions } = useLinkPreviewContext(); const [state, setState] = useState(() => ({ href, + status: "loading", preview: null, - hasError: false, showEmbed: false, })); const isCurrent = state.href === href; + const status: PreviewStatus = isCurrent ? state.status : "loading"; const preview = isCurrent ? state.preview : null; - const hasError = isCurrent ? state.hasError : false; const showEmbed = isCurrent ? state.showEmbed : false; + const hasPreview = status === "success" && preview !== null; + const isLoading = status === "loading"; const handleShowEmbed = () => { setState((prev) => prev.href === href ? { ...prev, showEmbed: true } - : { href, preview: null, hasError: false, showEmbed: true } + : { href, status: "loading", preview: null, showEmbed: false } ); }; @@ -277,8 +290,13 @@ const YoutubePreview = ({ href }: YoutubePreviewProps) => { if (!sanitizedHtml) { setState((prev) => prev.href === href - ? { ...prev, preview: null, hasError: true, showEmbed: false } - : { href, preview: null, hasError: true, showEmbed: false } + ? { + ...prev, + status: "error", + preview: null, + showEmbed: false, + } + : { href, status: "error", preview: null, showEmbed: false } ); return; } @@ -290,11 +308,11 @@ const YoutubePreview = ({ href }: YoutubePreviewProps) => { setState((prev) => prev.href === href - ? { ...prev, preview: normalizedPreview, hasError: false } + ? { ...prev, status: "success", preview: normalizedPreview } : { href, + status: "success", preview: normalizedPreview, - hasError: false, showEmbed: false, } ); @@ -303,8 +321,13 @@ const YoutubePreview = ({ href }: YoutubePreviewProps) => { setState((prev) => prev.href === href - ? { ...prev, preview: null, hasError: true, showEmbed: false } - : { href, preview: null, hasError: true, showEmbed: false } + ? { + ...prev, + status: "empty", + preview: null, + showEmbed: false, + } + : { href, status: "empty", preview: null, showEmbed: false } ); } catch (error) { if (!isActive) { @@ -317,8 +340,13 @@ const YoutubePreview = ({ href }: YoutubePreviewProps) => { setState((prev) => prev.href === href - ? { ...prev, preview: null, hasError: true, showEmbed: false } - : { href, preview: null, hasError: true, showEmbed: false } + ? { + ...prev, + status: "error", + preview: null, + showEmbed: false, + } + : { href, status: "error", preview: null, showEmbed: false } ); } }; @@ -331,79 +359,114 @@ const YoutubePreview = ({ href }: YoutubePreviewProps) => { }; }, [href, videoId]); - if (hasError) { - throw new Error("Failed to load YouTube preview"); - } + const card = (): ReactElement => { + if (isLoading) { + return ( +
+
+
+
+
+
+
+ ); + } + + if (hasPreview) { + const ariaLabel = preview.title + ? `Play YouTube video ${preview.title}` + : `Play YouTube video ${videoId}`; + + return ( +
+
+ {showEmbed ? ( +
+ ) : ( + + )} +
+
+

+ {preview.title || "YouTube video"} +

+

+ {preview.author_name ?? "YouTube"} +

+
+
+ ); + } + + const fallbackMessage = + status === "error" + ? "Failed to load YouTube preview" + : "YouTube preview unavailable"; - if (!preview) { return ( -
-
- +
+ + Open on YouTube + + + {href} + +
+
); - } - - const ariaLabel = preview.title - ? `Play YouTube video ${preview.title}` - : `Play YouTube video ${videoId}`; + }; return (
-
-
- {showEmbed ? ( -
- ) : ( - - )} -
-
- {preview.title && ( -

- {preview.title} -

- )} - {preview.author_name && ( -

- {preview.author_name} -

- )} -
+
+ {card()}
{!hideActions && }
diff --git a/components/waves/LinkPreviewCard.tsx b/components/waves/LinkPreviewCard.tsx index 9972e351bc..68b7d43dea 100644 --- a/components/waves/LinkPreviewCard.tsx +++ b/components/waves/LinkPreviewCard.tsx @@ -31,6 +31,9 @@ type PreviewState = } | { readonly type: "ens"; readonly href: string; readonly data: EnsPreview }; +const CHAT_STABLE_FRAME_CLASSES = + "tw-h-[10rem] tw-min-h-[10rem] tw-max-h-[10rem] tw-w-full md:tw-h-[11rem] md:tw-min-h-[11rem] md:tw-max-h-[11rem]"; + const toPreviewData = ( response: Awaited> ): OpenGraphPreviewData => { @@ -56,6 +59,7 @@ export default function LinkPreviewCard({ }: LinkPreviewCardProps) { const contextVariant = useLinkPreviewVariant(); const resolvedVariant = variant ?? contextVariant; + const shouldUseStableFrame = resolvedVariant === "chat"; const [state, setState] = useState(() => ({ type: "loading", href, @@ -95,58 +99,69 @@ export default function LinkPreviewCard({ }, [href]); const isCurrent = state.href === href; + let content: ReactElement; if (isCurrent && state.type === "fallback") { const fallbackContent = renderFallback(); - - return ( + content = (
-
+
{fallbackContent}
); - } - - if (isCurrent && state.type === "success") { - return ( + } else if (isCurrent && state.type === "success") { + content = ( ); - } - - if (isCurrent && state.type === "ens") { - return ( + } else if (isCurrent && state.type === "ens") { + content = (
- +
+ +
); + } else { + content = ( + + ); + } + + if (!shouldUseStableFrame) { + return content; } return ( - +
+ {content} +
); } diff --git a/components/waves/OpenGraphPreview.tsx b/components/waves/OpenGraphPreview.tsx index b4cdcd348f..15aeef2118 100644 --- a/components/waves/OpenGraphPreview.tsx +++ b/components/waves/OpenGraphPreview.tsx @@ -256,8 +256,8 @@ export function LinkPreviewCardLayout({ } return ( -
-
+
+
{children}
@@ -313,12 +313,12 @@ export default function OpenGraphPreview({ return ( -
+
-
+
@@ -363,10 +363,10 @@ export default function OpenGraphPreview({ return (
-
+

Link unavailable

@@ -374,7 +374,7 @@ export default function OpenGraphPreview({ href={effectiveHref} target={linkTarget} rel={linkRel} - className="tw-[overflow-wrap:anywhere] tw-break-words tw-text-sm tw-font-semibold tw-text-iron-100 tw-no-underline tw-transition tw-duration-200 hover:tw-text-white" + className="tw-[overflow-wrap:anywhere] tw-line-clamp-2 tw-block tw-break-words tw-text-sm tw-font-semibold tw-text-iron-100 tw-no-underline tw-transition tw-duration-200 hover:tw-text-white" > {wrapLongUnbrokenSegments(domain ?? href)} @@ -392,7 +392,7 @@ export default function OpenGraphPreview({ target={linkTarget} rel={linkRel} onClick={(e) => e.stopPropagation()} - className="tw-relative tw-block tw-h-full tw-w-full tw-min-h-0 tw-overflow-hidden tw-rounded-t-xl tw-border tw-border-solid tw-border-white/10 tw-bg-black/30 tw-no-underline" + className="tw-relative tw-block tw-h-full tw-min-h-0 tw-w-full tw-overflow-hidden tw-rounded-t-xl tw-border tw-border-solid tw-border-white/10 tw-bg-black/30 tw-no-underline" data-testid="og-preview-card" > {imageUrl && ( @@ -427,18 +427,18 @@ export default function OpenGraphPreview({ ) : (
-
+
{imageUrl && ( -
+
{title )} -
+
{domain && ( - + {wrapLongUnbrokenSegments(domain)} )} @@ -462,12 +462,12 @@ export default function OpenGraphPreview({ href={effectiveHref} target={linkTarget} rel={linkRel} - className="tw-[overflow-wrap:anywhere] tw-break-words tw-text-lg tw-font-semibold tw-leading-snug tw-text-iron-100 tw-no-underline tw-transition tw-duration-200 hover:tw-text-white" + className="tw-[overflow-wrap:anywhere] tw-line-clamp-2 tw-block tw-break-words tw-text-base tw-font-semibold tw-leading-snug tw-text-iron-100 tw-no-underline tw-transition tw-duration-200 hover:tw-text-white" > {wrapLongUnbrokenSegments(title ?? domain ?? href)} {description && ( -

+

{wrapLongUnbrokenSegments(description)}

)} diff --git a/components/waves/drops/WaveDropReply.tsx b/components/waves/drops/WaveDropReply.tsx index 2ae439828c..d7681af0c0 100644 --- a/components/waves/drops/WaveDropReply.tsx +++ b/components/waves/drops/WaveDropReply.tsx @@ -25,6 +25,7 @@ export default function WaveDropReply({ maybeDrop, onReplyClick, }: WaveDropReplyProps) { + const fixedReplyHeightClasses = "tw-h-[24px] tw-min-h-[24px] tw-max-h-[24px]"; const { drop, content, isLoading } = useDropContent( dropId, dropPartId, @@ -41,7 +42,7 @@ export default function WaveDropReply({ } return ( -
+
{drop.author.pfp ? ( )}
-
-

+

+

{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" />

@@ -80,7 +83,12 @@ export default function WaveDropReply({ style={{ height: "calc(100% - 3px)" }} >
- {renderDropContent()} +
+ {renderDropContent()} +
);