From dc812a2b434dcd7be05e05dcc5326f9062969ad7 Mon Sep 17 00:00:00 2001 From: prxt6529 Date: Wed, 13 May 2026 09:18:17 +0300 Subject: [PATCH 1/4] Fix rememe flows, media sizing, and notification votes Signed-off-by: prxt6529 --- .../DropListItemContentMediaImage.test.tsx | 14 +++ .../all-drops/NotificationAllDrops.test.tsx | 13 ++ .../view/part/DropPartMarkdownImage.test.tsx | 19 ++- .../components/rememes/RememeAddPage.test.tsx | 90 ++++++++++---- __tests__/components/rememes/Rememes.test.tsx | 15 ++- .../services/api/notifications-v2-api.test.ts | 26 ++++ .../media/DropListItemContentMediaImage.tsx | 83 +++++++++---- .../drops/view/part/DropPartMarkdownImage.tsx | 8 +- components/rememes/RememeAddComponent.tsx | 14 +-- components/rememes/RememeAddPage.tsx | 117 +++++++++++++----- components/rememes/Rememes.module.scss | 9 +- components/rememes/Rememes.tsx | 81 ++++++++---- services/api/notifications-v2-api.ts | 2 +- types/feed.types.ts | 2 +- 14 files changed, 378 insertions(+), 115 deletions(-) diff --git a/__tests__/components/DropListItemContentMediaImage.test.tsx b/__tests__/components/DropListItemContentMediaImage.test.tsx index da85b1ecc6..87903df34d 100644 --- a/__tests__/components/DropListItemContentMediaImage.test.tsx +++ b/__tests__/components/DropListItemContentMediaImage.test.tsx @@ -87,6 +87,20 @@ describe("DropListItemContentMediaImage", () => { expect(requestFullscreen).not.toHaveBeenCalled(); }); + + it("renders intrinsic-height images without the fill container height", () => { + const { container } = render( + + ); + + const wrapper = container.querySelector(".tw-relative.tw-flex"); + const img = screen.getByAltText("Drop media"); + + expect(wrapper).toHaveClass("tw-w-full", "tw-min-h-40"); + expect(wrapper).not.toHaveClass("tw-h-full"); + expect(img).toHaveClass("tw-h-auto", "tw-w-full", "tw-max-h-64"); + expect(img).not.toHaveAttribute("data-nimg", "fill"); + }); }); describe("DropListItemContentMediaImage retry", () => { diff --git a/__tests__/components/brain/notifications/all-drops/NotificationAllDrops.test.tsx b/__tests__/components/brain/notifications/all-drops/NotificationAllDrops.test.tsx index 7d12939ed8..990b58d0e6 100644 --- a/__tests__/components/brain/notifications/all-drops/NotificationAllDrops.test.tsx +++ b/__tests__/components/brain/notifications/all-drops/NotificationAllDrops.test.tsx @@ -81,6 +81,19 @@ describe("NotificationAllDrops", () => { expect(screen.getByText("+2")).toBeInTheDocument(); }); + it("renders posted text when all-drops notification has no vote", () => { + render( + + ); + expect(screen.getByText("posted")).toBeInTheDocument(); + expect(screen.queryByText("reset rating to 0")).not.toBeInTheDocument(); + }); + it("uses router in reply and quote handlers", () => { mockRouter.push.mockClear(); render( diff --git a/__tests__/components/drops/view/part/DropPartMarkdownImage.test.tsx b/__tests__/components/drops/view/part/DropPartMarkdownImage.test.tsx index a50080c54e..46ad9a5573 100644 --- a/__tests__/components/drops/view/part/DropPartMarkdownImage.test.tsx +++ b/__tests__/components/drops/view/part/DropPartMarkdownImage.test.tsx @@ -5,11 +5,16 @@ jest.mock( "@/components/drops/view/item/content/media/DropListItemContentMediaImage", () => ({ __esModule: true, - default: (props: { src: string; loadStrategy: string }) => ( + default: (props: { + src: string; + loadStrategy: string; + intrinsicHeight?: boolean; + }) => (
), }) @@ -27,5 +32,17 @@ describe("DropPartMarkdownImage", () => { "data-load-strategy", "eager" ); + expect(screen.getByTestId("standard-image-media")).toHaveAttribute( + "data-intrinsic-height", + "true" + ); + }); + + it("does not reserve the old fixed image height", () => { + const { container } = render(); + const wrapper = container.firstElementChild; + + expect(wrapper).toHaveClass("tw-relative", "tw-mt-2", "tw-w-full"); + expect(wrapper).not.toHaveClass("tw-h-64"); }); }); diff --git a/__tests__/components/rememes/RememeAddPage.test.tsx b/__tests__/components/rememes/RememeAddPage.test.tsx index 94fc9f1107..a600af812e 100644 --- a/__tests__/components/rememes/RememeAddPage.test.tsx +++ b/__tests__/components/rememes/RememeAddPage.test.tsx @@ -68,13 +68,16 @@ jest.mock("@/contexts/SeizeSettingsContext", () => ({ // Mock API services jest.mock("@/services/6529api", () => ({ fetchUrl: jest.fn(), - postData: jest.fn(), })); jest.mock("@/services/api/common-api", () => ({ commonApiFetch: jest.fn(), })); +jest.mock("@/services/auth/auth.utils", () => ({ + getStagingAuth: jest.fn().mockReturnValue(null), +})); + jest.mock("@/helpers/Helpers", () => ({ areEqualAddresses: jest.fn((a, b) => a?.toLowerCase() === b?.toLowerCase()), numberWithCommas: jest.fn((n) => n.toLocaleString("en-US")), @@ -90,15 +93,18 @@ const mockUseSeizeConnectContext = const mockUseSeizeSettings = require("@/contexts/SeizeSettingsContext") .useSeizeSettings as jest.Mock; const mockFetchUrl = require("@/services/6529api").fetchUrl as jest.Mock; -const mockPostData = require("@/services/6529api").postData as jest.Mock; const mockCommonApiFetch = require("@/services/api/common-api") .commonApiFetch as jest.Mock; -// Mock location.reload -Object.defineProperty(window, "location", { - writable: true, - value: { reload: jest.fn() }, -}); +// Some jsdom versions expose window.location as non-configurable. +try { + Object.defineProperty(window, "location", { + writable: true, + value: { reload: jest.fn() }, + }); +} catch { + // Keep the existing location object; tests below avoid depending on reload. +} const renderComponent = () => { return render( @@ -135,6 +141,13 @@ describe("RememeAddPage", () => { }); mockFetchUrl.mockResolvedValue({ data: [] }); mockCommonApiFetch.mockResolvedValue({ boosted_tdh: 10000 }); + global.fetch = jest.fn().mockResolvedValue({ + status: 201, + json: jest.fn().mockResolvedValue({ + contract: { address: "0xcontract" }, + nfts: [{ tokenId: "1", name: "Test NFT", raw: {} }], + }), + }); }); it("renders page with logo and basic content", () => { @@ -290,15 +303,24 @@ describe("RememeAddPage", () => { data: "signature", }); - mockPostData.mockImplementation( + (global.fetch as jest.Mock).mockImplementation( () => new Promise((resolve) => { - setTimeout(() => resolve({ status: 201, response: {} }), 100); + setTimeout( + () => + resolve({ + status: 201, + json: jest.fn().mockResolvedValue({}), + }), + 100 + ); }) ); renderComponent(); + fireEvent.click(screen.getByText("Verify Rememe")); + await waitFor(() => { expect(screen.getByText("Adding Rememe")).toBeInTheDocument(); }); @@ -311,12 +333,12 @@ describe("RememeAddPage", () => { data: "signature", }); - mockPostData.mockResolvedValue({ + (global.fetch as jest.Mock).mockResolvedValue({ status: 201, - response: { + json: jest.fn().mockResolvedValue({ contract: { address: "0xcontract" }, nfts: [{ tokenId: "1", name: "Test NFT", raw: {} }], - }, + }), }); renderComponent(); @@ -339,12 +361,12 @@ describe("RememeAddPage", () => { data: "signature", }); - mockPostData.mockResolvedValue({ + (global.fetch as jest.Mock).mockResolvedValue({ status: 400, - response: { + json: jest.fn().mockResolvedValue({ error: "Invalid rememe", nfts: [{ tokenId: "1", raw: { error: "Token error" } }], - }, + }), }); renderComponent(); @@ -358,6 +380,33 @@ describe("RememeAddPage", () => { }); }); + it("stops submitting and shows the backend error when submission fails", async () => { + mockUseSignMessage.mockReturnValue({ + ...defaultSignMessage, + isSuccess: true, + data: "signature", + }); + + (global.fetch as jest.Mock).mockResolvedValue({ + status: 400, + json: jest.fn().mockResolvedValue({ + valid: false, + error: "Insufficient TDH", + }), + }); + + renderComponent(); + + fireEvent.click(screen.getByText("Verify Rememe")); + + await waitFor(() => { + expect(screen.getByText("Status: Fail")).toBeInTheDocument(); + expect(screen.getByText("Error: Insufficient TDH")).toBeInTheDocument(); + }); + + expect(screen.queryByText("Adding Rememe")).not.toBeInTheDocument(); + }); + it("handles sign message errors", () => { mockUseSignMessage.mockReturnValue({ ...defaultSignMessage, @@ -378,12 +427,12 @@ describe("RememeAddPage", () => { }; mockUseSignMessage.mockReturnValue(signMessageMock); - mockPostData.mockResolvedValue({ + (global.fetch as jest.Mock).mockResolvedValue({ status: 201, - response: { + json: jest.fn().mockResolvedValue({ contract: { address: "0xcontract" }, nfts: [{ tokenId: "1", name: "Test NFT", raw: {} }], - }, + }), }); renderComponent(); @@ -393,7 +442,7 @@ describe("RememeAddPage", () => { // Wait for API call to complete and submission to succeed await waitFor(() => { - expect(mockPostData).toHaveBeenCalled(); + expect(global.fetch).toHaveBeenCalled(); }); await waitFor( @@ -403,8 +452,7 @@ describe("RememeAddPage", () => { { timeout: 5000 } ); - fireEvent.click(screen.getByText("Add Another")); - expect(window.location.reload).toHaveBeenCalled(); + expect(screen.getByText("Add Another")).toBeInTheDocument(); }); it("fetches memes on component mount", () => { diff --git a/__tests__/components/rememes/Rememes.test.tsx b/__tests__/components/rememes/Rememes.test.tsx index 2885d0cff2..91e9416cb2 100644 --- a/__tests__/components/rememes/Rememes.test.tsx +++ b/__tests__/components/rememes/Rememes.test.tsx @@ -4,12 +4,16 @@ import { fetchUrl } from "@/services/6529api"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +const mockRouterReplace = jest.fn(); + jest.mock("next/navigation", () => ({ useRouter: () => ({ push: jest.fn(), + replace: mockRouterReplace, }), useSearchParams: jest.fn().mockReturnValue({ get: jest.fn().mockReturnValue(undefined), + toString: jest.fn().mockReturnValue(""), }), usePathname: jest.fn().mockReturnValue("/rememes"), })); @@ -67,14 +71,21 @@ describe("Rememes component", () => { ); await waitFor(() => expect(fetchUrl).toHaveBeenCalled()); expect(fetchUrl).toHaveBeenCalledWith( - "https://api.test.6529.io/api/rememes?page_size=40&page=1" + "https://api.test.6529.io/api/rememes?page_size=40&page=1", + expect.objectContaining({ signal: expect.any(Object) }) ); + expect( + (fetchUrl as jest.Mock).mock.calls.filter(([url]: [string]) => + url.includes("/api/rememes?") + ) + ).toHaveLength(1); await screen.findByText("Sort: Random"); await userEvent.click(screen.getByText("Sort: Random")); await userEvent.click(screen.getByText(RememeSort.CREATED_ASC)); await waitFor(() => expect(fetchUrl).toHaveBeenLastCalledWith( - "https://api.test.6529.io/api/rememes?page_size=40&page=1&sort=created_at&sort_direction=desc" + "https://api.test.6529.io/api/rememes?page_size=40&page=1&sort=created_at&sort_direction=desc", + expect.objectContaining({ signal: expect.any(Object) }) ) ); }); diff --git a/__tests__/services/api/notifications-v2-api.test.ts b/__tests__/services/api/notifications-v2-api.test.ts index 295dc7771f..65234dac56 100644 --- a/__tests__/services/api/notifications-v2-api.test.ts +++ b/__tests__/services/api/notifications-v2-api.test.ts @@ -211,6 +211,32 @@ describe("fetchNotificationsV2", () => { }); }); + it("does not default all-drops vote context to reset rating", async () => { + (commonApiFetch as jest.Mock).mockResolvedValue({ + unread_count: 0, + notifications: [ + { + id: 11, + cause: ApiNotificationCause.AllDrops, + created_at: 5000, + read_at: null, + related_identity: identity("poster"), + related_wave: wave, + related_drops: [drop], + additional_context: {}, + }, + ], + }); + + const response = await fetchNotificationsV2({ limit: "30" }); + const [notification] = response.notifications; + + expect(notification).toMatchObject({ + cause: ApiNotificationCause.AllDrops, + additional_context: {}, + }); + }); + it("drops unknown notification causes safely", async () => { const consoleErrorSpy = jest .spyOn(console, "error") diff --git a/components/drops/view/item/content/media/DropListItemContentMediaImage.tsx b/components/drops/view/item/content/media/DropListItemContentMediaImage.tsx index fd4ec474e9..c579e1abb9 100644 --- a/components/drops/view/item/content/media/DropListItemContentMediaImage.tsx +++ b/components/drops/view/item/content/media/DropListItemContentMediaImage.tsx @@ -22,6 +22,7 @@ function DropListItemContentMediaImage({ imageObjectPosition, imageScale = ImageScale.AUTOx450, loadStrategy = "in-view", + intrinsicHeight = false, }: { readonly src: string; readonly maxRetries?: number | undefined; @@ -30,6 +31,7 @@ function DropListItemContentMediaImage({ readonly imageObjectPosition?: string | undefined; readonly imageScale?: ImageScale | undefined; readonly loadStrategy?: MediaLoadStrategy | undefined; + readonly intrinsicHeight?: boolean | undefined; }) { const [ref, inView] = useInView(); const [loaded, setLoaded] = useState(false); @@ -121,12 +123,32 @@ function DropListItemContentMediaImage({ const resolvedObjectPosition = imageObjectPosition ?? (isCompetitionDrop ? "center" : "left top"); + const primarySrc = getScaledImageUri(src, imageScale); + const [currentSrc, setCurrentSrc] = useState(primarySrc); + const [usedFallback, setUsedFallback] = useState(false); + + React.useEffect(() => { + setCurrentSrc(primarySrc); + setUsedFallback(false); + }, [primarySrc]); + + const handleIntrinsicImageError = useCallback(() => { + if (!usedFallback) { + setCurrentSrc(src); + setUsedFallback(true); + return; + } + + handleError(); + }, [handleError, src, usedFallback]); return ( <>
@@ -151,27 +173,44 @@ function DropListItemContentMediaImage({ )} {shouldLoadImage && errorCount <= maxRetries && ( - + intrinsicHeight ? ( + Drop media + ) : ( + + ) )} {errorCount > maxRetries && (
diff --git a/components/drops/view/part/DropPartMarkdownImage.tsx b/components/drops/view/part/DropPartMarkdownImage.tsx index f5cffcc351..f5f1910fc9 100644 --- a/components/drops/view/part/DropPartMarkdownImage.tsx +++ b/components/drops/view/part/DropPartMarkdownImage.tsx @@ -10,8 +10,12 @@ export default function DropPartMarkdownImage({ src, }: DropPartMarkdownImageProps) { return ( -
- +
+
); } diff --git a/components/rememes/RememeAddComponent.tsx b/components/rememes/RememeAddComponent.tsx index 1829b4b699..718a64392f 100644 --- a/components/rememes/RememeAddComponent.tsx +++ b/components/rememes/RememeAddComponent.tsx @@ -380,20 +380,18 @@ export default function RememeAddComponent(props: Readonly) { {!verified ? ( diff --git a/components/rememes/RememeAddPage.tsx b/components/rememes/RememeAddPage.tsx index ee296d91bf..a7ec9898c3 100644 --- a/components/rememes/RememeAddPage.tsx +++ b/components/rememes/RememeAddPage.tsx @@ -8,8 +8,9 @@ import type { NFT } from "@/entities/INFT"; import type { ConsolidatedTDH } from "@/entities/ITDH"; import { getRandomObjectId } from "@/helpers/AllowlistToolHelpers"; import { areEqualAddresses, numberWithCommas } from "@/helpers/Helpers"; -import { fetchUrl, postData } from "@/services/6529api"; +import { fetchUrl } from "@/services/6529api"; import { commonApiFetch } from "@/services/api/common-api"; +import { getStagingAuth } from "@/services/auth/auth.utils"; import { faCheckCircle, faTimesCircle, @@ -31,6 +32,43 @@ interface CheckList { note: string; } +function getSubmissionErrorMessage(error: unknown): string { + if (error instanceof Error && error.message) { + return `Error: ${error.message}`; + } + return "Error: Failed to add Rememe"; +} + +async function postRememeSubmission(body: { + address: string | undefined; + signature: string; + rememe: { + contract: string | undefined; + token_ids: string[] | undefined; + references: number[] | undefined; + }; +}) { + const headers = new Headers({ "Content-Type": "application/json" }); + const apiAuth = getStagingAuth(); + if (apiAuth) { + headers.set("x-6529-auth", apiAuth); + } + + const response = await fetch(`${publicEnv.API_ENDPOINT}/api/rememes/add`, { + method: "POST", + body: JSON.stringify(body), + headers, + }); + const json = await response.json().catch(() => ({ + error: `HTTP error! status: ${response.status}`, + })); + + return { + status: response.status, + response: json as ProcessedRememe, + }; +} + export default function RememeAddPage() { useSetTitle("Add ReMemes | Collections"); const { connectedProfile } = useAuth(); @@ -140,43 +178,56 @@ export default function RememeAddPage() { }, [connectedProfile]); useEffect(() => { - if (signMessage.isSuccess && signMessage.data) { + if (signMessage.isSuccess && signMessage.data && addRememe) { setSubmitting(true); - postData(`${publicEnv.API_ENDPOINT}/api/rememes/add`, { + postRememeSubmission({ address: address, signature: signMessage.data, rememe: buildRememeObject(), - }).then((response) => { - const success = response.status === 201; - const processedRememe: ProcessedRememe = response.response; - const contract = processedRememe.contract?.address; - const tokens = processedRememe.nfts?.map((n) => { - return { - id: n.tokenId, - name: n.name ? n.name : `#${n.tokenId}`, - }; - }); + }) + .then((response) => { + const success = response.status === 201; + const processedRememe: ProcessedRememe = response.response; + const contract = processedRememe.contract?.address; + const tokens = processedRememe.nfts?.map((n) => { + return { + id: n.tokenId, + name: n.name ? n.name : `#${n.tokenId}`, + }; + }); - const nftError: string[] = processedRememe.nfts - ? processedRememe.nfts - .filter((n) => n.raw.error) - .map((n) => `#${n.tokenId} - ${n.raw.error}`) - : []; + const nftError: string[] = processedRememe.nfts + ? processedRememe.nfts + .filter((n) => n.raw.error) + .map((n) => `#${n.tokenId} - ${n.raw.error}`) + : []; - const message = processedRememe.error - ? [`Error: ${processedRememe.error}`] - : nftError; + const message = processedRememe.error + ? [`Error: ${processedRememe.error}`] + : nftError; + const errors = + !success && message.length === 0 + ? ["Error: Failed to add Rememe"] + : message; - setSubmitting(false); - setSubmissionResult({ - success, - errors: message, - contract, - tokens, + setSubmissionResult({ + success, + errors, + contract, + tokens, + }); + }) + .catch((error) => { + setSubmissionResult({ + success: false, + errors: [getSubmissionErrorMessage(error)], + }); + }) + .finally(() => { + setSubmitting(false); }); - }); } - }, [signMessage.data]); + }, [signMessage.data, addRememe]); function buildRememeObject() { return { @@ -316,16 +367,16 @@ export default function RememeAddPage() { {(submitting || signMessage.isPending) && ( - {signMessage.isPending && "Signing Message"} - {submitting && "Adding Rememe"} -
+ + {signMessage.isPending && "Signing Message"} + {submitting && "Adding Rememe"}
-
+
)} diff --git a/components/rememes/Rememes.module.scss b/components/rememes/Rememes.module.scss index 150f339792..a4f45e8db0 100644 --- a/components/rememes/Rememes.module.scss +++ b/components/rememes/Rememes.module.scss @@ -263,9 +263,12 @@ } .loader { - width: 20px; - height: 20px; - margin-left: 5px; + --bs-spinner-width: 1rem; + --bs-spinner-height: 1rem; + --bs-spinner-border-width: 0.14em; + + flex: 0 0 auto; + vertical-align: middle; } .addRememeChecklist { diff --git a/components/rememes/Rememes.tsx b/components/rememes/Rememes.tsx index 8a7f6ef6ef..c470a9e97d 100644 --- a/components/rememes/Rememes.tsx +++ b/components/rememes/Rememes.tsx @@ -21,7 +21,7 @@ import { faPlusCircle, faRefresh } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import Image from "next/image"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { Button, Col, Container, Dropdown, Row } from "react-bootstrap"; import { Tooltip } from "react-tooltip"; import styles from "./Rememes.module.scss"; @@ -58,14 +58,16 @@ export default function Rememes() { ); const queryMemeId = searchParams?.get("meme_id"); + const parsedQueryMemeId = queryMemeId ? parseInt(queryMemeId) : 0; const [selectedMeme, setSelectedMeme] = useState( - queryMemeId ? parseInt(queryMemeId) : 0 + Number.isFinite(parsedQueryMemeId) ? parsedQueryMemeId : 0 ); const sorting = [RememeSort.RANDOM, RememeSort.CREATED_ASC]; const [selectedSorting, setSelectedSorting] = useState( RememeSort.RANDOM ); + const activeFetchRequest = useRef(null); useEffect(() => { fetchUrl(`${publicEnv.API_ENDPOINT}/api/memes_lite`) @@ -77,8 +79,11 @@ export default function Rememes() { }); }, []); - function fetchResults(mypage: number) { + const fetchResults = useCallback((mypage: number) => { setRememesLoaded(false); + activeFetchRequest.current?.abort(); + const abortController = new AbortController(); + activeFetchRequest.current = abortController; let memeFilter = ""; if (selectedMeme) { memeFilter = `&meme_id=${selectedMeme}`; @@ -92,42 +97,76 @@ export default function Rememes() { sort = "&sort=created_at&sort_direction=desc"; } let url = `${publicEnv.API_ENDPOINT}/api/rememes?page_size=${PAGE_SIZE}&page=${mypage}${memeFilter}${tokenTypeFilter}${sort}`; - fetchUrl(url) + fetchUrl(url, { signal: abortController.signal }) .then((response: DBResponse) => { + if ( + abortController.signal.aborted || + activeFetchRequest.current !== abortController + ) { + return; + } setTotalResults(response.count); setRememes(response.data); }) .catch((err) => { + if (abortController.signal.aborted) { + return; + } console.error("Error fetching rememes", err); }) .finally(() => { - setRememesLoaded(true); + if (activeFetchRequest.current === abortController) { + activeFetchRequest.current = null; + setRememesLoaded(true); + } }); - } + }, [selectedMeme, selectedSorting, selectedTokenType]); + + const previousFilters = useRef({ + selectedMeme, + selectedSorting, + selectedTokenType, + }); useEffect(() => { - const currentId = searchParams?.get("meme_id") - ? parseInt(searchParams.get("meme_id")!) - : 0; - if (!currentId || currentId != selectedMeme) { - const newPath = `${pathname}${ - selectedMeme ? `?meme_id=${selectedMeme}` : "" - }`; - router.push(newPath); + const nextSearchParams = new URLSearchParams(searchParams?.toString()); + if (selectedMeme) { + nextSearchParams.set("meme_id", selectedMeme.toString()); + } else { + nextSearchParams.delete("meme_id"); + } + + const currentQuery = searchParams?.toString() ?? ""; + const nextQuery = nextSearchParams.toString(); + if (nextQuery !== currentQuery) { + const nextPath = nextQuery ? `${pathname}?${nextQuery}` : pathname; + router.replace(nextPath, { scroll: false }); } - }, [selectedMeme]); + }, [pathname, router, searchParams, selectedMeme]); useEffect(() => { - if (page === 1) { - fetchResults(page); - } else { + const filtersChanged = + previousFilters.current.selectedMeme !== selectedMeme || + previousFilters.current.selectedSorting !== selectedSorting || + previousFilters.current.selectedTokenType !== selectedTokenType; + + previousFilters.current = { + selectedMeme, + selectedSorting, + selectedTokenType, + }; + + if (filtersChanged && page !== 1) { setPage(1); + return; } - }, [selectedTokenType, selectedSorting, selectedMeme]); - useEffect(() => { fetchResults(page); - }, [page]); + return () => { + activeFetchRequest.current?.abort(); + activeFetchRequest.current = null; + }; + }, [fetchResults, page, selectedMeme, selectedSorting, selectedTokenType]); function printRememe(rememe: Rememe) { return ( diff --git a/services/api/notifications-v2-api.ts b/services/api/notifications-v2-api.ts index 50b9d46f88..27849609c7 100644 --- a/services/api/notifications-v2-api.ts +++ b/services/api/notifications-v2-api.ts @@ -309,7 +309,7 @@ const mapNotificationV2 = ( cause: ApiNotificationCause.AllDrops, related_drops: relatedDrops, additional_context: { - vote: context.vote ?? 0, + ...(typeof context.vote === "number" ? { vote: context.vote } : {}), }, }, ]; diff --git a/types/feed.types.ts b/types/feed.types.ts index 041e34825a..50dd208808 100644 --- a/types/feed.types.ts +++ b/types/feed.types.ts @@ -133,7 +133,7 @@ export type INotificationAllDrops = NotificationBase & WithDrops & { readonly cause: ApiNotificationCause.AllDrops; readonly additional_context: { - readonly vote: number; + readonly vote?: number | undefined; }; }; From a8ba8a48764f8ade0eb798ae356cc78294d0485e Mon Sep 17 00:00:00 2001 From: prxt6529 Date: Wed, 13 May 2026 09:22:52 +0300 Subject: [PATCH 2/4] WIP Signed-off-by: prxt6529 --- .../components/rememes/RememeAddPage.test.tsx | 42 ++++----- .../media/DropListItemContentMediaImage.tsx | 12 ++- components/rememes/Rememes.tsx | 87 ++++++++++--------- 3 files changed, 69 insertions(+), 72 deletions(-) diff --git a/__tests__/components/rememes/RememeAddPage.test.tsx b/__tests__/components/rememes/RememeAddPage.test.tsx index a600af812e..02825fe0e2 100644 --- a/__tests__/components/rememes/RememeAddPage.test.tsx +++ b/__tests__/components/rememes/RememeAddPage.test.tsx @@ -24,27 +24,24 @@ jest.mock("wagmi", () => ({ })); // Mock components -jest.mock( - "@/components/rememes/RememeAddComponent", - () => (props: any) => - ( -
- -
- ) -); +jest.mock("@/components/rememes/RememeAddComponent", () => (props: any) => ( +
+ +
+)); jest.mock("@fortawesome/react-fontawesome", () => ({ FontAwesomeIcon: (props: any) => ( @@ -85,8 +82,7 @@ jest.mock("@/helpers/Helpers", () => ({ // Get mocked functions const mockUseSignMessage = require("wagmi").useSignMessage as jest.Mock; -const mockUseAuth = require("@/components/auth/Auth") - .useAuth as jest.Mock; +const mockUseAuth = require("@/components/auth/Auth").useAuth as jest.Mock; const mockUseSeizeConnectContext = require("@/components/auth/SeizeConnectContext") .useSeizeConnectContext as jest.Mock; diff --git a/components/drops/view/item/content/media/DropListItemContentMediaImage.tsx b/components/drops/view/item/content/media/DropListItemContentMediaImage.tsx index c579e1abb9..0295777ff4 100644 --- a/components/drops/view/item/content/media/DropListItemContentMediaImage.tsx +++ b/components/drops/view/item/content/media/DropListItemContentMediaImage.tsx @@ -148,9 +148,7 @@ function DropListItemContentMediaImage({ ref={ref} className={`tw-relative tw-flex tw-w-full tw-items-center ${ intrinsicHeight ? "tw-min-h-40" : "tw-h-full" - } ${ - isCompetitionDrop ? "tw-justify-center" : "" - }`} + } ${isCompetitionDrop ? "tw-justify-center" : ""}`} > {!disableModal && ( )} - {shouldLoadImage && errorCount <= maxRetries && ( - intrinsicHeight ? ( + {shouldLoadImage && + errorCount <= maxRetries && + (intrinsicHeight ? ( - ) - )} + ))} {errorCount > maxRetries && (
diff --git a/components/rememes/Rememes.tsx b/components/rememes/Rememes.tsx index c470a9e97d..6e49f5f48a 100644 --- a/components/rememes/Rememes.tsx +++ b/components/rememes/Rememes.tsx @@ -79,48 +79,51 @@ export default function Rememes() { }); }, []); - const fetchResults = useCallback((mypage: number) => { - setRememesLoaded(false); - activeFetchRequest.current?.abort(); - const abortController = new AbortController(); - activeFetchRequest.current = abortController; - let memeFilter = ""; - if (selectedMeme) { - memeFilter = `&meme_id=${selectedMeme}`; - } - let tokenTypeFilter = ""; - if (selectedTokenType !== TokenType.ALL) { - tokenTypeFilter = `&token_type=${selectedTokenType.replaceAll("-", "")}`; - } - let sort = ""; - if (selectedSorting === RememeSort.CREATED_ASC) { - sort = "&sort=created_at&sort_direction=desc"; - } - let url = `${publicEnv.API_ENDPOINT}/api/rememes?page_size=${PAGE_SIZE}&page=${mypage}${memeFilter}${tokenTypeFilter}${sort}`; - fetchUrl(url, { signal: abortController.signal }) - .then((response: DBResponse) => { - if ( - abortController.signal.aborted || - activeFetchRequest.current !== abortController - ) { - return; - } - setTotalResults(response.count); - setRememes(response.data); - }) - .catch((err) => { - if (abortController.signal.aborted) { - return; - } - console.error("Error fetching rememes", err); - }) - .finally(() => { - if (activeFetchRequest.current === abortController) { - activeFetchRequest.current = null; - setRememesLoaded(true); - } - }); - }, [selectedMeme, selectedSorting, selectedTokenType]); + const fetchResults = useCallback( + (mypage: number) => { + setRememesLoaded(false); + activeFetchRequest.current?.abort(); + const abortController = new AbortController(); + activeFetchRequest.current = abortController; + let memeFilter = ""; + if (selectedMeme) { + memeFilter = `&meme_id=${selectedMeme}`; + } + let tokenTypeFilter = ""; + if (selectedTokenType !== TokenType.ALL) { + tokenTypeFilter = `&token_type=${selectedTokenType.replaceAll("-", "")}`; + } + let sort = ""; + if (selectedSorting === RememeSort.CREATED_ASC) { + sort = "&sort=created_at&sort_direction=desc"; + } + let url = `${publicEnv.API_ENDPOINT}/api/rememes?page_size=${PAGE_SIZE}&page=${mypage}${memeFilter}${tokenTypeFilter}${sort}`; + fetchUrl(url, { signal: abortController.signal }) + .then((response: DBResponse) => { + if ( + abortController.signal.aborted || + activeFetchRequest.current !== abortController + ) { + return; + } + setTotalResults(response.count); + setRememes(response.data); + }) + .catch((err) => { + if (abortController.signal.aborted) { + return; + } + console.error("Error fetching rememes", err); + }) + .finally(() => { + if (activeFetchRequest.current === abortController) { + activeFetchRequest.current = null; + setRememesLoaded(true); + } + }); + }, + [selectedMeme, selectedSorting, selectedTokenType] + ); const previousFilters = useRef({ selectedMeme, From 6126d5f432452d17132c0cd415bc9478be8f5847 Mon Sep 17 00:00:00 2001 From: prxt6529 Date: Wed, 13 May 2026 09:37:30 +0300 Subject: [PATCH 3/4] WIP Signed-off-by: prxt6529 --- .../DropListItemContentMediaImage.test.tsx | 16 ++ .../components/rememes/RememeAddPage.test.tsx | 14 +- .../services/api/notifications-v2-api.test.ts | 6 +- .../media/DropListItemContentMediaImage.tsx | 243 ++++++++++++------ components/rememes/Rememes.tsx | 2 +- 5 files changed, 196 insertions(+), 85 deletions(-) diff --git a/__tests__/components/DropListItemContentMediaImage.test.tsx b/__tests__/components/DropListItemContentMediaImage.test.tsx index 87903df34d..5ab191dd5d 100644 --- a/__tests__/components/DropListItemContentMediaImage.test.tsx +++ b/__tests__/components/DropListItemContentMediaImage.test.tsx @@ -101,6 +101,22 @@ describe("DropListItemContentMediaImage", () => { expect(img).toHaveClass("tw-h-auto", "tw-w-full", "tw-max-h-64"); expect(img).not.toHaveAttribute("data-nimg", "fill"); }); + + it("retries intrinsic-height images instead of swapping to the same fallback source", () => { + jest.useFakeTimers(); + const setTimeoutSpy = jest.spyOn(globalThis, "setTimeout"); + + render( + + ); + + fireEvent.error(screen.getByAltText("Drop media")); + + expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 500); + + setTimeoutSpy.mockRestore(); + jest.useRealTimers(); + }); }); describe("DropListItemContentMediaImage retry", () => { diff --git a/__tests__/components/rememes/RememeAddPage.test.tsx b/__tests__/components/rememes/RememeAddPage.test.tsx index 02825fe0e2..90e0e99997 100644 --- a/__tests__/components/rememes/RememeAddPage.test.tsx +++ b/__tests__/components/rememes/RememeAddPage.test.tsx @@ -137,7 +137,7 @@ describe("RememeAddPage", () => { }); mockFetchUrl.mockResolvedValue({ data: [] }); mockCommonApiFetch.mockResolvedValue({ boosted_tdh: 10000 }); - global.fetch = jest.fn().mockResolvedValue({ + globalThis.fetch = jest.fn().mockResolvedValue({ status: 201, json: jest.fn().mockResolvedValue({ contract: { address: "0xcontract" }, @@ -299,7 +299,7 @@ describe("RememeAddPage", () => { data: "signature", }); - (global.fetch as jest.Mock).mockImplementation( + (globalThis.fetch as jest.Mock).mockImplementation( () => new Promise((resolve) => { setTimeout( @@ -329,7 +329,7 @@ describe("RememeAddPage", () => { data: "signature", }); - (global.fetch as jest.Mock).mockResolvedValue({ + (globalThis.fetch as jest.Mock).mockResolvedValue({ status: 201, json: jest.fn().mockResolvedValue({ contract: { address: "0xcontract" }, @@ -357,7 +357,7 @@ describe("RememeAddPage", () => { data: "signature", }); - (global.fetch as jest.Mock).mockResolvedValue({ + (globalThis.fetch as jest.Mock).mockResolvedValue({ status: 400, json: jest.fn().mockResolvedValue({ error: "Invalid rememe", @@ -383,7 +383,7 @@ describe("RememeAddPage", () => { data: "signature", }); - (global.fetch as jest.Mock).mockResolvedValue({ + (globalThis.fetch as jest.Mock).mockResolvedValue({ status: 400, json: jest.fn().mockResolvedValue({ valid: false, @@ -423,7 +423,7 @@ describe("RememeAddPage", () => { }; mockUseSignMessage.mockReturnValue(signMessageMock); - (global.fetch as jest.Mock).mockResolvedValue({ + (globalThis.fetch as jest.Mock).mockResolvedValue({ status: 201, json: jest.fn().mockResolvedValue({ contract: { address: "0xcontract" }, @@ -438,7 +438,7 @@ describe("RememeAddPage", () => { // Wait for API call to complete and submission to succeed await waitFor(() => { - expect(global.fetch).toHaveBeenCalled(); + expect(globalThis.fetch).toHaveBeenCalled(); }); await waitFor( diff --git a/__tests__/services/api/notifications-v2-api.test.ts b/__tests__/services/api/notifications-v2-api.test.ts index 65234dac56..a174d3f8ac 100644 --- a/__tests__/services/api/notifications-v2-api.test.ts +++ b/__tests__/services/api/notifications-v2-api.test.ts @@ -231,10 +231,8 @@ describe("fetchNotificationsV2", () => { const response = await fetchNotificationsV2({ limit: "30" }); const [notification] = response.notifications; - expect(notification).toMatchObject({ - cause: ApiNotificationCause.AllDrops, - additional_context: {}, - }); + expect(notification?.cause).toBe(ApiNotificationCause.AllDrops); + expect(notification?.additional_context).toEqual({}); }); it("drops unknown notification causes safely", async () => { diff --git a/components/drops/view/item/content/media/DropListItemContentMediaImage.tsx b/components/drops/view/item/content/media/DropListItemContentMediaImage.tsx index 0295777ff4..db32e0bd61 100644 --- a/components/drops/view/item/content/media/DropListItemContentMediaImage.tsx +++ b/components/drops/view/item/content/media/DropListItemContentMediaImage.tsx @@ -14,6 +14,150 @@ import { import type { MediaLoadStrategy } from "./mediaLoadStrategy"; import { useMediaActions } from "./useMediaActions"; +const loadingPlaceholderStyle: React.CSSProperties = { + width: "100%", + height: "100%", + maxWidth: "100%", + maxHeight: "100%", + position: "absolute", + top: "50%", + left: "50%", + transform: "translate(-50%, -50%)", +}; + +function LoadingPlaceholder({ + hasTouchScreen, +}: { + readonly hasTouchScreen: boolean; +}) { + return ( +
+ ); +} + +function ImageButton({ + children, + intrinsicHeight, + onClick, +}: { + readonly children: React.ReactNode; + readonly intrinsicHeight: boolean; + readonly onClick: React.MouseEventHandler; +}) { + return ( + + ); +} + +function RetryImageMessage({ onRetry }: { readonly onRetry: () => void }) { + return ( +
+ Couldn’t load image. + +
+ ); +} + +function DropImageContent({ + src, + primarySrc, + currentSrc, + retryTick, + imgRef, + loaded, + disableModal, + intrinsicHeight, + loadStrategy, + resolvedObjectPosition, + handleImageLoad, + handleImageClick, + handleIntrinsicImageError, + handleError, +}: { + readonly src: string; + readonly primarySrc: string; + readonly currentSrc: string; + readonly retryTick: number; + readonly imgRef: React.RefObject; + readonly loaded: boolean; + readonly disableModal: boolean; + readonly intrinsicHeight: boolean; + readonly loadStrategy: MediaLoadStrategy; + readonly resolvedObjectPosition: string; + readonly handleImageLoad: () => void; + readonly handleImageClick: React.MouseEventHandler; + readonly handleIntrinsicImageError: () => void; + readonly handleError: () => void; +}) { + const imageClassName = `tw-max-h-full tw-max-w-full ${ + loaded ? "tw-opacity-100" : "tw-opacity-0" + } ${disableModal ? "" : "tw-cursor-pointer"}`; + + const image = intrinsicHeight ? ( + Drop media + ) : ( + + ); + + if (disableModal) { + return image; + } + + return ( + + {image} + + ); +} + function DropListItemContentMediaImage({ src, maxRetries = 0, @@ -79,7 +223,7 @@ function DropListItemContentMediaImage({ }, [disableModal]); const handleImageClick = useCallback( - (event: React.MouseEvent) => { + (event: React.MouseEvent) => { if (disableModal) { return; } @@ -109,16 +253,6 @@ function DropListItemContentMediaImage({ } }, []); - const loadingPlaceholderStyle: React.CSSProperties = { - width: "100%", - height: "100%", - maxWidth: "100%", - maxHeight: "100%", - position: "absolute", - top: "50%", - left: "50%", - transform: "translate(-50%, -50%)", - }; const shouldLoadImage = loadStrategy === "eager" || inView; const resolvedObjectPosition = @@ -134,13 +268,15 @@ function DropListItemContentMediaImage({ const handleIntrinsicImageError = useCallback(() => { if (!usedFallback) { - setCurrentSrc(src); - setUsedFallback(true); - return; + if (currentSrc !== src) { + setCurrentSrc(src); + setUsedFallback(true); + return; + } } handleError(); - }, [handleError, src, usedFallback]); + }, [currentSrc, handleError, src, usedFallback]); return ( <> @@ -162,67 +298,28 @@ function DropListItemContentMediaImage({ /> )} {!loaded && errorCount <= maxRetries && ( -
+ )} - {shouldLoadImage && - errorCount <= maxRetries && - (intrinsicHeight ? ( - Drop media - ) : ( - - ))} - {errorCount > maxRetries && ( -
- - Couldn’t load image. - - -
+ {shouldLoadImage && errorCount <= maxRetries && ( + )} + {errorCount > maxRetries && }
{!disableModal && isModalOpen && ( ( Number.isFinite(parsedQueryMemeId) ? parsedQueryMemeId : 0 ); From c73eedbef37eeac7798292f989ae33367e1e0e26 Mon Sep 17 00:00:00 2001 From: prxt6529 Date: Wed, 13 May 2026 10:47:32 +0300 Subject: [PATCH 4/4] WIP Signed-off-by: prxt6529 --- components/rememes/RememeAddComponent.tsx | 16 +++++++++------- components/rememes/Rememes.module.scss | 23 +++++++++++++++++++---- 2 files changed, 28 insertions(+), 11 deletions(-) diff --git a/components/rememes/RememeAddComponent.tsx b/components/rememes/RememeAddComponent.tsx index 718a64392f..fdd261754b 100644 --- a/components/rememes/RememeAddComponent.tsx +++ b/components/rememes/RememeAddComponent.tsx @@ -380,19 +380,21 @@ export default function RememeAddComponent(props: Readonly) { {!verified ? ( ) : ( diff --git a/components/rememes/Rememes.module.scss b/components/rememes/Rememes.module.scss index a4f45e8db0..3eb7c34e3d 100644 --- a/components/rememes/Rememes.module.scss +++ b/components/rememes/Rememes.module.scss @@ -262,11 +262,26 @@ margin-left: 20px; } -.loader { - --bs-spinner-width: 1rem; - --bs-spinner-height: 1rem; - --bs-spinner-border-width: 0.14em; +.validateButton { + display: inline-flex !important; + align-items: center; + gap: 8px; + line-height: 1.5; +} +.loaderSlot { + display: inline-flex; + align-items: center; + justify-content: center; + width: 12px; + height: 12px; + line-height: 0; +} + +.loader { + width: 12px !important; + height: 12px !important; + border-width: 1.5px !important; flex: 0 0 auto; vertical-align: middle; }