diff --git a/__tests__/components/DropListItemContentMediaImage.test.tsx b/__tests__/components/DropListItemContentMediaImage.test.tsx index 3491d24f99..fbaabae16d 100644 --- a/__tests__/components/DropListItemContentMediaImage.test.tsx +++ b/__tests__/components/DropListItemContentMediaImage.test.tsx @@ -1,48 +1,116 @@ -import { render, screen, fireEvent } from '@testing-library/react'; -import React from 'react'; -import DropListItemContentMediaImage from '@/components/drops/view/item/content/media/DropListItemContentMediaImage'; +import { fireEvent, render, screen } from "@testing-library/react"; +import React from "react"; +import DropListItemContentMediaImage from "@/components/drops/view/item/content/media/DropListItemContentMediaImage"; -jest.mock('@/helpers/image.helpers', () => ({ +jest.mock("@/helpers/image.helpers", () => ({ getScaledImageUri: (_src: string) => _src, - ImageScale: { AUTOx450: 'AUTOx450', AUTOx1080: 'AUTOx1080' }, + responsiveDropImageLoader: jest.fn(), + ImageScale: { AUTOx450: "AUTOx450", AUTOx1080: "AUTOx1080" }, })); -jest.mock('@/helpers/Helpers', () => ({ +jest.mock("@/helpers/Helpers", () => ({ fullScreenSupported: () => true, })); -jest.mock('@/hooks/useCapacitor', () => ({ __esModule: true, default: () => ({ isCapacitor: false }) })); +jest.mock("@/components/common/FallbackImage", () => { + const React = require("react"); -jest.mock('@/hooks/useInView', () => ({ + return { + FallbackImage: React.forwardRef( + ( + { + alt, + primarySrc, + fallbackSrc, + optimize, + loader, + onClick, + onError, + onLoad, + }: any, + ref: any + ) => ( + {alt} + ) + ), + }; +}); + +jest.mock("@/hooks/useCapacitor", () => ({ + __esModule: true, + default: () => ({ isCapacitor: false }), +})); + +jest.mock("@/hooks/useInView", () => ({ useInView: () => [jest.fn(), true], })); beforeEach(() => { - (global as any).ResizeObserver = class { observe(){} disconnect(){} }; + (global as any).ResizeObserver = class { + observe() {} + disconnect() {} + }; }); -describe('DropListItemContentMediaImage', () => { - it('calls onContainerClick from modal button', () => { +describe("DropListItemContentMediaImage", () => { + it("calls onContainerClick from modal button", () => { const onContainerClick = jest.fn(); - render(); - const img = screen.getByAltText('Drop media'); + render( + + ); + const img = screen.getByAltText("Drop media"); fireEvent.load(img); fireEvent.click(img); - fireEvent.click(screen.getByLabelText('View drop details')); + fireEvent.click(screen.getByLabelText("View drop details")); expect(onContainerClick).toHaveBeenCalled(); }); - it('does not open modal when disableModal is true', () => { + it("does not open modal when disableModal is true", () => { render(); - const img = screen.getByAltText('Drop media'); + const img = screen.getByAltText("Drop media"); fireEvent.load(img); fireEvent.click(img); - expect(screen.queryByLabelText('View drop details')).not.toBeInTheDocument(); + expect( + screen.queryByLabelText("View drop details") + ).not.toBeInTheDocument(); + }); + + it("keeps normal images unoptimized without a custom loader", () => { + render(); + + const img = screen.getByAltText("Drop media"); + expect(img).toHaveAttribute("data-optimize", "false"); + expect(img).toHaveAttribute("data-has-loader", "false"); + expect(img).toHaveAttribute("data-fallback-src", "img"); + }); + + it("uses the responsive loader only when responsive srcset mode is enabled", () => { + render( + + ); + + const img = screen.getByAltText("Drop media"); + expect(img).toHaveAttribute("data-optimize", "true"); + expect(img).toHaveAttribute("data-has-loader", "true"); }); }); -describe('DropListItemContentMediaImage retry', () => { - it('shows error and retries manually', () => { +describe("DropListItemContentMediaImage retry", () => { + it("shows error and retries manually", () => { render(); expect(screen.getByText("Couldn’t load image.")).toBeInTheDocument(); }); diff --git a/__tests__/components/common/FallbackImage.test.tsx b/__tests__/components/common/FallbackImage.test.tsx index 333bcece15..025772e46a 100644 --- a/__tests__/components/common/FallbackImage.test.tsx +++ b/__tests__/components/common/FallbackImage.test.tsx @@ -4,19 +4,50 @@ import { forwardRef, type ComponentProps } from "react"; type MockNextImageProps = ComponentProps<"img"> & { readonly fill?: boolean | undefined; readonly unoptimized?: boolean | undefined; + readonly loader?: + | ((props: { + readonly src: string; + readonly width: number; + readonly quality?: number | undefined; + }) => string) + | undefined; }; jest.mock("next/image", () => ({ __esModule: true, default: forwardRef( // eslint-disable-next-line react/display-name - ({ fill: _fill, unoptimized: _unoptimized, alt, ...rest }, ref) => ( - {alt - ) + ({ fill: _fill, unoptimized, loader, alt, src, ...rest }, ref) => { + const shouldUseLoader = + typeof src === "string" && !!loader && !unoptimized; + const renderedSrc = shouldUseLoader ? loader({ src, width: 1080 }) : src; + const srcSet = shouldUseLoader + ? `${loader({ src, width: 450 })} 450w, ${loader({ + src, + width: 1080, + })} 1080w` + : undefined; + + return ( + {alt + ); + } ), })); +jest.mock("@/components/ipfs/IPFSContext", () => ({ + resolveIpfsUrlSync: (url: string) => url, +})); + import { FallbackImage } from "../../../components/common/FallbackImage"; +import { responsiveDropImageLoader } from "@/helpers/image.helpers"; describe("FallbackImage", () => { afterEach(() => { @@ -75,4 +106,79 @@ describe("FallbackImage", () => { expect(onPrimaryError).toHaveBeenCalledTimes(1); expect(onError).toHaveBeenCalledTimes(1); }); + + it("resets fallback state when the primary source changes", async () => { + const { rerender } = render( + + ); + + const image = screen.getByRole("img", { + name: "rerender fallback example", + }); + expect(image).toHaveAttribute("src", "primary-a.gif"); + + fireEvent.error(image); + + await waitFor(() => { + expect(image).toHaveAttribute("src", "fallback-a.gif"); + }); + + rerender( + + ); + + const resetImage = screen.getByRole("img", { + name: "rerender fallback example", + }); + expect(resetImage).toHaveAttribute("src", "primary-b.gif"); + }); + + it("uses the custom loader for responsive primary urls and direct fallback urls", async () => { + const primarySrc = + "https://d3lqz0a4bldqgf.cloudfront.net/drops/drop-id/AUTOx450/art.png"; + const fallbackSrc = + "https://d3lqz0a4bldqgf.cloudfront.net/drops/drop-id/art.png"; + + render( + + ); + + const image = screen.getByRole("img", { + name: "responsive fallback example", + }); + + expect(image).toHaveAttribute( + "src", + "https://d3lqz0a4bldqgf.cloudfront.net/drops/drop-id/AUTOx1080/art.png" + ); + expect(image).toHaveAttribute( + "srcset", + "https://d3lqz0a4bldqgf.cloudfront.net/drops/drop-id/AUTOx450/art.png 450w, https://d3lqz0a4bldqgf.cloudfront.net/drops/drop-id/AUTOx1080/art.png 1080w" + ); + expect(image.getAttribute("srcset")).not.toContain("/_next/image"); + + fireEvent.error(image); + + await waitFor(() => { + expect(image).toHaveAttribute("src", fallbackSrc); + }); + + expect(image).not.toHaveAttribute("srcset"); + expect(image).toHaveAttribute("data-unoptimized", "true"); + }); }); diff --git a/__tests__/components/drops/view/item/content/media/DropListItemContentMedia.test.tsx b/__tests__/components/drops/view/item/content/media/DropListItemContentMedia.test.tsx index 445dd30c2f..c27cb3e67a 100644 --- a/__tests__/components/drops/view/item/content/media/DropListItemContentMedia.test.tsx +++ b/__tests__/components/drops/view/item/content/media/DropListItemContentMedia.test.tsx @@ -4,7 +4,18 @@ import DropListItemContentMedia from "@/components/drops/view/item/content/media jest.mock( "@/components/drops/view/item/content/media/DropListItemContentMediaImage", - () => ({ __esModule: true, default: () =>
}) + () => ({ + __esModule: true, + default: (props: any) => ( +
+ ), + }) ); jest.mock( "@/components/drops/view/item/content/media/DropListItemContentMediaVideo", @@ -45,6 +56,35 @@ describe("DropListItemContentMedia", () => { expect(screen.getByTestId("image")).toBeInTheDocument(); }); + it("forwards image sizes to the image component", () => { + render( + + ); + expect(screen.getByTestId("image")).toHaveAttribute( + "data-image-sizes", + "(max-width: 767px) 100vw, 33vw" + ); + }); + + it("forwards responsive srcset mode to the image component", () => { + render( + + ); + + expect(screen.getByTestId("image")).toHaveAttribute( + "data-responsive-srcset", + "true" + ); + }); + it("renders video component", () => { render( diff --git a/__tests__/components/waves/drops/WaveDrop.test.tsx b/__tests__/components/waves/drops/WaveDrop.test.tsx index b521fd3c68..ad3f21b345 100644 --- a/__tests__/components/waves/drops/WaveDrop.test.tsx +++ b/__tests__/components/waves/drops/WaveDrop.test.tsx @@ -6,8 +6,10 @@ import WaveDrop from "@/components/waves/drops/WaveDrop"; import useIsMobileDevice from "@/hooks/isMobileDevice"; import { editSlice } from "@/store/editSlice"; import { ApiDropGroupMention } from "@/generated/models/ApiDropGroupMention"; +import { DropLocation } from "@/components/waves/drops/drop.types"; const mockWaveDropActions = jest.fn(); +const mockWaveDropContent = jest.fn(); const mockMutate = jest.fn(); let mockEditMentionedGroups: ApiDropGroupMention[] = []; jest.mock("@/components/waves/drops/WaveDropActions", () => (props: any) => { @@ -17,22 +19,31 @@ jest.mock("@/components/waves/drops/WaveDropActions", () => (props: any) => { jest.mock("@/components/waves/drops/WaveDropReply", () => () => (
)); -jest.mock("@/components/waves/drops/WaveDropContent", () => (props: any) => ( -
-
-)); +jest.mock("@/components/waves/drops/WaveDropContent", () => { + const MockWaveDropContent = (props: any) => { + mockWaveDropContent(props); + return ( +
+
+ ); + }; + + return MockWaveDropContent; +}); jest.mock("@/components/waves/drops/WaveDropHeader", () => () => (
)); @@ -125,6 +136,7 @@ const drop: any = { describe("WaveDrop", () => { beforeEach(() => { mockWaveDropActions.mockClear(); + mockWaveDropContent.mockClear(); mockMutate.mockClear(); mockEditMentionedGroups = []; }); @@ -198,6 +210,107 @@ describe("WaveDrop", () => { ); }); + it("enables responsive image grid for wave view content", () => { + isMobileMock.mockReturnValue(false); + renderWithRedux( + + ); + + expect(mockWaveDropContent).toHaveBeenLastCalledWith( + expect.objectContaining({ responsiveImageGrid: true }) + ); + }); + + it("keeps responsive image grid disabled outside wave view content", () => { + isMobileMock.mockReturnValue(false); + renderWithRedux( + + ); + + expect(mockWaveDropContent).toHaveBeenLastCalledWith( + expect.objectContaining({ responsiveImageGrid: false }) + ); + }); + + it("saves edited drops when parts are missing attachments", () => { + isMobileMock.mockReturnValue(false); + const legacyDrop = { + ...drop, + parts: [ + { + part_id: 1, + content: "first", + media: [], + quoted_drop: null, + replies_count: 0, + quotes_count: 0, + }, + { + part_id: 2, + content: "second", + media: [], + quoted_drop: null, + replies_count: 0, + quotes_count: 0, + }, + ], + }; + + renderWithRedux( + + ); + + fireEvent.click(screen.getByTestId("save-edit")); + + expect(mockMutate).toHaveBeenCalledTimes(1); + const request = mockMutate.mock.calls[0][0].request; + expect(request.parts).toEqual([ + { content: "edited", quoted_drop: null, media: [] }, + { content: "second", quoted_drop: null, media: [] }, + ]); + expect(request.parts[0]).not.toHaveProperty("attachments"); + expect(request.parts[1]).not.toHaveProperty("attachments"); + }); + it("omits group mention metadata from edit update requests", () => { isMobileMock.mockReturnValue(false); mockEditMentionedGroups = [ApiDropGroupMention.All]; diff --git a/__tests__/components/waves/drops/WaveDropPartContent.test.tsx b/__tests__/components/waves/drops/WaveDropPartContent.test.tsx index e0aa4b8f42..4b60b0180b 100644 --- a/__tests__/components/waves/drops/WaveDropPartContent.test.tsx +++ b/__tests__/components/waves/drops/WaveDropPartContent.test.tsx @@ -1,19 +1,37 @@ -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import WaveDropPartContent from '@/components/waves/drops/WaveDropPartContent'; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import WaveDropPartContent from "@/components/waves/drops/WaveDropPartContent"; -jest.mock('@/components/waves/drops/WaveDropPartContentMarkdown', () => (props: any) => ( -
{props.part.content}
-)); -jest.mock('@/components/waves/drops/WaveDropPartContentMedias', () => (props: any) => ( -
{props.activePart.media.length}
-)); +const mockMarkdownProps: any[] = []; +const mockAttachmentsProps: any[] = []; + +jest.mock( + "@/components/waves/drops/WaveDropPartContentMarkdown", + () => (props: any) => { + mockMarkdownProps.push(props); + return
{props.part.content}
; + } +); +jest.mock( + "@/components/waves/drops/WaveDropPartContentMedias", + () => (props: any) => ( +
{props.activePart.media.length}
+ ) +); +jest.mock( + "@/components/waves/drops/WaveDropPartContentAttachments", + () => (props: any) => { + mockAttachmentsProps.push(props); + return
{props.attachments.length}
; + } +); const baseProps = { mentionedUsers: [], + mentionedWaves: [], referencedNfts: [], wave: {} as any, - activePart: { content: 'test', media: [] } as any, + activePart: { content: "test", media: [], attachments: [] } as any, havePreviousPart: false, haveNextPart: false, isStorm: false, @@ -22,22 +40,61 @@ const baseProps = { onQuoteClick: jest.fn(), }; -it('renders markdown without medias', () => { +beforeEach(() => { + mockMarkdownProps.length = 0; + mockAttachmentsProps.length = 0; + jest.clearAllMocks(); +}); + +it("renders markdown without medias", () => { render(); - expect(screen.getByTestId('markdown')).toHaveTextContent('test'); - expect(screen.queryByTestId('medias')).toBeNull(); + expect(screen.getByTestId("markdown")).toHaveTextContent("test"); + expect(screen.queryByTestId("medias")).toBeNull(); +}); + +it("reuses the default mentionedGroups reference across rerenders", () => { + const { rerender } = render(); + const firstMentionedGroups = + mockMarkdownProps[mockMarkdownProps.length - 1].mentionedGroups; + + rerender(); + const secondMentionedGroups = + mockMarkdownProps[mockMarkdownProps.length - 1].mentionedGroups; + + expect(firstMentionedGroups).toBe(secondMentionedGroups); + expect(firstMentionedGroups).toEqual([]); +}); + +it("treats missing attachments as an empty attachment list", () => { + render( + + ); + + expect(screen.getByTestId("attachments")).toHaveTextContent("0"); + expect( + mockAttachmentsProps[mockAttachmentsProps.length - 1].attachments + ).toEqual([]); }); -it('renders medias and navigation', async () => { +it("renders medias and navigation", async () => { const user = userEvent.setup(); const props = { ...baseProps, isStorm: true, haveNextPart: true, - activePart: { content: 'c', media: [{ url: 'u', mime_type: 'm' }] } as any, + activePart: { + content: "c", + media: [{ url: "u", mime_type: "m" }], + attachments: [], + } as any, }; render(); - expect(screen.getByTestId('medias')).toBeInTheDocument(); - await user.click(screen.getAllByLabelText('Next part')[0]); - expect(props.setActivePartIndex).toHaveBeenCalledWith(props.activePartIndex + 1); + expect(screen.getByTestId("medias")).toBeInTheDocument(); + await user.click(screen.getAllByLabelText("Next part")[0]); + expect(props.setActivePartIndex).toHaveBeenCalledWith( + props.activePartIndex + 1 + ); }); diff --git a/__tests__/components/waves/drops/WaveDropPartContentMedias.test.tsx b/__tests__/components/waves/drops/WaveDropPartContentMedias.test.tsx index d8e0fb6770..b1a87c7eb5 100644 --- a/__tests__/components/waves/drops/WaveDropPartContentMedias.test.tsx +++ b/__tests__/components/waves/drops/WaveDropPartContentMedias.test.tsx @@ -1,42 +1,190 @@ -import { render, screen } from '@testing-library/react'; -import React from 'react'; -import WaveDropPartContentMedias from '@/components/waves/drops/WaveDropPartContentMedias'; +import { render, screen } from "@testing-library/react"; +import React from "react"; +import WaveDropPartContentMedias from "@/components/waves/drops/WaveDropPartContentMedias"; -jest.mock('@/components/drops/view/item/content/media/MediaDisplay', () => ({ +jest.mock("@/components/drops/view/item/content/media/MediaDisplay", () => ({ __esModule: true, default: () =>
, })); -jest.mock('@/components/drops/view/item/content/media/DropListItemContentMedia', () => ({ +jest.mock( + "@/components/drops/view/item/content/media/DropListItemContentMedia", + () => ({ + __esModule: true, + default: (props: any) => ( +
+ ), + }) +); + +jest.mock("@/components/waves/drops/WaveDropPartContentFullWidthImage", () => ({ __esModule: true, - default: () =>
, + default: () =>
, })); -const basePart: any = { - content: '', - media: [ - { mime_type: 'image/png', url: 'u1' }, - { mime_type: 'video/mp4', url: 'u2' }, - ], +const twoColumnResponsiveGridImageSizes = "(max-width: 767px) 100vw, 50vw"; +const threeColumnResponsiveGridImageSizes = "(max-width: 767px) 100vw, 33vw"; + +const createImageMedia = (count: number) => + Array.from({ length: count }, (_, index) => ({ + mime_type: "image/png", + url: `image-${index + 1}`, + })); + +const createPart = (media: any[]) => ({ + content: "", + media, +}); + +const basePart: any = createPart([ + { mime_type: "image/png", url: "u1" }, + { mime_type: "video/mp4", url: "u2" }, +]); + +const getLayout = (container: HTMLElement) => { + const layout = container.firstElementChild; + expect(layout).not.toBeNull(); + return layout as HTMLElement; }; -describe('WaveDropPartContentMedias', () => { - it('returns null when no media', () => { +describe("WaveDropPartContentMedias", () => { + it("returns null when no media", () => { const { container } = render( ); expect(container.firstChild).toBeNull(); }); - it('renders DropListItemContentMedia by default', () => { + it("renders DropListItemContentMedia by default", () => { render(); - expect(screen.getAllByTestId('drop-media')).toHaveLength(2); + expect(screen.getAllByTestId("drop-media")).toHaveLength(2); }); - it('uses MediaDisplay when disabled', () => { + it("uses MediaDisplay when disabled", () => { render( - + ); - expect(screen.getAllByTestId('media-display')).toHaveLength(2); + expect(screen.getAllByTestId("media-display")).toHaveLength(2); + }); + + it("keeps the default layout stacked", () => { + const { container } = render( + + ); + + const layout = getLayout(container); + expect(layout).toHaveClass("tw-space-y-3"); + expect(layout).not.toHaveClass("tw-grid"); + }); + + it("uses a responsive image grid for multiple image media", () => { + const { container } = render( + + ); + + const layout = getLayout(container); + const firstMedia = screen.getAllByTestId("drop-media")[0]!; + const firstMediaContainer = firstMedia.parentElement as HTMLElement; + expect(layout).toHaveClass("tw-grid", "tw-grid-cols-1", "tw-gap-3"); + expect(firstMedia).toHaveAttribute( + "data-image-sizes", + twoColumnResponsiveGridImageSizes + ); + expect(firstMedia).toHaveAttribute("data-responsive-srcset", "true"); + expect(firstMediaContainer).toHaveClass( + "md:tw-aspect-square", + "md:tw-h-auto" + ); + }); + + it("uses two desktop columns and 50vw sizes for two images", () => { + const { container } = render( + + ); + + const layout = getLayout(container); + const firstMedia = screen.getAllByTestId("drop-media")[0]!; + expect(layout).toHaveClass("md:tw-grid-cols-2"); + expect(layout).not.toHaveClass("md:tw-grid-cols-3"); + expect(firstMedia).toHaveAttribute( + "data-image-sizes", + twoColumnResponsiveGridImageSizes + ); + }); + + it("uses three desktop columns and 33vw sizes for 3+ images", () => { + const { container } = render( + + ); + + const layout = getLayout(container); + const firstMedia = screen.getAllByTestId("drop-media")[0]!; + expect(layout).toHaveClass("md:tw-grid-cols-3"); + expect(layout).not.toHaveClass("md:tw-grid-cols-2"); + expect(firstMedia).toHaveAttribute( + "data-image-sizes", + threeColumnResponsiveGridImageSizes + ); + }); + + it("keeps mixed image and video media stacked", () => { + const { container } = render( + + ); + + const layout = getLayout(container); + const firstMedia = screen.getAllByTestId("drop-media")[0]!; + expect(layout).toHaveClass("tw-space-y-3"); + expect(layout).not.toHaveClass("tw-grid"); + expect(firstMedia).toHaveAttribute("data-image-sizes", ""); + expect(firstMedia).toHaveAttribute("data-responsive-srcset", "false"); + }); + + it("keeps disabled media interaction stacked", () => { + const { container } = render( + + ); + + const layout = getLayout(container); + expect(layout).toHaveClass("tw-space-y-3"); + expect(layout).not.toHaveClass("tw-grid"); + expect(screen.getAllByTestId("media-display")).toHaveLength(2); + }); + + it("keeps full-width media stacked", () => { + const { container } = render( + + ); + + const layout = getLayout(container); + expect(layout).toHaveClass("tw-space-y-3"); + expect(layout).not.toHaveClass("tw-grid"); + expect(screen.getAllByTestId("full-width-image")).toHaveLength(2); }); }); diff --git a/__tests__/helpers/image.helpers.test.ts b/__tests__/helpers/image.helpers.test.ts index 4a05c2e047..87fa97a801 100644 --- a/__tests__/helpers/image.helpers.test.ts +++ b/__tests__/helpers/image.helpers.test.ts @@ -1,4 +1,6 @@ import { + getResponsiveDropImageScale, + responsiveDropImageLoader, getScaledImageUri, getScaledResolvedImageUri, ImageScale, @@ -26,6 +28,14 @@ describe("getScaledImageUri", () => { ); }); + it("replaces an existing backend scale instead of nesting scales", () => { + const url = + "https://d3lqz0a4bldqgf.cloudfront.net/drops/drop-id/AUTOx450/art.png?x=1"; + expect(getScaledImageUri(url, ImageScale.AUTOx1080)).toBe( + "https://d3lqz0a4bldqgf.cloudfront.net/drops/drop-id/AUTOx1080/art.png?x=1" + ); + }); + it("returns original url for unsupported extension", () => { const url = "https://d3lqz0a4bldqgf.cloudfront.net/pfp/user/file.svg"; expect(getScaledImageUri(url, ImageScale.AUTOx450)).toBe(url); @@ -45,3 +55,27 @@ describe("getScaledResolvedImageUri", () => { expect(getScaledResolvedImageUri(url, ImageScale.W_AUTO_H_50)).toBe(url); }); }); + +describe("responsive drop image helpers", () => { + it.each([ + [320, ImageScale.AUTOx450], + [450, ImageScale.AUTOx450], + [451, ImageScale.AUTOx600], + [600, ImageScale.AUTOx600], + [601, ImageScale.AUTOx800], + [800, ImageScale.AUTOx800], + [801, ImageScale.AUTOx1080], + [1440, ImageScale.AUTOx1080], + ])("maps width %i to %s", (width, scale) => { + expect(getResponsiveDropImageScale(width)).toBe(scale); + }); + + it("builds scaled CloudFront urls for the Next image loader", () => { + const src = + "https://d3lqz0a4bldqgf.cloudfront.net/drops/drop-id/AUTOx450/art.png"; + + expect(responsiveDropImageLoader({ src, width: 750 })).toBe( + "https://d3lqz0a4bldqgf.cloudfront.net/drops/drop-id/AUTOx800/art.png" + ); + }); +}); diff --git a/components/common/FallbackImage.tsx b/components/common/FallbackImage.tsx index 810e935453..9ccb136e63 100644 --- a/components/common/FallbackImage.tsx +++ b/components/common/FallbackImage.tsx @@ -16,77 +16,81 @@ type FallbackImageProps = Omit & { export const FallbackImage = React.forwardRef< HTMLImageElement, FallbackImageProps ->( - ( - { - primarySrc, - fallbackSrc, - alt = "", - onError, - onPrimaryError, - optimize, - ...imageProps - }, - ref - ) => { - const [src, setSrc] = React.useState(primarySrc); - const [usedFallback, setUsedFallback] = React.useState(false); +>((props, ref) => ( + +)); - // Reset state when primarySrc changes (for retries) - React.useEffect(() => { - setSrc(primarySrc); - setUsedFallback(false); - }, [primarySrc]); +FallbackImage.displayName = "FallbackImage"; - const handleError = (e: React.SyntheticEvent) => { - if (!usedFallback) { - onPrimaryError?.(e); - setSrc(fallbackSrc); - setUsedFallback(true); - } else { - // If fallback also fails, call the external onError handler - onError?.(e); - } - }; +type FallbackImageInnerProps = FallbackImageProps & { + readonly forwardedRef: React.ForwardedRef; +}; - const skipOptimization = useMemo(() => { - if (optimize === false) { - return true; - } +function FallbackImageInner({ + primarySrc, + fallbackSrc, + alt = "", + onError, + onPrimaryError, + optimize, + loader, + forwardedRef, + ...imageProps +}: FallbackImageInnerProps) { + const [usedFallback, setUsedFallback] = React.useState(false); + const src = usedFallback ? fallbackSrc : primarySrc; - const targetSrc = src ?? primarySrc; + const handleError = (e: React.SyntheticEvent) => { + if (!usedFallback) { + onPrimaryError?.(e); + setUsedFallback(true); + } else { + // If fallback also fails, call the external onError handler + onError?.(e); + } + }; - const isAnimatedGif = - /\.gif(?:$|\?)/i.test(primarySrc) || /\.gif(?:$|\?)/i.test(fallbackSrc); - if (isAnimatedGif) { - return true; - } + const skipOptimization = useMemo(() => { + if (usedFallback) { + return true; + } - if (optimize === true) { - return false; - } + if (optimize === false) { + return true; + } - try { - const parsed = new URL(targetSrc); - const hostname = parsed.hostname.toLowerCase(); - const isCloudfrontHost = hostname.endsWith(".cloudfront.net"); - return !isCloudfrontHost; - } catch { - return true; - } - }, [fallbackSrc, optimize, primarySrc, src]); + const isAnimatedGif = + /\.gif(?:$|\?)/i.test(primarySrc) || /\.gif(?:$|\?)/i.test(fallbackSrc); + if (isAnimatedGif) { + return true; + } - return ( - {alt} - ); - } -); + if (optimize === true) { + return false; + } -FallbackImage.displayName = "FallbackImage"; + try { + const parsed = new URL(src); + const hostname = parsed.hostname.toLowerCase(); + const isCloudfrontHost = hostname.endsWith(".cloudfront.net"); + return !isCloudfrontHost; + } catch { + return true; + } + }, [fallbackSrc, optimize, primarySrc, src, usedFallback]); + + const loaderProps: Pick = + !usedFallback && loader ? { loader } : {}; + + return ( + {alt} + ); +} diff --git a/components/drops/view/item/content/media/DropListItemContentMedia.tsx b/components/drops/view/item/content/media/DropListItemContentMedia.tsx index e78f4bfbf2..b5c56d807e 100644 --- a/components/drops/view/item/content/media/DropListItemContentMedia.tsx +++ b/components/drops/view/item/content/media/DropListItemContentMedia.tsx @@ -36,6 +36,8 @@ export default function DropListItemContentMedia({ disableAutoPlay = false, imageObjectPosition, imageScale = ImageScale.AUTOx800, + imageSizes, + useResponsiveImageSrcSet = false, htmlIframeContainerClassName, htmlPreviewImageUrl, loadStrategy = "in-view", @@ -48,6 +50,8 @@ export default function DropListItemContentMedia({ readonly disableAutoPlay?: boolean | undefined; readonly imageObjectPosition?: string | undefined; readonly imageScale?: ImageScale | undefined; + readonly imageSizes?: string | undefined; + readonly useResponsiveImageSrcSet?: boolean | undefined; readonly htmlIframeContainerClassName?: string | undefined; readonly htmlPreviewImageUrl?: string | undefined; readonly loadStrategy?: MediaLoadStrategy | undefined; @@ -88,6 +92,8 @@ export default function DropListItemContentMedia({ disableModal={disableModal} imageObjectPosition={imageObjectPosition} imageScale={imageScale} + imageSizes={imageSizes} + useResponsiveImageSrcSet={useResponsiveImageSrcSet} loadStrategy={loadStrategy} /> ); diff --git a/components/drops/view/item/content/media/DropListItemContentMediaImage.tsx b/components/drops/view/item/content/media/DropListItemContentMediaImage.tsx index 9f3d7ac6b4..7d537bbfb3 100644 --- a/components/drops/view/item/content/media/DropListItemContentMediaImage.tsx +++ b/components/drops/view/item/content/media/DropListItemContentMediaImage.tsx @@ -2,7 +2,11 @@ import { FallbackImage } from "@/components/common/FallbackImage"; import { fullScreenSupported } from "@/helpers/Helpers"; -import { getScaledImageUri, ImageScale } from "@/helpers/image.helpers"; +import { + getScaledImageUri, + ImageScale, + responsiveDropImageLoader, +} from "@/helpers/image.helpers"; import useCapacitor from "@/hooks/useCapacitor"; import useDeviceInfo from "@/hooks/useDeviceInfo"; import { useInView } from "@/hooks/useInView"; @@ -37,6 +41,8 @@ function DropListItemContentMediaImage({ disableModal = false, imageObjectPosition, imageScale = ImageScale.AUTOx450, + imageSizes = "(max-width: 768px) 100vw, 768px", + useResponsiveImageSrcSet = false, loadStrategy = "in-view", }: { readonly src: string; @@ -46,6 +52,8 @@ function DropListItemContentMediaImage({ readonly disableModal?: boolean | undefined; readonly imageObjectPosition?: string | undefined; readonly imageScale?: ImageScale | undefined; + readonly imageSizes?: string | undefined; + readonly useResponsiveImageSrcSet?: boolean | undefined; readonly loadStrategy?: MediaLoadStrategy | undefined; }) { const [ref, inView] = useInView(); @@ -287,6 +295,12 @@ function DropListItemContentMediaImage({ const resolvedObjectPosition = imageObjectPosition ?? (isCompetitionDrop ? "center" : "left top"); + const fallbackImageOptimizationProps: Pick< + React.ComponentProps, + "loader" | "optimize" + > = useResponsiveImageSrcSet + ? { optimize: true, loader: responsiveDropImageLoader } + : { optimize: false }; return ( <> @@ -312,10 +326,10 @@ function DropListItemContentMediaImage({ primarySrc={getScaledImageUri(src, imageScale)} fallbackSrc={src} alt="Drop media" - optimize={false} + {...fallbackImageOptimizationProps} fill loading={loadStrategy === "eager" ? "eager" : undefined} - sizes="(max-width: 768px) 100vw, 768px" + sizes={imageSizes} className={`tw-max-h-full tw-max-w-full ${ !loaded ? "tw-opacity-0" : "tw-opacity-100" } ${disableModal ? "" : "tw-cursor-pointer"}`} diff --git a/components/waves/drops/WaveDrop.tsx b/components/waves/drops/WaveDrop.tsx index 7009fe4a33..d85034a64a 100644 --- a/components/waves/drops/WaveDrop.tsx +++ b/components/waves/drops/WaveDrop.tsx @@ -317,6 +317,7 @@ const getContentBlock = ({ handleOnReply, handleOnEdit, hasActiveLinkCardActions, + responsiveImageGrid, }: { readonly shouldShowReplyHeader: boolean; readonly onReplyClick: (serialNo: number) => void; @@ -352,6 +353,7 @@ const getContentBlock = ({ readonly handleOnReply: () => void; readonly handleOnEdit: () => void; readonly hasActiveLinkCardActions: boolean; + readonly responsiveImageGrid: boolean; }): React.ReactNode => ( <> {shouldShowReplyHeader && replyTo && ( @@ -392,6 +394,7 @@ const getContentBlock = ({ onCancel={handleEditCancel} hasTouch={allowLongPress} onLinkCardActionsActiveChange={handleLinkCardActionsActiveChange} + responsiveImageGrid={responsiveImageGrid} />
@@ -745,6 +748,7 @@ const WaveDrop = ({ handleOnReply, handleOnEdit, hasActiveLinkCardActions, + responsiveImageGrid: location === DropLocation.WAVE, }); const reactionsRow = (drop.metadata.length > 0 || showInteractions) && ( diff --git a/components/waves/drops/WaveDropContent.tsx b/components/waves/drops/WaveDropContent.tsx index c5752835a2..ed67070546 100644 --- a/components/waves/drops/WaveDropContent.tsx +++ b/components/waves/drops/WaveDropContent.tsx @@ -31,6 +31,7 @@ interface WaveDropContentProps { readonly isCompetitionDrop?: boolean | undefined; readonly mediaImageScale?: ImageScale | undefined; readonly fullWidthMedia?: boolean | undefined; + readonly responsiveImageGrid?: boolean | undefined; readonly hasTouch?: boolean | undefined; readonly onLinkCardActionsActiveChange?: | ((href: string, active: boolean) => void) @@ -57,6 +58,7 @@ const WaveDropContent: React.FC = ({ isCompetitionDrop = false, mediaImageScale = ImageScale.AUTOx450, fullWidthMedia = false, + responsiveImageGrid = false, hasTouch, onLinkCardActionsActiveChange, contentPresentation = "default", @@ -84,6 +86,7 @@ const WaveDropContent: React.FC = ({ isCompetitionDrop={isCompetitionDrop} mediaImageScale={mediaImageScale} fullWidthMedia={fullWidthMedia} + responsiveImageGrid={responsiveImageGrid} hasTouch={effectiveHasTouch} onLinkCardActionsActiveChange={onLinkCardActionsActiveChange} contentPresentation={contentPresentation} diff --git a/components/waves/drops/WaveDropPart.tsx b/components/waves/drops/WaveDropPart.tsx index 0c1bf1a455..6f6911e9d8 100644 --- a/components/waves/drops/WaveDropPart.tsx +++ b/components/waves/drops/WaveDropPart.tsx @@ -32,6 +32,7 @@ interface WaveDropPartProps { readonly isCompetitionDrop?: boolean | undefined; readonly mediaImageScale?: ImageScale | undefined; readonly fullWidthMedia?: boolean | undefined; + readonly responsiveImageGrid?: boolean | undefined; readonly hasTouch?: boolean | undefined; readonly onLinkCardActionsActiveChange?: | ((href: string, active: boolean) => void) @@ -62,6 +63,7 @@ const WaveDropPart: React.FC = memo( isCompetitionDrop = false, mediaImageScale = ImageScale.AUTOx450, fullWidthMedia = false, + responsiveImageGrid = false, hasTouch = false, onLinkCardActionsActiveChange, contentPresentation = "default", @@ -166,6 +168,7 @@ const WaveDropPart: React.FC = memo( isCompetitionDrop={isCompetitionDrop} mediaImageScale={mediaImageScale} fullWidthMedia={fullWidthMedia} + responsiveImageGrid={responsiveImageGrid} onLinkCardActionsActiveChange={onLinkCardActionsActiveChange} contentPresentation={contentPresentation} embedPath={embedPath} diff --git a/components/waves/drops/WaveDropPartContent.tsx b/components/waves/drops/WaveDropPartContent.tsx index 7d0e57c371..6e15c70bb8 100644 --- a/components/waves/drops/WaveDropPartContent.tsx +++ b/components/waves/drops/WaveDropPartContent.tsx @@ -42,6 +42,7 @@ interface WaveDropPartContentProps { readonly isCompetitionDrop?: boolean | undefined; readonly mediaImageScale?: ImageScale | undefined; readonly fullWidthMedia?: boolean | undefined; + readonly responsiveImageGrid?: boolean | undefined; readonly onLinkCardActionsActiveChange?: | ((href: string, active: boolean) => void) | undefined; @@ -52,9 +53,11 @@ interface WaveDropPartContentProps { readonly maxEmbedDepth?: number | undefined; } +const EMPTY_MENTIONED_GROUPS: ApiDropGroupMention[] = []; + const WaveDropPartContent: React.FC = ({ mentionedUsers, - mentionedGroups = [], + mentionedGroups = EMPTY_MENTIONED_GROUPS, mentionedWaves, referencedNfts, wave, @@ -73,6 +76,7 @@ const WaveDropPartContent: React.FC = ({ isCompetitionDrop = false, mediaImageScale = ImageScale.AUTOx450, fullWidthMedia = false, + responsiveImageGrid = false, onLinkCardActionsActiveChange, contentPresentation = "default", embedPath, @@ -187,6 +191,7 @@ const WaveDropPartContent: React.FC = ({ isCompetitionDrop={isCompetitionDrop} imageScale={mediaImageScale} fullWidthMedia={fullWidthMedia} + responsiveImageGrid={responsiveImageGrid} /> )} = ({ @@ -33,6 +42,7 @@ const WaveDropPartContentMedias: React.FC = ({ isCompetitionDrop = false, imageScale = ImageScale.AUTOx450, fullWidthMedia = false, + responsiveImageGrid = false, }) => { if (!activePart.media.length) { return null; @@ -49,13 +59,34 @@ const WaveDropPartContentMedias: React.FC = ({ topSpacingClassName = "tw-mt-0"; } - const mediaStackClassName = clsx(topSpacingClassName, "tw-space-y-3"); + const useResponsiveImageGrid = + responsiveImageGrid && + activePart.media.length > 1 && + activePart.media.every((media) => isImageMedia(media.mime_type)) && + !fullWidthMedia && + !disableMediaInteraction; + const usesTwoColumnResponsiveGrid = activePart.media.length === 2; + const desktopGridColumnClassName = usesTwoColumnResponsiveGrid + ? "md:tw-grid-cols-2" + : "md:tw-grid-cols-3"; + const responsiveImageGridSizes = usesTwoColumnResponsiveGrid + ? TWO_COLUMN_RESPONSIVE_IMAGE_GRID_SIZES + : THREE_COLUMN_RESPONSIVE_IMAGE_GRID_SIZES; + const mediaStackClassName = useResponsiveImageGrid + ? clsx( + topSpacingClassName, + "tw-grid tw-grid-cols-1 tw-gap-3", + desktopGridColumnClassName + ) + : clsx(topSpacingClassName, "tw-space-y-3"); const getMediaContainerClassName = ({ useNaturalHeightImage, useCompactLink, + useResponsiveGridItem, }: { readonly useNaturalHeightImage: boolean; readonly useCompactLink: boolean; + readonly useResponsiveGridItem: boolean; }) => { if (useNaturalHeightImage || useCompactLink) { return "tw-w-full"; @@ -63,6 +94,7 @@ const WaveDropPartContentMedias: React.FC = ({ return clsx( "tw-flex tw-h-64 tw-items-center tw-justify-center", + useResponsiveGridItem && "md:tw-aspect-square md:tw-h-auto", fullWidthMedia && "tw-w-full" ); }; @@ -76,6 +108,7 @@ const WaveDropPartContentMedias: React.FC = ({ const mediaContainerClassName = getMediaContainerClassName({ useNaturalHeightImage, useCompactLink, + useResponsiveGridItem: useResponsiveImageGrid, }); let mediaContent; @@ -102,6 +135,10 @@ const WaveDropPartContentMedias: React.FC = ({ media_url={media.url} isCompetitionDrop={isCompetitionDrop} imageScale={imageScale} + imageSizes={ + useResponsiveImageGrid ? responsiveImageGridSizes : undefined + } + useResponsiveImageSrcSet={useResponsiveImageGrid} /> ); } diff --git a/components/waves/drops/WaveDropPartDrop.tsx b/components/waves/drops/WaveDropPartDrop.tsx index b41fe84150..b570ca15e1 100644 --- a/components/waves/drops/WaveDropPartDrop.tsx +++ b/components/waves/drops/WaveDropPartDrop.tsx @@ -32,6 +32,7 @@ interface WaveDropPartDropProps { isCompetitionDrop?: boolean | undefined; mediaImageScale?: ImageScale | undefined; fullWidthMedia?: boolean | undefined; + responsiveImageGrid?: boolean | undefined; readonly onLinkCardActionsActiveChange?: | ((href: string, active: boolean) => void) | undefined; @@ -58,6 +59,7 @@ const WaveDropPartDrop: React.FC = ({ isCompetitionDrop = false, mediaImageScale = ImageScale.AUTOx450, fullWidthMedia = false, + responsiveImageGrid = false, onLinkCardActionsActiveChange, contentPresentation = "default", embedPath, @@ -93,6 +95,7 @@ const WaveDropPartDrop: React.FC = ({ isCompetitionDrop={isCompetitionDrop} mediaImageScale={mediaImageScale} fullWidthMedia={fullWidthMedia} + responsiveImageGrid={responsiveImageGrid} onLinkCardActionsActiveChange={onLinkCardActionsActiveChange} contentPresentation={contentPresentation} embedPath={embedPath} diff --git a/helpers/image.helpers.ts b/helpers/image.helpers.ts index 52a2c206d3..d13c624467 100644 --- a/helpers/image.helpers.ts +++ b/helpers/image.helpers.ts @@ -9,6 +9,18 @@ export enum ImageScale { AUTOx1080 = "AUTOx1080", } +const BACKEND_IMAGE_SCALE_SEGMENTS = new Set(Object.values(ImageScale)); + +const RESPONSIVE_DROP_IMAGE_SCALES: ReadonlyArray<{ + readonly maxWidth: number; + readonly scale: ImageScale; +}> = [ + { maxWidth: 450, scale: ImageScale.AUTOx450 }, + { maxWidth: 600, scale: ImageScale.AUTOx600 }, + { maxWidth: 800, scale: ImageScale.AUTOx800 }, + { maxWidth: 1080, scale: ImageScale.AUTOx1080 }, +]; + const SCALABLE_PREFIXES = [ "https://d3lqz0a4bldqgf.cloudfront.net/pfp/", "https://d3lqz0a4bldqgf.cloudfront.net/rememes/", @@ -30,10 +42,12 @@ export function getScaledResolvedImageUri( const path = resolvedUrl.slice(scalableUrl.length); const pathParts = path.split("/"); const fileName = pathParts.pop(); - const folder = pathParts.join("/"); if (!fileName) { return resolvedUrl; } + const unscaledPathParts = pathParts.filter( + (part) => !BACKEND_IMAGE_SCALE_SEGMENTS.has(part) + ); const fileNameParts = fileName.split("."); if (fileNameParts.length <= 1) { return resolvedUrl; @@ -43,8 +57,9 @@ export function getScaledResolvedImageUri( extension = extension.slice(0, extension.indexOf("?")); } if (["gif", "webp", "jpg", "jpeg", "png"].includes(extension.toLowerCase())) { + const unscaledFolder = unscaledPathParts.join("/"); return `${scalableUrl}${ - folder.length ? folder + "/" : "" + unscaledFolder.length ? unscaledFolder + "/" : "" }${scale}/${fileName}`; } return resolvedUrl; @@ -53,3 +68,25 @@ export function getScaledResolvedImageUri( export function getScaledImageUri(url: string, scale: ImageScale): string { return getScaledResolvedImageUri(resolveIpfsUrlSync(url), scale); } + +export function getResponsiveDropImageScale(width: number): ImageScale { + return ( + RESPONSIVE_DROP_IMAGE_SCALES.find(({ maxWidth }) => width <= maxWidth) + ?.scale ?? ImageScale.AUTOx1080 + ); +} + +function getResponsiveDropImageUri(src: string, width: number): string { + return getScaledImageUri(src, getResponsiveDropImageScale(width)); +} + +export function responsiveDropImageLoader({ + src, + width, +}: { + readonly src: string; + readonly width: number; + readonly quality?: number | undefined; +}): string { + return getResponsiveDropImageUri(src, width); +}