Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions __tests__/components/common/FallbackImage.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,20 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { forwardRef, type ComponentProps } from "react";

type MockNextImageProps = ComponentProps<"img"> & {
readonly fill?: boolean;
readonly unoptimized?: boolean;
};

jest.mock("next/image", () => ({
__esModule: true,
default: forwardRef<HTMLImageElement, MockNextImageProps>(
// eslint-disable-next-line react/display-name
({ fill: _fill, unoptimized: _unoptimized, alt, ...rest }, ref) => (
<img ref={ref} alt={alt ?? ""} {...rest} />
)
),
}));

import { FallbackImage } from "../../../components/common/FallbackImage";

Expand Down
Original file line number Diff line number Diff line change
@@ -1,28 +1,62 @@
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import React, { createRef, forwardRef, type ComponentProps } from "react";
import { fireEvent, render } from "@testing-library/react";

jest.mock('@/helpers/image.helpers', () => ({
getScaledImageUri: jest.fn(() => 'scaled-url'),
ImageScale: { AUTOx450: 'AUTOx450' },
jest.mock("@/helpers/image.helpers", () => ({
getScaledImageUri: jest.fn(() => "scaled-url"),
ImageScale: { AUTOx600: "AUTOx600" },
}));

jest.mock('@/hooks/useInView', () => ({
useInView: jest.fn(() => [React.createRef(), true] as any),
jest.mock("@/hooks/useInView", () => ({
useInView: jest.fn(
(): [React.RefObject<HTMLDivElement | null>, boolean] => [
createRef<HTMLDivElement>(),
true,
]
),
}));

import MediaDisplayImage from '@/components/drops/view/item/content/media/MediaDisplayImage';
type MockNextImageProps = ComponentProps<"img"> & {
readonly fill?: boolean;
readonly unoptimized?: boolean;
};

const { getScaledImageUri } = require('@/helpers/image.helpers');
jest.mock("next/image", () => ({
__esModule: true,
default: forwardRef<HTMLImageElement, MockNextImageProps>(
// eslint-disable-next-line react/display-name
({ fill: _fill, unoptimized: _unoptimized, alt, ...rest }, ref) => (
<img ref={ref} alt={alt ?? ""} {...rest} />
)
),
}));

import MediaDisplayImage from "@/components/drops/view/item/content/media/MediaDisplayImage";

const { getScaledImageUri } = require("@/helpers/image.helpers");

describe("MediaDisplayImage", () => {
it("shows scaled image and removes placeholder after load", () => {
const { container, queryByRole } = render(
<MediaDisplayImage src="test.jpg" />
);

expect(
container.querySelector(".tw-bg-iron-800")
).toBeInTheDocument();

const img = container.querySelector("img");

if (!(img instanceof HTMLImageElement)) {
throw new TypeError("Expected an HTMLImageElement");
}

expect(img).toBeInTheDocument();
expect(getScaledImageUri).toHaveBeenCalledWith("test.jpg", "AUTOx600");
expect(img.src).toContain("scaled-url");

describe('MediaDisplayImage', () => {
it('displays scaled image after load', () => {
const { container } = render(<MediaDisplayImage src="test.jpg" />);
const placeholder = container.querySelector('.tw-bg-iron-800') as HTMLElement;
expect(placeholder).toBeInTheDocument();
const img = container.querySelector('img') as HTMLImageElement;
expect(getScaledImageUri).toHaveBeenCalledWith('test.jpg', 'AUTOx450');
expect(img.src).toContain('scaled-url');
fireEvent.load(img);
expect(placeholder.style.display).toBe(''); // still present but we don't care

expect(container.querySelector(".tw-bg-iron-800")).not.toBeInTheDocument();
expect(queryByRole("img", { name: "Media content" })).toBeInTheDocument();
});
});
4 changes: 2 additions & 2 deletions __tests__/components/waves/WavePicture.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import WavePicture from '@/components/waves/WavePicture';
describe('WavePicture', () => {
it('renders picture image when provided', () => {
render(<WavePicture name="wave" picture="pic.jpg" contributors={[]} />);
const img = screen.getByRole('img');
expect(img).toHaveAttribute('src', 'pic.jpg');
const img = screen.getByRole('img', { name: 'wave' });
expect(img.getAttribute('src')).toContain('pic.jpg');
expect(img).toHaveAttribute('alt', 'wave');
});

Expand Down
1 change: 1 addition & 0 deletions codex/STATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ This table is the single source of truth for active and historical tickets. Keep
| TKT-0033 | Remove unused createIcsDataUrl helper | Review | P2 | openai-assistant | — | 2025-10-28 |
| TKT-0034 | Remove unused findLightDropBySerialNoWithPagination helper | Review | P2 | openai-assistant | — | 2025-10-28 |
| TKT-0035 | Add Discover link to app sidebar navigation | In-Progress | P1 | openai-assistant | — | 2025-10-29 |
| TKT-0036 | Improve wave media image optimisation | In-Progress | P1 | openai-assistant | — | 2025-10-29 |

## Usage Guidelines

Expand Down
37 changes: 37 additions & 0 deletions codex/tickets/TKT-0036.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
---
created: 2025-10-29
id: TKT-0036
owner: openai-assistant
priority: P1
status: In-Progress
title: Improve wave media image optimisation
---

## Context

> Drop media rendering was still heavily reliant on raw `<img>` tags, leading to inconsistent optimisation, noisy GIF previews, and duplicated animation code. We need a coordinated clean-up so shared helpers handle optimisation reliably while keeping Markdown/IPFS images stable.

## Plan

- [x] Refine `FallbackImage` to centralise optimisation toggles and fail over gracefully for gifs/IPFS.
- [x] Update media display components to opt into shared scaling helpers and remove redundant inline CSS animations.
- [ ] Document remaining lint/test follow-ups and run the standard quality checks once warnings are addressed.

## Acceptance

- [ ] Wave media components request consistent CDN scales (`AUTOx450`/`AUTOx600`) without regressing alignment.
- [ ] Markdown/IPFS images still render without optimisation errors.
- [ ] Tailwind animation tokens replace inline keyframes for artist preview loaders.
- [ ] `npm run lint`, `npm run type-check`, and targeted media display tests pass locally.

## Links

- Primary PR: _(add when available)_
- Follow-ups: _(capture any residual lint fixes if needed)_

## Log

- 2025-10-29T15:20:00Z – Created ticket to track the image optimisation clean-up across wave components.
- 2025-10-29T15:45:00Z – Wrapped `FallbackImage` around shared media previews, added AVIF/WebP gating, and introduced the `AUTOx600` scale.
- 2025-10-29T16:05:00Z – Replaced inline loader keyframes with Tailwind animation tokens and aligned gallery/list `imageScale` usage.
- 2025-10-29T16:25:00Z – Synced environment defaults to `ipfs.6529.io`, restored safe scroll bounds in artist preview modals, and logged remaining lint follow-ups.
3 changes: 3 additions & 0 deletions components/brain/my-stream/votes/MyStreamWaveMyVote.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { SingleWaveDropPosition } from "@/components/waves/drop/SingleWaveDropPo
import { cicToType } from "@/helpers/Helpers";
import Link from "next/link";
import UserProfileTooltipWrapper from "@/components/utils/tooltip/UserProfileTooltipWrapper";
import { ImageScale } from "@/helpers/image.helpers";

interface MyStreamWaveMyVoteProps {
readonly drop: ExtendedDrop;
Expand Down Expand Up @@ -90,6 +91,8 @@ const MyStreamWaveMyVote: React.FC<MyStreamWaveMyVoteProps> = ({
<DropListItemContentMedia
media_mime_type={artWork.mime_type}
media_url={artWork.url}
imageScale={ImageScale.AUTOx450}
isCompetitionDrop={true}
/>
)}
</div>
Expand Down
50 changes: 44 additions & 6 deletions components/common/FallbackImage.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,32 @@
"use client";

import React from "react";
import Image, { type ImageProps } from "next/image";
import React, { useMemo } from "react";

type FallbackImageProps = React.ImgHTMLAttributes<HTMLImageElement> & {
type FallbackImageProps = Omit<ImageProps, "src" | "alt"> & {
readonly primarySrc: string;
readonly fallbackSrc: string;
readonly alt?: string;
readonly onPrimaryError?: (
event: React.SyntheticEvent<HTMLImageElement, Event>
) => void;
readonly optimize?: boolean;
};

export const FallbackImage = React.forwardRef<
HTMLImageElement,
FallbackImageProps
>(
(
{ primarySrc, fallbackSrc, alt = "", onError, onPrimaryError, ...rest },
{
primarySrc,
fallbackSrc,
alt = "",
onError,
onPrimaryError,
optimize,
...imageProps
},
ref
) => {
const [src, setSrc] = React.useState(primarySrc);
Expand All @@ -38,14 +49,41 @@ export const FallbackImage = React.forwardRef<
}
};

const skipOptimization = useMemo(() => {
if (optimize === false) {
return true;
}

const targetSrc = src ?? primarySrc;

const isAnimatedGif =
/\.gif(?:$|\?)/i.test(primarySrc) || /\.gif(?:$|\?)/i.test(fallbackSrc);
if (isAnimatedGif) {
return true;
}

if (optimize === true) {
return false;
}

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]);

return (
<img
<Image
ref={ref}
src={src}
alt={alt}
onError={handleError}
loading="lazy"
{...rest}
unoptimized={skipOptimization}
{...imageProps}
/>
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export default function DropListItemContentMedia({
media_url,
onContainerClick,
isCompetitionDrop = false,
imageScale = ImageScale.AUTOx450,
imageScale = ImageScale.AUTOx800,
}: {
readonly media_mime_type: string;
readonly media_url: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,8 @@ function DropListItemContentMediaImage({
</div>
);

const imageObjectPosition = isCompetitionDrop ? "center" : "left top";

return (
<>
<div
Expand All @@ -278,10 +280,15 @@ function DropListItemContentMediaImage({
primarySrc={getScaledImageUri(src, imageScale)}
fallbackSrc={src}
alt="Drop media"
className={`tw-object-contain tw-max-w-full tw-max-h-full ${
fill
sizes="(max-width: 768px) 100vw, 768px"
className={`tw-max-w-full tw-max-h-full ${
!loaded ? "tw-opacity-0" : "tw-opacity-100"
} tw-cursor-pointer`}
decoding="async"
style={{
objectFit: "contain",
objectPosition: imageObjectPosition,
}}
onLoad={handleImageLoad}
onClick={handleImageClick}
onError={handleError}
Expand Down
7 changes: 5 additions & 2 deletions components/drops/view/item/content/media/MediaDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { assertUnreachable } from "@/helpers/AllowlistToolHelpers";

// Import the media display components
import MediaDisplayImage from "./MediaDisplayImage";
import { ImageScale } from "@/helpers/image.helpers";
import MediaDisplayVideo from "./MediaDisplayVideo";
import MediaDisplayAudio from "./MediaDisplayAudio";

Expand All @@ -27,10 +28,12 @@ export default function MediaDisplay({
media_mime_type,
media_url,
disableMediaInteraction = false,
imageScale = ImageScale.AUTOx600,
}: {
readonly media_mime_type: string;
readonly media_url: string;
readonly disableMediaInteraction?: boolean; // Set to true in gallery context to disable all media interaction (controls and click handlers)
readonly imageScale?: ImageScale;
}) {
const getMediaType = (): MediaType => {
if (media_mime_type.includes("image")) {
Expand All @@ -55,7 +58,7 @@ export default function MediaDisplay({

switch (mediaType) {
case MediaType.IMAGE:
return <MediaDisplayImage src={media_url} />;
return <MediaDisplayImage src={media_url} imageScale={imageScale} />;
case MediaType.VIDEO:
return <MediaDisplayVideo
src={media_url}
Expand All @@ -71,4 +74,4 @@ export default function MediaDisplay({
default:
assertUnreachable(mediaType);
}
}
}
23 changes: 10 additions & 13 deletions components/drops/view/item/content/media/MediaDisplayImage.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,16 @@
"use client";

import React, { useState, useCallback } from "react";
import {
getScaledImageUri,
ImageScale,
} from "@/helpers/image.helpers";
import { FallbackImage } from "@/components/common/FallbackImage";
import { getScaledImageUri, ImageScale } from "@/helpers/image.helpers";
import { useInView } from "@/hooks/useInView";

interface Props {
readonly src: string;
readonly imageScale?: ImageScale;
}

function MediaDisplayImage({ src }: Props) {
function MediaDisplayImage({ src, imageScale = ImageScale.AUTOx600 }: Props) {
const [ref, inView] = useInView<HTMLDivElement>();
const [isLoading, setIsLoading] = useState(true);

Expand Down Expand Up @@ -41,19 +40,17 @@ function MediaDisplayImage({ src }: Props) {
/>
)}
{inView && (
<img
src={getScaledImageUri(src, ImageScale.AUTOx450)}
<FallbackImage
primarySrc={getScaledImageUri(src, imageScale)}
fallbackSrc={src}
alt="Media content"
loading="lazy"
decoding="async"
fill
sizes="(max-width: 768px) 100vw, 600px"
className={`tw-object-contain tw-max-w-full ${
isLoading ? "tw-opacity-0" : "tw-opacity-100"
}`}
style={{
maxWidth: "100%",
maxHeight: "100%",
}}
onLoad={handleImageLoad}
onError={handleImageLoad}
/>
)}
</div>
Expand Down
Loading