diff --git a/__tests__/components/common/FallbackImage.test.tsx b/__tests__/components/common/FallbackImage.test.tsx index 6f2ba31b45..67b47ed5ab 100644 --- a/__tests__/components/common/FallbackImage.test.tsx +++ b/__tests__/components/common/FallbackImage.test.tsx @@ -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( + // eslint-disable-next-line react/display-name + ({ fill: _fill, unoptimized: _unoptimized, alt, ...rest }, ref) => ( + {alt + ) + ), +})); import { FallbackImage } from "../../../components/common/FallbackImage"; diff --git a/__tests__/components/drops/view/item/content/media/MediaDisplayImage.test.tsx b/__tests__/components/drops/view/item/content/media/MediaDisplayImage.test.tsx index 19a0aecf15..b3279dfc0a 100644 --- a/__tests__/components/drops/view/item/content/media/MediaDisplayImage.test.tsx +++ b/__tests__/components/drops/view/item/content/media/MediaDisplayImage.test.tsx @@ -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, boolean] => [ + createRef(), + 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( + // eslint-disable-next-line react/display-name + ({ fill: _fill, unoptimized: _unoptimized, alt, ...rest }, ref) => ( + {alt + ) + ), +})); + +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( + + ); + + 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(); - 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(); }); }); diff --git a/__tests__/components/waves/WavePicture.test.tsx b/__tests__/components/waves/WavePicture.test.tsx index c36c33d520..66b6614d1f 100644 --- a/__tests__/components/waves/WavePicture.test.tsx +++ b/__tests__/components/waves/WavePicture.test.tsx @@ -5,8 +5,8 @@ import WavePicture from '@/components/waves/WavePicture'; describe('WavePicture', () => { it('renders picture image when provided', () => { render(); - 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'); }); diff --git a/codex/STATE.md b/codex/STATE.md index 8389e260b0..297914d5fe 100644 --- a/codex/STATE.md +++ b/codex/STATE.md @@ -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 diff --git a/codex/tickets/TKT-0036.md b/codex/tickets/TKT-0036.md new file mode 100644 index 0000000000..879a82bc4b --- /dev/null +++ b/codex/tickets/TKT-0036.md @@ -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 `` 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. diff --git a/components/brain/my-stream/votes/MyStreamWaveMyVote.tsx b/components/brain/my-stream/votes/MyStreamWaveMyVote.tsx index d988175513..cd6cd9d196 100644 --- a/components/brain/my-stream/votes/MyStreamWaveMyVote.tsx +++ b/components/brain/my-stream/votes/MyStreamWaveMyVote.tsx @@ -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; @@ -90,6 +91,8 @@ const MyStreamWaveMyVote: React.FC = ({ )} diff --git a/components/common/FallbackImage.tsx b/components/common/FallbackImage.tsx index f195196011..6079feaa7c 100644 --- a/components/common/FallbackImage.tsx +++ b/components/common/FallbackImage.tsx @@ -1,13 +1,16 @@ "use client"; -import React from "react"; +import Image, { type ImageProps } from "next/image"; +import React, { useMemo } from "react"; -type FallbackImageProps = React.ImgHTMLAttributes & { +type FallbackImageProps = Omit & { readonly primarySrc: string; readonly fallbackSrc: string; + readonly alt?: string; readonly onPrimaryError?: ( event: React.SyntheticEvent ) => void; + readonly optimize?: boolean; }; export const FallbackImage = React.forwardRef< @@ -15,7 +18,15 @@ export const FallbackImage = React.forwardRef< FallbackImageProps >( ( - { primarySrc, fallbackSrc, alt = "", onError, onPrimaryError, ...rest }, + { + primarySrc, + fallbackSrc, + alt = "", + onError, + onPrimaryError, + optimize, + ...imageProps + }, ref ) => { const [src, setSrc] = React.useState(primarySrc); @@ -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 ( - {alt} ); } diff --git a/components/drops/view/item/content/media/DropListItemContentMedia.tsx b/components/drops/view/item/content/media/DropListItemContentMedia.tsx index 0eb24aef41..878117f57f 100644 --- a/components/drops/view/item/content/media/DropListItemContentMedia.tsx +++ b/components/drops/view/item/content/media/DropListItemContentMedia.tsx @@ -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; diff --git a/components/drops/view/item/content/media/DropListItemContentMediaImage.tsx b/components/drops/view/item/content/media/DropListItemContentMediaImage.tsx index e4063fdabf..def28c8b4b 100644 --- a/components/drops/view/item/content/media/DropListItemContentMediaImage.tsx +++ b/components/drops/view/item/content/media/DropListItemContentMediaImage.tsx @@ -257,6 +257,8 @@ function DropListItemContentMediaImage({ ); + const imageObjectPosition = isCompetitionDrop ? "center" : "left top"; + return ( <>
{ if (media_mime_type.includes("image")) { @@ -55,7 +58,7 @@ export default function MediaDisplay({ switch (mediaType) { case MediaType.IMAGE: - return ; + return ; case MediaType.VIDEO: return (); const [isLoading, setIsLoading] = useState(true); @@ -41,19 +40,17 @@ function MediaDisplayImage({ src }: Props) { /> )} {inView && ( - Media content )}
diff --git a/components/waves/WavePicture.tsx b/components/waves/WavePicture.tsx index c9ac52179c..90d2570946 100644 --- a/components/waves/WavePicture.tsx +++ b/components/waves/WavePicture.tsx @@ -1,4 +1,5 @@ import React from "react"; +import { FallbackImage } from "@/components/common/FallbackImage"; interface WavePictureProps { readonly name: string; @@ -66,11 +67,16 @@ export default function WavePicture({ }: WavePictureProps) { if (picture) { return ( - {name} +
+ +
); } @@ -100,10 +106,13 @@ export default function WavePicture({ className="tw-absolute tw-inset-0" style={{ clipPath: clip }} > - {`Contributor-${i}`} ); diff --git a/components/waves/drops/ArtistActiveSubmissionContent.tsx b/components/waves/drops/ArtistActiveSubmissionContent.tsx index 58c5ebb609..aecb1072c9 100644 --- a/components/waves/drops/ArtistActiveSubmissionContent.tsx +++ b/components/waves/drops/ArtistActiveSubmissionContent.tsx @@ -80,39 +80,22 @@ export const ArtistActiveSubmissionContent: React.FC< } return ( - <> - {/* Content */} -
+
{(() => { if (isLoading || dropsLoading) { return (
- + Loading submissions... -
); @@ -205,7 +188,6 @@ export const ArtistActiveSubmissionContent: React.FC<
); })()} -
- + ); }; diff --git a/components/waves/drops/ArtistPreviewModal.tsx b/components/waves/drops/ArtistPreviewModal.tsx index 3d43d8177e..48492730b7 100644 --- a/components/waves/drops/ArtistPreviewModal.tsx +++ b/components/waves/drops/ArtistPreviewModal.tsx @@ -108,7 +108,7 @@ export const ArtistPreviewModal: React.FC< leaveFrom="tw-opacity-100" leaveTo="tw-opacity-100" > - e.stopPropagation()}> + e.stopPropagation()}>
- + Loading won artworks... -
); @@ -58,7 +43,7 @@ export const ArtistWinningArtworksContent: React.FC< return (
{winningDrops.map((drop) => { @@ -72,7 +57,6 @@ export const ArtistWinningArtworksContent: React.FC< className="tw-group tw-relative tw-cursor-pointer tw-flex tw-flex-col tw-flex-1 tw-bg-gradient-to-br tw-from-iron-900 tw-to-white/5 tw-rounded-lg tw-overflow-hidden tw-ring-1 tw-px-0.5 tw-pt-0.5 tw-ring-inset tw-ring-iron-900 desktop-hover:hover:tw-ring-iron-700 tw-transition-all tw-duration-500 tw-ease-out tw-mb-3" onClick={() => onDropClick(extendedDrop)} > - {/* Image container */}
@@ -92,7 +76,8 @@ export const ArtistWinningArtworksContent: React.FC< )}
- {/* View indicator */} + +
= ({ media_mime_type={media.mime_type} media_url={media.url} disableMediaInteraction={disableMediaInteraction} + imageScale={imageScale} /> ) : ( = ({ }, []); return ( -
+
{multiDecision ? (
{(() => { diff --git a/components/waves/leaderboard/header/WaveleaderboardHeader.tsx b/components/waves/leaderboard/header/WaveleaderboardHeader.tsx index ea352ce86e..689470f169 100644 --- a/components/waves/leaderboard/header/WaveleaderboardHeader.tsx +++ b/components/waves/leaderboard/header/WaveleaderboardHeader.tsx @@ -33,8 +33,8 @@ export const WaveLeaderboardHeader: React.FC = ({ const breakpoint = useBreakpoint(); return ( -
-
+
+
{isMemesWave && (
diff --git a/components/waves/small-leaderboard/WaveSmallLeaderboardItemContent.tsx b/components/waves/small-leaderboard/WaveSmallLeaderboardItemContent.tsx index 3a66ebf1c6..86092cd135 100644 --- a/components/waves/small-leaderboard/WaveSmallLeaderboardItemContent.tsx +++ b/components/waves/small-leaderboard/WaveSmallLeaderboardItemContent.tsx @@ -5,6 +5,7 @@ import { useEffect, useRef, useState } from "react"; import { Tooltip } from "react-tooltip"; import WaveDropPartContentMedias from "../drops/WaveDropPartContentMedias"; import WaveDropPartContentMarkdown from "../drops/WaveDropPartContentMarkdown"; +import { ImageScale } from "@/helpers/image.helpers"; interface WaveSmallLeaderboardItemContentProps { readonly drop: ExtendedDrop; @@ -37,6 +38,7 @@ export const WaveSmallLeaderboardItemContent: React.FC< )}