diff --git a/__tests__/components/brain/notifications/subcomponents/NotificationHeader.test.tsx b/__tests__/components/brain/notifications/subcomponents/NotificationHeader.test.tsx new file mode 100644 index 0000000000..ebcfcbfdeb --- /dev/null +++ b/__tests__/components/brain/notifications/subcomponents/NotificationHeader.test.tsx @@ -0,0 +1,99 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import type { ComponentProps, ReactNode } from "react"; +import NotificationHeader from "@/components/brain/notifications/subcomponents/NotificationHeader"; + +type MockNextImageProps = ComponentProps<"img"> & { + readonly fill?: boolean | undefined; + readonly unoptimized?: boolean | undefined; +}; + +jest.mock("next/image", () => ({ + __esModule: true, + default: ({ + fill: _fill, + unoptimized, + alt, + ...props + }: MockNextImageProps) => ( + // eslint-disable-next-line @next/next/no-img-element + {alt + ), +})); + +jest.mock("next/link", () => ({ + __esModule: true, + default: ({ + href, + children, + ...props + }: { + href: string; + children: ReactNode; + }) => ( + + {children} + + ), +})); + +jest.mock("@/components/utils/tooltip/UserProfileTooltipWrapper", () => ({ + __esModule: true, + default: ({ children }: { children: ReactNode }) => <>{children}, +})); + +jest.mock("@/components/nft-image/utils/gateway-fallback", () => ({ + getArweaveGatewayFallbackUrls: (url: string) => + url === "ipfs://gelato" + ? ["https://ipfs.6529.io/ipfs/gelato", "https://ipfs.io/ipfs/gelato"] + : [url], +})); + +describe("NotificationHeader", () => { + it("prefers the configured ipfs gateway and falls back to ipfs.io on failure", () => { + render( + + posted + + ); + + const firstAttempt = screen.getByRole("img", { + name: "GelatoGenesis", + }); + expect(firstAttempt).toHaveAttribute( + "src", + "https://ipfs.6529.io/ipfs/gelato" + ); + expect(firstAttempt).toHaveAttribute("data-unoptimized", "false"); + + fireEvent.error(firstAttempt); + + const secondAttempt = screen.getByRole("img", { + name: "GelatoGenesis", + }); + expect(secondAttempt).toHaveAttribute( + "src", + "https://ipfs.6529.io/ipfs/gelato" + ); + expect(secondAttempt).toHaveAttribute("data-unoptimized", "true"); + + fireEvent.error(secondAttempt); + + const thirdAttempt = screen.getByRole("img", { + name: "GelatoGenesis", + }); + expect(thirdAttempt).toHaveAttribute("src", "https://ipfs.io/ipfs/gelato"); + expect(thirdAttempt).toHaveAttribute("data-unoptimized", "false"); + }); +}); diff --git a/__tests__/components/common/OverlappingAvatars.test.tsx b/__tests__/components/common/OverlappingAvatars.test.tsx new file mode 100644 index 0000000000..f49bf4105d --- /dev/null +++ b/__tests__/components/common/OverlappingAvatars.test.tsx @@ -0,0 +1,111 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; +import OverlappingAvatars from "@/components/common/OverlappingAvatars"; + +jest.mock("next/image", () => ({ + __esModule: true, + default: ({ + unoptimized, + alt, + ...props + }: { + unoptimized?: boolean; + alt?: string; + [key: string]: unknown; + }) => ( + // eslint-disable-next-line @next/next/no-img-element + {alt + ), +})); + +jest.mock("react-tooltip", () => ({ + Tooltip: ({ children }: { children: ReactNode }) =>
{children}
, +})); + +jest.mock("@/hooks/useIsTouchDevice", () => ({ + __esModule: true, + default: () => false, +})); + +jest.mock("@/components/nft-image/utils/gateway-fallback", () => ({ + getArweaveGatewayFallbackUrls: (url: string) => + url === "ipfs://gpebbles" + ? ["https://ipfs.6529.io/ipfs/gpebbles", "https://ipfs.io/ipfs/gpebbles"] + : [url], +})); + +describe("OverlappingAvatars", () => { + it("retries an optimized avatar load with unoptimized mode before showing fallback", () => { + render( + + ); + + const firstAttempt = screen.getByRole("img", { name: "View @gpebbles" }); + expect(firstAttempt).toHaveAttribute("data-unoptimized", "false"); + + fireEvent.error(firstAttempt); + + const retryAttempt = screen.getByRole("img", { name: "View @gpebbles" }); + expect(retryAttempt).toHaveAttribute("data-unoptimized", "true"); + + fireEvent.error(retryAttempt); + + expect(screen.getByText("GP")).toBeInTheDocument(); + expect( + screen.queryByRole("img", { name: "View @gpebbles" }) + ).not.toBeInTheDocument(); + }); + + it("falls back from the configured ipfs gateway to ipfs.io", () => { + render( + + ); + + const firstAttempt = screen.getByRole("img", { name: "View @gpebbles" }); + expect(firstAttempt).toHaveAttribute( + "src", + "https://ipfs.6529.io/ipfs/gpebbles" + ); + expect(firstAttempt).toHaveAttribute("data-unoptimized", "false"); + + fireEvent.error(firstAttempt); + + const secondAttempt = screen.getByRole("img", { name: "View @gpebbles" }); + expect(secondAttempt).toHaveAttribute( + "src", + "https://ipfs.6529.io/ipfs/gpebbles" + ); + expect(secondAttempt).toHaveAttribute("data-unoptimized", "true"); + + fireEvent.error(secondAttempt); + + const thirdAttempt = screen.getByRole("img", { name: "View @gpebbles" }); + expect(thirdAttempt).toHaveAttribute( + "src", + "https://ipfs.io/ipfs/gpebbles" + ); + expect(thirdAttempt).toHaveAttribute("data-unoptimized", "false"); + }); +}); diff --git a/__tests__/components/ipfs/IPFSContext.test.tsx b/__tests__/components/ipfs/IPFSContext.test.tsx index afaa1d4856..32909d9284 100644 --- a/__tests__/components/ipfs/IPFSContext.test.tsx +++ b/__tests__/components/ipfs/IPFSContext.test.tsx @@ -22,7 +22,7 @@ beforeEach(() => { describe("IpfsContext", () => { it("initializes IpfsService on mount", async () => { const init = jest.fn(); - MockIpfsService.mockImplementation(() => ({ init } as any)); + MockIpfsService.mockImplementation(() => ({ init }) as any); render( @@ -52,8 +52,24 @@ describe("IpfsContext", () => { }); it("resolves synchronously when needed", () => { - expect(resolveIpfsUrlSync("ipfs://sync")) - .toBe("https://ipfs.test.6529.io/ipfs/sync"); + expect(resolveIpfsUrlSync("ipfs://sync")).toBe( + "https://ipfs.test.6529.io/ipfs/sync" + ); + }); + + it("normalizes ipfs.io urls back to the configured gateway", () => { + expect(resolveIpfsUrlSync("https://ipfs.io/ipfs/sync")).toBe( + "https://ipfs.test.6529.io/ipfs/sync" + ); + }); + + it("preserves configured gateway port and base path when rewriting ipfs.io urls", () => { + publicEnv.IPFS_GATEWAY_ENDPOINT = + "https://ipfs.test.6529.io:8443/base/ipfs"; + + expect(resolveIpfsUrlSync("https://ipfs.io/ipfs/sync?x=1#hash")).toBe( + "https://ipfs.test.6529.io:8443/base/ipfs/sync?x=1#hash" + ); }); it("returns original url if env missing", async () => { diff --git a/__tests__/helpers/image.helpers.test.ts b/__tests__/helpers/image.helpers.test.ts index 4ac3b342c9..4a05c2e047 100644 --- a/__tests__/helpers/image.helpers.test.ts +++ b/__tests__/helpers/image.helpers.test.ts @@ -1,4 +1,8 @@ -import { getScaledImageUri, ImageScale } from "@/helpers/image.helpers"; +import { + getScaledImageUri, + getScaledResolvedImageUri, + ImageScale, +} from "@/helpers/image.helpers"; jest.mock("@/components/ipfs/IPFSContext", () => ({ resolveIpfsUrlSync: (url: string) => { @@ -34,3 +38,10 @@ describe("getScaledImageUri", () => { ); }); }); + +describe("getScaledResolvedImageUri", () => { + it("does not re-resolve already concrete urls", () => { + const url = "https://ipfs.io/ipfs/QmConcrete"; + expect(getScaledResolvedImageUri(url, ImageScale.W_AUTO_H_50)).toBe(url); + }); +}); diff --git a/components/brain/notifications/drop-reacted/NotificationDropReactedGroup.tsx b/components/brain/notifications/drop-reacted/NotificationDropReactedGroup.tsx index 59f827ed6b..d0103ce411 100644 --- a/components/brain/notifications/drop-reacted/NotificationDropReactedGroup.tsx +++ b/components/brain/notifications/drop-reacted/NotificationDropReactedGroup.tsx @@ -4,7 +4,6 @@ import OverlappingAvatars from "@/components/common/OverlappingAvatars"; import { UserFollowBtnSize } from "@/components/user/utils/UserFollowBtn"; import type { DropInteractionParams } from "@/components/waves/drops/Drop"; import type { ApiProfileMin } from "@/generated/models/ApiProfileMin"; -import { parseIpfsUrl } from "@/helpers/Helpers"; import type { ExtendedDrop } from "@/helpers/waves/drop.helpers"; import type { ActiveDropState } from "@/types/dropInteractionTypes"; import type { @@ -73,7 +72,9 @@ type LatestPerUserEntry = { identity: ApiProfileMin; }; -function getFallbackIdentityKey(notification: INotificationDropReacted): string { +function getFallbackIdentityKey( + notification: INotificationDropReacted +): string { const identityKey = getIdentityKey(notification.related_identity); if (identityKey !== "unknown-profile") { return identityKey; @@ -348,11 +349,12 @@ export default function NotificationDropReactedGroup({ const title = displayName || undefined; return { key, - pfpUrl: profile.pfp ? parseIpfsUrl(profile.pfp) : null, + pfpUrl: profile.pfp ?? null, ariaLabel: normalizedHandle ? `View @${normalizedHandle}` : "View profile", - fallback: normalizedHandle?.slice(0, 2).toUpperCase() ?? "?", + fallback: + normalizedHandle?.slice(0, 2).toUpperCase() ?? "?", ...(href !== undefined && { href }), ...(title !== undefined && { title }), }; diff --git a/components/brain/notifications/subcomponents/NotificationHeader.tsx b/components/brain/notifications/subcomponents/NotificationHeader.tsx index ba4277ed67..7a2ebac2f4 100644 --- a/components/brain/notifications/subcomponents/NotificationHeader.tsx +++ b/components/brain/notifications/subcomponents/NotificationHeader.tsx @@ -1,9 +1,9 @@ import Link from "next/link"; import Image from "next/image"; -import { getScaledImageUri, ImageScale } from "@/helpers/image.helpers"; -import { parseIpfsUrl } from "@/helpers/Helpers"; +import { getScaledResolvedImageUri, ImageScale } from "@/helpers/image.helpers"; import UserProfileTooltipWrapper from "@/components/utils/tooltip/UserProfileTooltipWrapper"; import type { ApiProfileMin } from "@/generated/models/ApiProfileMin"; +import { useGatewayImageLoadState } from "@/components/common/image/useGatewayImageLoadState"; interface NotificationHeaderProps { readonly author: ApiProfileMin; @@ -16,37 +16,40 @@ export default function NotificationHeader({ children, actions, }: NotificationHeaderProps) { + const { activeSrc, isPlaceholder, unoptimized, handleError } = + useGatewayImageLoadState(author.pfp); + return (
-
- {author.pfp ? ( +
+ {!isPlaceholder && activeSrc ? ( {author.handle ) : ( -
+
)}
-
+
+ className="tw-text-sm tw-font-semibold tw-text-iron-50 tw-no-underline" + > {author.handle} {children}
- {actions && ( -
- {actions} -
- )} + {actions &&
{actions}
}
); diff --git a/components/common/OverlappingAvatars.tsx b/components/common/OverlappingAvatars.tsx index 0bcae409d1..740e08a18b 100644 --- a/components/common/OverlappingAvatars.tsx +++ b/components/common/OverlappingAvatars.tsx @@ -1,13 +1,14 @@ "use client"; -import { getScaledImageUri, ImageScale } from "@/helpers/image.helpers"; +import { getScaledResolvedImageUri, ImageScale } from "@/helpers/image.helpers"; import { TOOLTIP_STYLES } from "@/helpers/tooltip.helpers"; import useIsTouchDevice from "@/hooks/useIsTouchDevice"; import Image from "next/image"; import Link from "next/link"; import type { MouseEvent, ReactNode } from "react"; -import { useId, useState } from "react"; +import { useId } from "react"; import { Tooltip } from "react-tooltip"; +import { useGatewayImageLoadState } from "@/components/common/image/useGatewayImageLoadState"; interface OverlappingAvatarItem { readonly key: string; @@ -46,9 +47,10 @@ function AvatarContent({ readonly fallback?: string | undefined; readonly avatarRing: string; }) { - const [imgError, setImgError] = useState(false); + const { activeSrc, isPlaceholder, unoptimized, handleError } = + useGatewayImageLoadState(pfpUrl); - if (!pfpUrl || imgError) { + if (isPlaceholder || activeSrc === null) { return (
@@ -60,13 +62,14 @@ function AvatarContent({ return ( {ariaLabel setImgError(true)} - className={`tw-object-cover tw-rounded-full ${avatarRing}`} + unoptimized={unoptimized} + onError={handleError} + className={`tw-rounded-full tw-object-cover ${avatarRing}`} /> ); } diff --git a/components/common/image/useGatewayImageLoadState.ts b/components/common/image/useGatewayImageLoadState.ts new file mode 100644 index 0000000000..6987520a5c --- /dev/null +++ b/components/common/image/useGatewayImageLoadState.ts @@ -0,0 +1,78 @@ +"use client"; + +import { getArweaveGatewayFallbackUrls } from "@/components/nft-image/utils/gateway-fallback"; +import { useMemo, useState } from "react"; + +type GatewayImageLoadMode = "optimized" | "unoptimized" | "placeholder"; + +type GatewayImageLoadState = { + src: string | null; + candidateIndex: number; + mode: GatewayImageLoadMode; +}; + +export function useGatewayImageLoadState(src: string | null = null) { + const normalizedSrc = src; + const candidateUrls = useMemo( + () => (normalizedSrc ? getArweaveGatewayFallbackUrls(normalizedSrc) : []), + [normalizedSrc] + ); + + const [loadState, setLoadState] = useState({ + src: null, + candidateIndex: 0, + mode: "optimized", + }); + + const currentState: GatewayImageLoadState = + loadState.src === normalizedSrc + ? loadState + : { + src: normalizedSrc, + candidateIndex: 0, + mode: "optimized", + }; + + const activeSrc = candidateUrls[currentState.candidateIndex] ?? null; + const isPlaceholder = + normalizedSrc === null || + activeSrc === null || + currentState.mode === "placeholder"; + + const handleError = () => { + if (normalizedSrc === null) { + return; + } + + if (currentState.mode === "optimized") { + setLoadState({ + src: normalizedSrc, + candidateIndex: currentState.candidateIndex, + mode: "unoptimized", + }); + return; + } + + if (currentState.candidateIndex + 1 < candidateUrls.length) { + setLoadState({ + src: normalizedSrc, + candidateIndex: currentState.candidateIndex + 1, + mode: "optimized", + }); + return; + } + + setLoadState({ + src: normalizedSrc, + candidateIndex: currentState.candidateIndex, + mode: "placeholder", + }); + }; + + return { + activeSrc, + isPlaceholder, + unoptimized: currentState.mode === "unoptimized", + handleError, + }; +} diff --git a/components/ipfs/IPFSContext.tsx b/components/ipfs/IPFSContext.tsx index 0a49e1bafb..87a18c8091 100644 --- a/components/ipfs/IPFSContext.tsx +++ b/components/ipfs/IPFSContext.tsx @@ -1,6 +1,7 @@ "use client"; import { publicEnv } from "@/config/env"; +import { getConfiguredIpfsGatewayHost } from "@/lib/media/ipfs-gateways"; import React, { createContext, useContext, @@ -79,14 +80,50 @@ export const useIpfsService = (): IpfsService => { return context.ipfsService; }; -export const resolveIpfsUrlSync = (url: string) => { - if (!url.startsWith("ipfs://")) { - return url; +function joinUrlPaths(basePathname: string, pathName: string): string { + const normalizedBase = basePathname.endsWith("/") + ? basePathname.slice(0, -1) + : basePathname; + const normalizedPath = pathName.startsWith("/") ? pathName : `/${pathName}`; + + if (!normalizedBase) { + return normalizedPath; } + return `${normalizedBase}${normalizedPath}`; +} + +export const resolveIpfsUrlSync = (url: string) => { try { const { gatewayBase } = readIpfsConfig(); - return `${gatewayBase}/ipfs/${url.slice(7)}`; + if (url.startsWith("ipfs://")) { + return `${gatewayBase}/ipfs/${url.slice(7)}`; + } + + const configuredHost = getConfiguredIpfsGatewayHost(); + if (!configuredHost) { + return url; + } + + const configuredGatewayBase = new URL(gatewayBase); + const parsedUrl = new URL(url); + const normalizedHost = parsedUrl.hostname.toLowerCase(); + if (normalizedHost !== "ipfs.io" && normalizedHost !== "www.ipfs.io") { + return url; + } + + if (!parsedUrl.pathname.startsWith("/ipfs/")) { + return url; + } + + parsedUrl.protocol = configuredGatewayBase.protocol; + parsedUrl.hostname = configuredGatewayBase.hostname; + parsedUrl.port = configuredGatewayBase.port; + parsedUrl.pathname = joinUrlPaths( + configuredGatewayBase.pathname, + parsedUrl.pathname + ); + return parsedUrl.toString(); } catch (error) { console.error("Error resolving IPFS URL", error); return url; diff --git a/components/waves/drop/SingleWaveDropLog.tsx b/components/waves/drop/SingleWaveDropLog.tsx index f37277aace..5a3b6c3b21 100644 --- a/components/waves/drop/SingleWaveDropLog.tsx +++ b/components/waves/drop/SingleWaveDropLog.tsx @@ -1,5 +1,4 @@ import { SystemAdjustmentPill } from "@/components/common/SystemAdjustmentPill"; -import { resolveIpfsUrlSync } from "@/components/ipfs/IPFSContext"; import UserProfileTooltipWrapper from "@/components/utils/tooltip/UserProfileTooltipWrapper"; import type { ApiWaveCreditType } from "@/generated/models/ApiWaveCreditType"; import type { ApiWaveLog } from "@/generated/models/ApiWaveLog"; @@ -101,7 +100,7 @@ export const SingleWaveDropLog = ({ const avatar = log.invoker.pfp ? ( User profile picture = ({ drop }) => { > {rater.profile.pfp ? ( {`${raterLabel}'s {voter.profile.pfp ? ( {`${voterLabel}'s resolvedUrl.startsWith(prefix) ); @@ -47,3 +49,7 @@ export function getScaledImageUri(url: string, scale: ImageScale): string { } return resolvedUrl; } + +export function getScaledImageUri(url: string, scale: ImageScale): string { + return getScaledResolvedImageUri(resolveIpfsUrlSync(url), scale); +} diff --git a/knip.jsonc b/knip.jsonc index 73a696d659..ea03e2690b 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -19,6 +19,7 @@ "standalone/standalone-memes-mint/src/next.config.ts", "standalone/standalone-memes-mint/src/postcss.config.js", "standalone/standalone-memes-mint/src/tailwind.config.cjs", + "eslint.config.single.mjs", "eslint.config.tight.mjs", "eslint.config.diff.mjs", "jest.config.js", @@ -46,6 +47,7 @@ "playwright.config.ts": ["exports"], "next-sitemap.config.ts": ["exports"], "jest.config.js": ["exports"], + "eslint.config.single.mjs": ["exports"], "eslint.config.tight.mjs": ["exports"], "eslint.config.diff.mjs": ["exports"], "standalone/standalone-memes-mint/src/app/layout.tsx": ["exports"], diff --git a/next-env.typecheck.d.ts b/next-env.typecheck.d.ts new file mode 100644 index 0000000000..4539b77e80 --- /dev/null +++ b/next-env.typecheck.d.ts @@ -0,0 +1,6 @@ +/// +/// +import "./.next/types/routes.d.ts"; + +// NOTE: This file is only used by tsconfig.typecheck.json to avoid +// importing unstable .next/dev generated types into repository typecheck. diff --git a/package.json b/package.json index ab33977eba..58b3a74856 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "lint:package-json": "node scripts/require-6529-command.cjs && node scripts/lint-package-json.cjs", "lint:quiet": "node scripts/require-6529-command.cjs && eslint . --quiet", "lint": "node scripts/require-6529-command.cjs && eslint .", - "typecheck": "node scripts/require-6529-command.cjs && tsc --noEmit", + "typecheck": "node scripts/require-6529-command.cjs && pnpm run format:uncommitted && tsc --noEmit -p tsconfig.typecheck.json", "typecheck:changed": "node scripts/require-6529-command.cjs && node scripts/typecheck-changed.cjs", "lint:csv": "node scripts/require-6529-command.cjs && node scripts/eslint-rule-summary.cjs --output eslint-rule-summary.csv", "lint:csv:tight": "node scripts/require-6529-command.cjs && node scripts/eslint-rule-summary.cjs --config eslint.config.tight.mjs --output eslint-rule-summary.csv", diff --git a/tsconfig.typecheck.json b/tsconfig.typecheck.json index af20d26c39..6468e3a0ae 100644 --- a/tsconfig.typecheck.json +++ b/tsconfig.typecheck.json @@ -1,6 +1,13 @@ { "extends": "./tsconfig.json", + "include": [ + "next-env.typecheck.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts" + ], "exclude": [ + "next-env.d.ts", ".next/dev/types/**", "node_modules", "generated/**",