From 6fde3d4168a87f82d3ca1e968a221089de8274be Mon Sep 17 00:00:00 2001 From: prxt6529 Date: Wed, 8 Apr 2026 09:44:04 +0300 Subject: [PATCH 1/6] Align notification avatars with IPFS gateway fallback Signed-off-by: prxt6529 --- .../subcomponents/NotificationHeader.test.tsx | 103 ++++++++++++++++ .../common/OverlappingAvatars.test.tsx | 114 ++++++++++++++++++ .../components/ipfs/IPFSContext.test.tsx | 5 + .../NotificationDropReactedGroup.tsx | 3 +- .../subcomponents/NotificationHeader.tsx | 12 +- components/common/OverlappingAvatars.tsx | 15 ++- .../common/image/useGatewayImageLoadState.ts | 78 ++++++++++++ components/ipfs/IPFSContext.tsx | 28 ++++- 8 files changed, 342 insertions(+), 16 deletions(-) create mode 100644 __tests__/components/brain/notifications/subcomponents/NotificationHeader.test.tsx create mode 100644 __tests__/components/common/OverlappingAvatars.test.tsx create mode 100644 components/common/image/useGatewayImageLoadState.ts 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..d8923a5650 --- /dev/null +++ b/__tests__/components/brain/notifications/subcomponents/NotificationHeader.test.tsx @@ -0,0 +1,103 @@ +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..b1738d82fd --- /dev/null +++ b/__tests__/components/common/OverlappingAvatars.test.tsx @@ -0,0 +1,114 @@ +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..3978f9df51 100644 --- a/__tests__/components/ipfs/IPFSContext.test.tsx +++ b/__tests__/components/ipfs/IPFSContext.test.tsx @@ -56,6 +56,11 @@ describe("IpfsContext", () => { .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("returns original url if env missing", async () => { publicEnv.IPFS_GATEWAY_ENDPOINT = undefined; publicEnv.IPFS_API_ENDPOINT = undefined; diff --git a/components/brain/notifications/drop-reacted/NotificationDropReactedGroup.tsx b/components/brain/notifications/drop-reacted/NotificationDropReactedGroup.tsx index 59f827ed6b..d3dca20eae 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 { @@ -348,7 +347,7 @@ 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", diff --git a/components/brain/notifications/subcomponents/NotificationHeader.tsx b/components/brain/notifications/subcomponents/NotificationHeader.tsx index ba4277ed67..d4898b6f0d 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 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,15 +16,21 @@ export default function NotificationHeader({ children, actions, }: NotificationHeaderProps) { + const { activeSrc, isPlaceholder, unoptimized, handleError } = + useGatewayImageLoadState(author.pfp); + return (
- {author.pfp ? ( + {!isPlaceholder && activeSrc ? ( {author.handle ) : ( diff --git a/components/common/OverlappingAvatars.tsx b/components/common/OverlappingAvatars.tsx index 0bcae409d1..66a336c2b1 100644 --- a/components/common/OverlappingAvatars.tsx +++ b/components/common/OverlappingAvatars.tsx @@ -6,8 +6,9 @@ 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) { return (
@@ -60,12 +62,13 @@ function AvatarContent({ return ( {ariaLabel setImgError(true)} + unoptimized={unoptimized} + onError={handleError} className={`tw-object-cover tw-rounded-full ${avatarRing}`} /> ); diff --git a/components/common/image/useGatewayImageLoadState.ts b/components/common/image/useGatewayImageLoadState.ts new file mode 100644 index 0000000000..ce51ff46cb --- /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 | undefined) { + const normalizedSrc = src ?? null; + 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..c3d79986b4 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, @@ -80,13 +81,30 @@ export const useIpfsService = (): IpfsService => { }; export const resolveIpfsUrlSync = (url: string) => { - if (!url.startsWith("ipfs://")) { - return url; - } - 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 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.hostname = configuredHost; + parsedUrl.host = configuredHost + (parsedUrl.port ? `:${parsedUrl.port}` : ""); + return parsedUrl.toString(); } catch (error) { console.error("Error resolving IPFS URL", error); return url; From 8efff7927f479f1bf0fd401ca149937d7e7aff08 Mon Sep 17 00:00:00 2001 From: prxt6529 Date: Wed, 8 Apr 2026 09:52:03 +0300 Subject: [PATCH 2/6] WIP Signed-off-by: prxt6529 --- components/common/image/useGatewayImageLoadState.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/components/common/image/useGatewayImageLoadState.ts b/components/common/image/useGatewayImageLoadState.ts index ce51ff46cb..6987520a5c 100644 --- a/components/common/image/useGatewayImageLoadState.ts +++ b/components/common/image/useGatewayImageLoadState.ts @@ -11,8 +11,8 @@ type GatewayImageLoadState = { mode: GatewayImageLoadMode; }; -export function useGatewayImageLoadState(src: string | null | undefined) { - const normalizedSrc = src ?? null; +export function useGatewayImageLoadState(src: string | null = null) { + const normalizedSrc = src; const candidateUrls = useMemo( () => (normalizedSrc ? getArweaveGatewayFallbackUrls(normalizedSrc) : []), [normalizedSrc] From ce80f3c80bd42e615eb9e3e1d9105e6f6f3fa657 Mon Sep 17 00:00:00 2001 From: prxt6529 Date: Wed, 8 Apr 2026 10:40:55 +0300 Subject: [PATCH 3/6] WIP Signed-off-by: prxt6529 --- .../components/ipfs/IPFSContext.test.tsx | 7 ++++++ __tests__/helpers/image.helpers.test.ts | 11 ++++++++- .../subcomponents/NotificationHeader.tsx | 4 ++-- components/common/OverlappingAvatars.tsx | 6 ++--- components/ipfs/IPFSContext.tsx | 23 +++++++++++++++++-- helpers/image.helpers.ts | 10 ++++++-- next-env.typecheck.d.ts | 6 +++++ package.json | 2 +- tsconfig.typecheck.json | 7 ++++++ 9 files changed, 65 insertions(+), 11 deletions(-) create mode 100644 next-env.typecheck.d.ts diff --git a/__tests__/components/ipfs/IPFSContext.test.tsx b/__tests__/components/ipfs/IPFSContext.test.tsx index 3978f9df51..73f5a21c9c 100644 --- a/__tests__/components/ipfs/IPFSContext.test.tsx +++ b/__tests__/components/ipfs/IPFSContext.test.tsx @@ -61,6 +61,13 @@ describe("IpfsContext", () => { .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 () => { publicEnv.IPFS_GATEWAY_ENDPOINT = undefined; publicEnv.IPFS_API_ENDPOINT = undefined; diff --git a/__tests__/helpers/image.helpers.test.ts b/__tests__/helpers/image.helpers.test.ts index 4ac3b342c9..097c4e8841 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) => { @@ -33,4 +37,9 @@ describe("getScaledImageUri", () => { "https://ipfs-gateway.test/ipfs/QmVdHEkqhPqjBCzS2cSNDhRwz4X2TicEzQtP9ep5Lspyc8" ); }); + + 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/subcomponents/NotificationHeader.tsx b/components/brain/notifications/subcomponents/NotificationHeader.tsx index d4898b6f0d..f76df69718 100644 --- a/components/brain/notifications/subcomponents/NotificationHeader.tsx +++ b/components/brain/notifications/subcomponents/NotificationHeader.tsx @@ -1,6 +1,6 @@ import Link from "next/link"; import Image from "next/image"; -import { getScaledImageUri, ImageScale } from "@/helpers/image.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"; @@ -25,7 +25,7 @@ export default function NotificationHeader({ {!isPlaceholder && activeSrc ? ( {author.handle @@ -63,7 +63,7 @@ function AvatarContent({ return ( {ariaLabel { return context.ipfsService; }; +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(); @@ -92,6 +105,7 @@ export const resolveIpfsUrlSync = (url: string) => { 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") { @@ -102,8 +116,13 @@ export const resolveIpfsUrlSync = (url: string) => { return url; } - parsedUrl.hostname = configuredHost; - parsedUrl.host = configuredHost + (parsedUrl.port ? `:${parsedUrl.port}` : ""); + 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); diff --git a/helpers/image.helpers.ts b/helpers/image.helpers.ts index 13bff075e3..52a2c206d3 100644 --- a/helpers/image.helpers.ts +++ b/helpers/image.helpers.ts @@ -17,8 +17,10 @@ const SCALABLE_PREFIXES = [ "https://d3lqz0a4bldqgf.cloudfront.net/images/", ]; -export function getScaledImageUri(url: string, scale: ImageScale): string { - const resolvedUrl = resolveIpfsUrlSync(url); +export function getScaledResolvedImageUri( + resolvedUrl: string, + scale: ImageScale +): string { const scalableUrl = SCALABLE_PREFIXES.find((prefix) => 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/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..b94839fd01 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 && 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/**", From 8ac7d97738d53a6d391e40d127ec32d07309d472 Mon Sep 17 00:00:00 2001 From: prxt6529 Date: Wed, 8 Apr 2026 10:41:52 +0300 Subject: [PATCH 4/6] WIP Signed-off-by: prxt6529 --- .../subcomponents/NotificationHeader.test.tsx | 22 ++++++++----------- .../common/OverlappingAvatars.test.tsx | 5 +---- .../components/ipfs/IPFSContext.test.tsx | 20 ++++++++++------- .../NotificationDropReactedGroup.tsx | 7 ++++-- .../subcomponents/NotificationHeader.tsx | 17 ++++++-------- components/common/OverlappingAvatars.tsx | 2 +- 6 files changed, 35 insertions(+), 38 deletions(-) diff --git a/__tests__/components/brain/notifications/subcomponents/NotificationHeader.test.tsx b/__tests__/components/brain/notifications/subcomponents/NotificationHeader.test.tsx index d8923a5650..ebcfcbfdeb 100644 --- a/__tests__/components/brain/notifications/subcomponents/NotificationHeader.test.tsx +++ b/__tests__/components/brain/notifications/subcomponents/NotificationHeader.test.tsx @@ -48,10 +48,7 @@ jest.mock("@/components/utils/tooltip/UserProfileTooltipWrapper", () => ({ 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", - ] + ? ["https://ipfs.6529.io/ipfs/gelato", "https://ipfs.io/ipfs/gelato"] : [url], })); @@ -59,11 +56,13 @@ describe("NotificationHeader", () => { it("prefers the configured ipfs gateway and falls back to ipfs.io on failure", () => { render( posted @@ -94,10 +93,7 @@ describe("NotificationHeader", () => { const thirdAttempt = screen.getByRole("img", { name: "GelatoGenesis", }); - expect(thirdAttempt).toHaveAttribute( - "src", - "https://ipfs.io/ipfs/gelato" - ); + 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 index b1738d82fd..f49bf4105d 100644 --- a/__tests__/components/common/OverlappingAvatars.test.tsx +++ b/__tests__/components/common/OverlappingAvatars.test.tsx @@ -34,10 +34,7 @@ jest.mock("@/hooks/useIsTouchDevice", () => ({ 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", - ] + ? ["https://ipfs.6529.io/ipfs/gpebbles", "https://ipfs.io/ipfs/gpebbles"] : [url], })); diff --git a/__tests__/components/ipfs/IPFSContext.test.tsx b/__tests__/components/ipfs/IPFSContext.test.tsx index 73f5a21c9c..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,20 +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"); + 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"; + 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"); + 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/components/brain/notifications/drop-reacted/NotificationDropReactedGroup.tsx b/components/brain/notifications/drop-reacted/NotificationDropReactedGroup.tsx index d3dca20eae..d0103ce411 100644 --- a/components/brain/notifications/drop-reacted/NotificationDropReactedGroup.tsx +++ b/components/brain/notifications/drop-reacted/NotificationDropReactedGroup.tsx @@ -72,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; @@ -351,7 +353,8 @@ export default function NotificationDropReactedGroup({ 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 f76df69718..7a2ebac2f4 100644 --- a/components/brain/notifications/subcomponents/NotificationHeader.tsx +++ b/components/brain/notifications/subcomponents/NotificationHeader.tsx @@ -21,7 +21,7 @@ export default function NotificationHeader({ return (
-
+
{!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 2be6bd072c..740e08a18b 100644 --- a/components/common/OverlappingAvatars.tsx +++ b/components/common/OverlappingAvatars.tsx @@ -69,7 +69,7 @@ function AvatarContent({ sizes="28px" unoptimized={unoptimized} onError={handleError} - className={`tw-object-cover tw-rounded-full ${avatarRing}`} + className={`tw-rounded-full tw-object-cover ${avatarRing}`} /> ); } From 9df5b24a0417b3b1aff99ef8b7e03489855b7b6a Mon Sep 17 00:00:00 2001 From: prxt6529 Date: Wed, 8 Apr 2026 10:44:43 +0300 Subject: [PATCH 5/6] WIP Signed-off-by: prxt6529 --- knip.jsonc | 2 ++ package.json | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) 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/package.json b/package.json index b94839fd01..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 -p tsconfig.typecheck.json", + "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", From 9ab7242298279cc8d647b2e29ea94faaff0a8634 Mon Sep 17 00:00:00 2001 From: prxt6529 Date: Wed, 8 Apr 2026 10:52:06 +0300 Subject: [PATCH 6/6] WIP Signed-off-by: prxt6529 --- __tests__/helpers/image.helpers.test.ts | 2 ++ components/waves/drop/SingleWaveDropLog.tsx | 3 +-- components/waves/drops/WaveDropRatings.tsx | 9 +++------ .../drops/header/WaveleaderboardDropRaters.tsx | 9 +++------ 4 files changed, 9 insertions(+), 14 deletions(-) diff --git a/__tests__/helpers/image.helpers.test.ts b/__tests__/helpers/image.helpers.test.ts index 097c4e8841..4a05c2e047 100644 --- a/__tests__/helpers/image.helpers.test.ts +++ b/__tests__/helpers/image.helpers.test.ts @@ -37,7 +37,9 @@ describe("getScaledImageUri", () => { "https://ipfs-gateway.test/ipfs/QmVdHEkqhPqjBCzS2cSNDhRwz4X2TicEzQtP9ep5Lspyc8" ); }); +}); +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/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 ? (