- {author.pfp ? (
+
+ {!isPlaceholder && activeSrc ? (
) : (
-
+
)}
-
+
+ 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 (
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 ? (
= ({ drop }) => {
>
{rater.profile.pfp ? (
{voter.profile.pfp ? (
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/**",