From ac6a4f3a8ab1e955e0b5cc0232294680087ac655 Mon Sep 17 00:00:00 2001 From: Ayush Srivastava Date: Fri, 3 Apr 2026 06:11:26 +0530 Subject: [PATCH] =?UTF-8?q?Added=20a=20relay=20backed=20fallback=20for=20c?= =?UTF-8?q?ache=20misses,=20so=20direct=20naddr=20listing=20URLs=20can=20s?= =?UTF-8?q?till=20resolve=20when=20the=20listing=20isn=E2=80=99t=20yet=20a?= =?UTF-8?q?vailable=20in=20the=20local=20DB/cache.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/__tests__/listing-page.test.tsx | 204 +++++++++++++++++++++ pages/listing/[[...productId]].tsx | 166 +++++++++++++++-- utils/nostr/fetch-service.ts | 123 ++++++++++++- 3 files changed, 474 insertions(+), 19 deletions(-) create mode 100644 components/__tests__/listing-page.test.tsx diff --git a/components/__tests__/listing-page.test.tsx b/components/__tests__/listing-page.test.tsx new file mode 100644 index 000000000..7a69e1a3d --- /dev/null +++ b/components/__tests__/listing-page.test.tsx @@ -0,0 +1,204 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import { useRouter } from "next/router"; +import ListingPage from "../../pages/listing/[[...productId]]"; +import { ProductContext } from "@/utils/context/context"; +import { NostrContext } from "@/components/utility-components/nostr-context-provider"; +import { fetchProductByIdentifierFromRelays } from "@/utils/nostr/fetch-service"; +import { NostrEvent } from "@/utils/types/types"; + +jest.mock("next/router", () => ({ __esModule: true, useRouter: jest.fn() })); +jest.mock("nostr-tools", () => ({ + Event: {}, + nip19: { + decode: jest.fn(), + naddrEncode: jest.fn(() => "naddr1encoded"), + }, +})); +jest.mock("@/utils/nostr/fetch-service", () => ({ + fetchProductByIdentifierFromRelays: jest.fn(), +})); +jest.mock("@/utils/nostr/nostr-helper-functions", () => ({ + getLocalStorageData: jest.fn(() => ({ + relays: ["wss://relay.one"], + readRelays: [], + })), + getDefaultRelays: jest.fn(() => ["wss://relay.default"]), +})); +jest.mock( + "@/components/storefront/storefront-theme-wrapper", + () => + function MockStorefrontThemeWrapper({ + children, + }: { + children: any; + }) { + return
{children}
; + } +); +jest.mock( + "../../components/utility-components/checkout-card", + () => + function MockCheckoutCard({ productData }: { productData: any }) { + return
{productData.title}
; + } +); +jest.mock( + "../../components/utility-components/modals/event-modals", + () => ({ + RawEventModal: () => null, + EventIdModal: () => null, + }) +); + +const mockUseRouter = useRouter as jest.Mock; +const mockFetchProductByIdentifierFromRelays = + fetchProductByIdentifierFromRelays as jest.Mock; + +const baseEvent: NostrEvent = { + id: "event-id", + pubkey: "f".repeat(64), + created_at: 1710000000, + kind: 30402, + tags: [ + ["d", "listing-d-tag"], + ["title", "Cold Load Listing"], + ["summary", "Loads from relay fallback"], + ["image", "https://example.com/listing.png"], + ["price", "10", "USD"], + ["shipping", "Free"], + ["location", "Online"], + ], + content: "", + sig: "signature", +}; + +const nextEvent: NostrEvent = { + id: "next-event-id", + pubkey: "e".repeat(64), + created_at: 1710000100, + kind: 30402, + tags: [ + ["d", "fresh-direct-load"], + ["title", "Fresh Direct Load"], + ["summary", "Loads after a route change"], + ["image", "https://example.com/fresh.png"], + ["price", "20", "USD"], + ["shipping", "Worldwide"], + ["location", "Remote"], + ], + content: "", + sig: "next-signature", +}; + +describe("Listing page relay fallback", () => { + let routerState: { + isReady: boolean; + push: jest.Mock; + replace: jest.Mock; + query: { productId: string[] }; + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockFetchProductByIdentifierFromRelays.mockResolvedValue(null); + routerState = { + isReady: true, + push: jest.fn(), + replace: jest.fn(), + query: { productId: ["naddr1testlisting"] }, + }; + mockUseRouter.mockImplementation(() => routerState); + }); + + it("falls back to relay fetch when product context misses", async () => { + mockFetchProductByIdentifierFromRelays.mockResolvedValue(baseEvent); + + render( + + + + + + ); + + expect( + await screen.findByTestId("checkout-card") + ).toHaveTextContent("Cold Load Listing"); + expect(mockFetchProductByIdentifierFromRelays).toHaveBeenCalledWith( + expect.anything(), + ["wss://relay.one"], + "naddr1testlisting" + ); + }); + + it("clears stale listing state before fetching a new direct route", async () => { + const addNewlyCreatedProductEvent = jest.fn(); + mockFetchProductByIdentifierFromRelays + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(nextEvent); + routerState = { + ...routerState, + query: { productId: ["event-id"] }, + }; + + const { rerender } = render( + + + + + + ); + + expect(await screen.findByTestId("checkout-card")).toHaveTextContent( + "Cold Load Listing" + ); + + routerState = { + ...routerState, + query: { productId: ["naddr1freshlisting"] }, + }; + + rerender( + + + + + + ); + + await waitFor(() => + expect(screen.getByTestId("checkout-card")).toHaveTextContent( + "Fresh Direct Load" + ) + ); + + expect(screen.queryByText("Cold Load Listing")).not.toBeInTheDocument(); + expect(mockFetchProductByIdentifierFromRelays).toHaveBeenCalledWith( + expect.anything(), + ["wss://relay.one"], + "naddr1freshlisting" + ); + expect(addNewlyCreatedProductEvent).toHaveBeenCalledWith(nextEvent); + }); +}); diff --git a/pages/listing/[[...productId]].tsx b/pages/listing/[[...productId]].tsx index f650435de..cf8f3b8b7 100644 --- a/pages/listing/[[...productId]].tsx +++ b/pages/listing/[[...productId]].tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useContext } from "react"; +import { useState, useEffect, useContext, useRef } from "react"; import { useRouter } from "next/router"; import { Modal, @@ -28,7 +28,7 @@ import { RawEventModal, EventIdModal, } from "../../components/utility-components/modals/event-modals"; -import { findProductBySlug, getListingSlug } from "@/utils/url-slugs"; +import { findProductBySlug, getListingSlug, titleToSlug } from "@/utils/url-slugs"; import StorefrontThemeWrapper from "@/components/storefront/storefront-theme-wrapper"; import { GetServerSideProps } from "next"; import { OgMetaProps, DEFAULT_OG } from "@/components/og-head"; @@ -38,6 +38,12 @@ import { fetchProductByTitleSlug, } from "@/utils/db/db-service"; import { NostrEvent } from "@/utils/types/types"; +import { NostrContext } from "@/components/utility-components/nostr-context-provider"; +import { + getDefaultRelays, + getLocalStorageData, +} from "@/utils/nostr/nostr-helper-functions"; +import { fetchProductByIdentifierFromRelays } from "@/utils/nostr/fetch-service"; type ListingPageProps = { ogMeta: OgMetaProps; @@ -67,6 +73,70 @@ const LISTING_FALLBACK: OgMetaProps = { description: "Check out this listing on Shopstr!", }; +function getListingStateFromEvent(event: Event | NostrEvent | undefined) { + if (!event) { + return { + parsedProduct: undefined, + rawEvent: undefined, + isZapsnag: false, + }; + } + + if (event.kind === 1) { + return { + parsedProduct: parseZapsnagNote(event as Event), + rawEvent: event as Event, + isZapsnag: true, + }; + } + + return { + parsedProduct: parseTags(event as Event), + rawEvent: event as Event, + isZapsnag: false, + }; +} + +function eventMatchesIdentifier( + event: Event | NostrEvent | undefined, + identifier: string +): boolean { + if (!event || !identifier) return false; + if (event.id === identifier) return true; + + const dTag = event.tags.find((tag: string[]) => tag[0] === "d")?.[1]; + if (dTag === identifier) return true; + + if (identifier.startsWith("naddr1") && dTag) { + try { + return ( + nip19.naddrEncode({ + identifier: dTag, + pubkey: event.pubkey, + kind: event.kind, + }) === identifier + ); + } catch { + return false; + } + } + + const title = event.tags.find((tag: string[]) => tag[0] === "title")?.[1]; + if (!title) return false; + + const normalizedIdentifier = identifier.toLowerCase(); + const slug = titleToSlug(title).toLowerCase(); + const slugWithPubkeySuffixMatch = identifier.match(/^(.+)-([a-f0-9]{8})$/); + if (slugWithPubkeySuffixMatch) { + return ( + slug === slugWithPubkeySuffixMatch[1]!.toLowerCase() && + event.pubkey.startsWith(slugWithPubkeySuffixMatch[2]!) + ); + } + + return slug === normalizedIdentifier; +} + export const getServerSideProps: GetServerSideProps = async ( context ) => { @@ -111,6 +181,7 @@ export const getServerSideProps: GetServerSideProps = async ( const Listing = () => { const router = useRouter(); + const { nostr } = useContext(NostrContext); const [productData, setProductData] = useState( undefined ); @@ -125,6 +196,7 @@ const Listing = () => { const [invoiceGenerationFailed, setInvoiceGenerationFailed] = useState(false); const [cashuPaymentSent, setCashuPaymentSent] = useState(false); const [cashuPaymentFailed, setCashuPaymentFailed] = useState(false); + const relayFetchAttemptedRef = useRef(""); const productContext = useContext(ProductContext); @@ -140,13 +212,25 @@ const Listing = () => { useEffect(() => { if (router.isReady) { const { productId } = router.query; - const productIdString = productId ? productId[0] : ""; - setProductIdString(productIdString!); - if (!productIdString) { + const nextProductIdString = Array.isArray(productId) + ? productId[0] || "" + : productId || ""; + setProductIdString(nextProductIdString); + if (!nextProductIdString) { router.push("/marketplace"); } } - }, [router]); + }, [router, router.isReady, router.query]); + + useEffect(() => { + relayFetchAttemptedRef.current = ""; + + if (eventMatchesIdentifier(rawEvent, productIdString)) return; + + setRawEvent(undefined); + setIsZapsnag(false); + setProductData(undefined); + }, [productIdString]); useEffect(() => { if (!productContext.isLoading && productContext.productEvents) { @@ -190,19 +274,13 @@ const Listing = () => { localStorage.removeItem("sf_seller_pubkey"); localStorage.removeItem("sf_shop_slug"); } - setRawEvent(matchingEvent); - let parsed; - if (matchingEvent.kind === 1) { - parsed = parseZapsnagNote(matchingEvent); - setIsZapsnag(true); - } else { - parsed = parseTags(matchingEvent); - setIsZapsnag(false); - } - setProductData(parsed); + const nextState = getListingStateFromEvent(matchingEvent); + setRawEvent(nextState.rawEvent); + setIsZapsnag(nextState.isZapsnag); + setProductData(nextState.parsedProduct); - if (parsed && parsed.title && matchingEvent.kind !== 1) { - const canonicalSlug = getListingSlug(parsed, allParsed); + if (nextState.parsedProduct && matchingEvent.kind !== 1) { + const canonicalSlug = getListingSlug(nextState.parsedProduct, allParsed); if (canonicalSlug && productIdString !== canonicalSlug) { router.replace(`/listing/${canonicalSlug}`, undefined, { shallow: true, @@ -213,6 +291,58 @@ const Listing = () => { } }, [productContext.isLoading, productContext.productEvents, productIdString]); + useEffect(() => { + if (!nostr || !router.isReady || !productIdString || productData) return; + if (relayFetchAttemptedRef.current === productIdString) return; + + const nostrManager = nostr; + relayFetchAttemptedRef.current = productIdString; + + let isActive = true; + + async function fetchListingFromRelays() { + const { relays, readRelays } = getLocalStorageData(); + const targetRelays = [ + ...new Set([...(relays || []), ...(readRelays || [])]), + ]; + const effectiveRelays = + targetRelays.length > 0 ? targetRelays : getDefaultRelays(); + + const fetchedEvent = await fetchProductByIdentifierFromRelays( + nostrManager, + effectiveRelays, + productIdString + ); + + if (!fetchedEvent || !isActive) return; + + const existingEvents = Array.isArray(productContext.productEvents) + ? productContext.productEvents + : []; + if (!existingEvents.some((event: Event) => event.id === fetchedEvent.id)) { + productContext.addNewlyCreatedProductEvent(fetchedEvent); + } + + const nextState = getListingStateFromEvent(fetchedEvent); + setRawEvent(nextState.rawEvent); + setIsZapsnag(nextState.isZapsnag); + setProductData(nextState.parsedProduct); + } + + fetchListingFromRelays(); + + return () => { + isActive = false; + }; + }, [ + nostr, + productContext.addNewlyCreatedProductEvent, + productContext.productEvents, + productData, + productIdString, + router.isReady, + ]); + return (
diff --git a/utils/nostr/fetch-service.ts b/utils/nostr/fetch-service.ts index be772eb56..06cb126da 100644 --- a/utils/nostr/fetch-service.ts +++ b/utils/nostr/fetch-service.ts @@ -1,4 +1,4 @@ -import { Filter } from "nostr-tools"; +import { Filter, nip19 } from "nostr-tools"; import { NostrEvent, NostrMessageEvent, @@ -39,6 +39,127 @@ function isHexString(value: string): boolean { return /^[0-9a-fA-F]{64}$/.test(value); } +function extractRelayUrlsFromRelayEvents(events: NostrEvent[]): string[] { + const relaySet = new Set(); + + for (const event of events) { + const relayTags = event.tags?.filter( + (tag: string[]) => tag[0] === "r" && tag[1] + ); + relayTags?.forEach((tag: string[]) => relaySet.add(tag[1]!)); + } + + return Array.from(relaySet); +} + +async function fetchPubkeyRelayList( + nostr: NostrManager, + relays: string[], + pubkey: string +): Promise { + const relaySet = new Set(); + + try { + const response = await fetch(`/api/db/fetch-relays?pubkey=${pubkey}`); + if (response.ok) { + const relayEventsFromDb: NostrEvent[] = await response.json(); + extractRelayUrlsFromRelayEvents(relayEventsFromDb).forEach((relay) => + relaySet.add(relay) + ); + } + } catch (error) { + console.error("Failed to fetch author relay list from database:", error); + } + + try { + const relayEvents = await nostr.fetch( + [ + { + kinds: [10002], + authors: [pubkey], + }, + ], + {}, + relays + ); + + const validRelayEvents = relayEvents.filter( + (event) => event.id && event.sig && event.pubkey && event.kind === 10002 + ); + if (validRelayEvents.length > 0) { + cacheEventsToDatabase(validRelayEvents).catch((error) => + console.error("Failed to cache author relay list events:", error) + ); + } + + extractRelayUrlsFromRelayEvents(relayEvents).forEach((relay) => + relaySet.add(relay) + ); + } catch (error) { + console.error("Failed to fetch author relay list from relays:", error); + } + + return Array.from(relaySet); +} + +export const fetchProductByIdentifierFromRelays = async ( + nostr: NostrManager, + relays: string[], + identifier: string +): Promise => { + try { + let filters: Filter[] = []; + let targetRelays = [...new Set(relays)]; + + if (identifier.startsWith("naddr1")) { + const decoded = nip19.decode(identifier); + if (decoded.type !== "naddr") return null; + + const authorRelays = await fetchPubkeyRelayList( + nostr, + targetRelays, + decoded.data.pubkey + ); + targetRelays = [...new Set([...targetRelays, ...authorRelays])]; + + filters = [ + { + kinds: [decoded.data.kind], + authors: [decoded.data.pubkey], + "#d": [decoded.data.identifier], + }, + ]; + } else if (isHexString(identifier)) { + filters = [ + { + ids: [identifier], + }, + ]; + } else { + return null; + } + + const fetchedEvents = await nostr.fetch(filters, {}, targetRelays); + if (fetchedEvents.length === 0) return null; + + const sortedEvents = [...fetchedEvents].sort( + (a, b) => b.created_at - a.created_at + ); + const matchedEvent = sortedEvents[0]!; + + if (matchedEvent.id && matchedEvent.sig && matchedEvent.pubkey) { + cacheEventsToDatabase([matchedEvent]).catch((error) => + console.error("Failed to cache fetched listing event:", error) + ); + } + + return matchedEvent; + } catch (error) { + console.error("Failed to fetch product from relays:", error); + return null; + } +}; + export const fetchAllPosts = async ( nostr: NostrManager, relays: string[],