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
Original file line number Diff line number Diff line change
@@ -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
<img
alt={alt ?? ""}
data-unoptimized={unoptimized ? "true" : "false"}
{...props}
/>
),
}));

jest.mock("next/link", () => ({
__esModule: true,
default: ({
href,
children,
...props
}: {
href: string;
children: ReactNode;
}) => (
<a href={href} {...props}>
{children}
</a>
),
}));

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(
<NotificationHeader
author={
{
id: "gelato-id",
handle: "GelatoGenesis",
pfp: "ipfs://gelato",
} as any
}
>
<span>posted</span>
</NotificationHeader>
);

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");
});
});
111 changes: 111 additions & 0 deletions __tests__/components/common/OverlappingAvatars.test.tsx
Original file line number Diff line number Diff line change
@@ -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
<img
alt={alt ?? ""}
data-unoptimized={unoptimized ? "true" : "false"}
{...props}
/>
),
}));

jest.mock("react-tooltip", () => ({
Tooltip: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}));

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(
<OverlappingAvatars
items={[
{
key: "gpebbles",
pfpUrl: "https://cdn.warpcast.com/avatars/gpebbles.png",
ariaLabel: "View @gpebbles",
fallback: "GP",
},
]}
/>
);

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(
<OverlappingAvatars
items={[
{
key: "gpebbles",
pfpUrl: "ipfs://gpebbles",
ariaLabel: "View @gpebbles",
fallback: "GP",
},
]}
/>
);

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");
});
});
22 changes: 19 additions & 3 deletions __tests__/components/ipfs/IPFSContext.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<IpfsProvider>
Expand Down Expand Up @@ -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 () => {
Expand Down
13 changes: 12 additions & 1 deletion __tests__/helpers/image.helpers.test.ts
Original file line number Diff line number Diff line change
@@ -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) => {
Expand Down Expand Up @@ -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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 }),
};
Expand Down
31 changes: 17 additions & 14 deletions components/brain/notifications/subcomponents/NotificationHeader.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -16,37 +16,40 @@ export default function NotificationHeader({
children,
actions,
}: NotificationHeaderProps) {
const { activeSrc, isPlaceholder, unoptimized, handleError } =
useGatewayImageLoadState(author.pfp);

return (
<div className="tw-flex tw-items-start tw-gap-x-3">
<div className="tw-h-7 tw-w-7 tw-flex-shrink-0 tw-relative">
{author.pfp ? (
<div className="tw-relative tw-h-7 tw-w-7 tw-flex-shrink-0">
{!isPlaceholder && activeSrc ? (
<Image
src={getScaledImageUri(parseIpfsUrl(author.pfp), ImageScale.W_AUTO_H_50)}
key={`${activeSrc}-${unoptimized ? "unoptimized" : "optimized"}`}
src={getScaledResolvedImageUri(activeSrc, ImageScale.W_AUTO_H_50)}
alt={author.handle ?? "User profile"}
fill
sizes="28px"
className="tw-flex-shrink-0 tw-object-contain tw-rounded-md tw-bg-iron-800 tw-ring-1 tw-ring-iron-700"
unoptimized={unoptimized}
onError={handleError}
Comment thread
prxt6529 marked this conversation as resolved.
className="tw-flex-shrink-0 tw-rounded-md tw-bg-iron-800 tw-object-contain tw-ring-1 tw-ring-iron-700"
/>
) : (
<div className="tw-flex-shrink-0 tw-h-full tw-w-full tw-rounded-md tw-bg-iron-800 tw-ring-1 tw-ring-iron-700" />
<div className="tw-h-full tw-w-full tw-flex-shrink-0 tw-rounded-md tw-bg-iron-800 tw-ring-1 tw-ring-iron-700" />
)}
</div>
<div className="tw-flex tw-flex-1 tw-flex-col tw-items-start min-[390px]:tw-flex-row min-[390px]:tw-justify-between min-[390px]:tw-items-center tw-gap-y-2 min-[390px]:tw-gap-x-2 tw-min-w-0">
<div className="tw-flex tw-min-w-0 tw-flex-1 tw-flex-col tw-items-start tw-gap-y-2 min-[390px]:tw-flex-row min-[390px]:tw-items-center min-[390px]:tw-justify-between min-[390px]:tw-gap-x-2">
<div className="tw-flex tw-flex-wrap tw-items-center tw-gap-x-1">
<UserProfileTooltipWrapper user={author.handle ?? ""}>
<Link
href={`/${author.handle}`}
className="tw-no-underline tw-font-semibold tw-text-sm tw-text-iron-50">
className="tw-text-sm tw-font-semibold tw-text-iron-50 tw-no-underline"
>
{author.handle}
</Link>
</UserProfileTooltipWrapper>
{children}
</div>
{actions && (
<div className="tw-flex-shrink-0">
{actions}
</div>
)}
{actions && <div className="tw-flex-shrink-0">{actions}</div>}
</div>
</div>
);
Expand Down
Loading
Loading