(
// eslint-disable-next-line react/display-name
- ({ fill: _fill, unoptimized: _unoptimized, alt, ...rest }, ref) => (
-
- )
+ ({ 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 (
+
+ );
+ }
),
}));
+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 (
+
+
+ props.onLinkCardActionsActiveChange?.("https://example.com", true)
+ }
+ />
+
+ props.onSave?.("edited", [], mockEditMentionedGroups, [])
+ }
+ />
+
+ );
+ };
+
+ 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 (
-
- );
- }
-);
+ 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 (
+
+ );
+}
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);
+}