diff --git a/.claude/launch.json b/.claude/launch.json index e124023..d15da17 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -6,6 +6,12 @@ "runtimeExecutable": "npm", "runtimeArgs": ["run", "dev", "--workspace=packages/app"], "port": 5173 + }, + { + "name": "prod-smoke", + "runtimeExecutable": "python3", + "runtimeArgs": ["-m", "http.server", "8931", "--directory", "packages/app/dist"], + "port": 8931 } ] } diff --git a/packages/app/src/components/RecentlyMinted.tsx b/packages/app/src/components/RecentlyMinted.tsx new file mode 100644 index 0000000..cbc10b0 --- /dev/null +++ b/packages/app/src/components/RecentlyMinted.tsx @@ -0,0 +1,494 @@ +/** + * Recently Minted — the Market hub's global token-discovery feed. + * + * Lists the newest glyphs on the network straight from RXinDexer's v4 recency + * index (glyph.get_recent), across every token type or narrowed to WAVE names + * for the name marketplace. Rows are indexer rows (other wallets' mints + * included) rendered without any per-row network fetches; opening a row seeds + * the glyph into the local DB (fetchGlyph) and routes to the existing detail + * view for its type. Pagination is an opaque forward cursor held in memory + * only — cursors are order- and filter-specific, so they are never persisted + * and never carried across a filter change. + */ +import { + Alert, + AlertIcon, + Badge, + Box, + Button, + ButtonGroup, + Flex, + HStack, + Icon, + Skeleton, + Spacer, + Spinner, + Text, + useToast, + VStack, +} from "@chakra-ui/react"; +import { MdRefresh } from "react-icons/md"; +import { + TbAt, + TbChevronRight, + TbCoins, + TbFileDescription, + TbFolder, + TbHexagon, + TbPhoto, + TbPick, + TbShieldCheck, + TbSparkles, +} from "react-icons/tb"; +import { IconType } from "react-icons/lib"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { useLiveQuery } from "dexie-react-hooks"; +import Card from "@app/components/Card"; +import NoContent from "@app/components/NoContent"; +import TokenContent from "@app/components/TokenContent"; +import db from "@app/db"; +import { electrumWorker } from "@app/electrum/Electrum"; +import type { RecentGlyphToken } from "@app/electrum/worker/electrumWorker"; +import { electrumStatus } from "@app/signals"; +import { ElectrumStatus, SmartToken, SmartTokenType } from "@app/types"; +import { getWaveDisplay } from "@lib/wave"; +import { reverseRef } from "@lib/Outpoint"; +import { shortRef } from "@app/marketModel"; + +const PAGE_SIZE = 30; +// GlyphTokenType.WAVE in the indexer's id space (1=FT 2=NFT 3=DAT 4=DMINT +// 5=WAVE 6=Container 7=Authority). +const WAVE_TOKEN_TYPE = 5; + +type RecentFilter = "all" | "names"; + +// Per-type row icon + badge colour, keyed by the indexer's GlyphTokenType id. +const TYPE_META: Record = { + 1: { icon: TbCoins, color: "cyan" }, + 2: { icon: TbPhoto, color: "purple" }, + 3: { icon: TbFileDescription, color: "gray" }, + 4: { icon: TbPick, color: "orange" }, + 5: { icon: TbAt, color: "blue" }, + 6: { icon: TbFolder, color: "green" }, + 7: { icon: TbShieldCheck, color: "red" }, +}; +const DEFAULT_TYPE_META = { icon: TbHexagon, color: "gray" }; + +// BE 72-hex ref — the form db.glyph and the detail routes key on. `ref_hex` is +// the raw LE (script-operand) form; fall back to parsing the display +// `txid_vout` if a server omits it. +function rowRefBE(row: RecentGlyphToken): string | null { + if (row.ref_hex && /^[0-9a-f]{72}$/i.test(row.ref_hex)) { + return reverseRef(row.ref_hex); + } + const m = row.ref?.match(/^([0-9a-f]{64})_(\d+)$/i); + if (!m) return null; + return m[1] + parseInt(m[2], 10).toString(16).padStart(8, "0"); +} + +// Best display name for a row: the local glyph (WAVE display name first) wins, +// else the indexer row's own attrs/name. WAVE names live in attrs, not `name`, +// so a bare row.name would show most names as untitled. +function displayName( + row: RecentGlyphToken, + glyph: SmartToken | undefined +): string | null { + if (glyph) { + const wave = getWaveDisplay(glyph); + if (wave?.full) return wave.full; + if (glyph.name) return glyph.name; + } + const wave = getWaveDisplay({ p: row.protocols, attrs: row.attrs }); + if (wave?.full) return wave.full; + return row.name || null; +} + +export default function RecentlyMinted() { + const toast = useToast(); + const navigate = useNavigate(); + const [filter, setFilter] = useState("all"); + const [tokens, setTokens] = useState([]); + const [nextCursor, setNextCursor] = useState(null); + const [loading, setLoading] = useState(false); + const [loadingMore, setLoadingMore] = useState(false); + const [loaded, setLoaded] = useState(false); + const [unavailable, setUnavailable] = useState(false); + const [openingRef, setOpeningRef] = useState(null); + const [tipHeight, setTipHeight] = useState(0); + // Monotonic id so a slow response from before a filter change / refresh + // can't clobber the newer page. + const requestSeq = useRef(0); + + const connected = electrumStatus.value === ElectrumStatus.CONNECTED; + + const fetchPage = useCallback( + async (f: RecentFilter, cursor: string | null) => { + const seq = ++requestSeq.current; + const isFirst = cursor === null; + if (isFirst) setLoading(true); + else setLoadingMore(true); + try { + const page = await electrumWorker.value.getRecentGlyphs( + PAGE_SIZE, + cursor, + f === "names" ? WAVE_TOKEN_TYPE : undefined + ); + if (seq !== requestSeq.current) return; + if (!page) { + setUnavailable(true); + if (isFirst) { + setTokens([]); + setNextCursor(null); + } + return; + } + setUnavailable(false); + setTokens((prev) => { + const base = isFirst ? [] : prev; + const seen = new Set(base.map((t) => t.ref)); + const merged = [...base]; + for (const t of page.tokens) { + if (!seen.has(t.ref)) { + seen.add(t.ref); + merged.push(t); + } + } + return merged; + }); + setNextCursor(page.next_cursor); + } finally { + if (seq === requestSeq.current) { + setLoading(false); + setLoadingMore(false); + setLoaded(true); + } + } + }, + [] + ); + + useEffect(() => { + if (connected) fetchPage(filter, null); + }, [connected, filter, fetchPage]); + + useEffect(() => { + if (!connected) return; + electrumWorker.value + .getBlockHeight() + .then(setTipHeight) + .catch(() => {}); + }, [connected]); + + const changeFilter = (f: RecentFilter) => { + if (f === filter) return; + setFilter(f); + setTokens([]); + setNextCursor(null); + setLoaded(false); + }; + + // Glyphs the wallet already has locally (own + previously fetched tokens): + // real thumbnails and names for those rows, no network round-trips. + const localGlyphs = useLiveQuery(async () => { + const refs = tokens + .map(rowRefBE) + .filter((r): r is string => r !== null); + if (refs.length === 0) return [] as SmartToken[]; + return db.glyph.where("ref").anyOf(refs).toArray(); + }, [tokens]); + const glyphByRef = useMemo( + () => new Map((localGlyphs || []).map((g) => [g.ref, g])), + [localGlyphs] + ); + + // Seed the glyph locally (idempotent) so the existing detail views — which + // read db.glyph — can render tokens minted by other wallets, then route by + // the decoded token type. + const openToken = async (row: RecentGlyphToken) => { + const refBE = rowRefBE(row); + if (!refBE || openingRef) return; + setOpeningRef(refBE); + try { + let glyph: SmartToken | undefined = + glyphByRef.get(refBE) || + (await db.glyph.get({ ref: refBE }).catch(() => undefined)); + if (!glyph) { + glyph = await electrumWorker.value.fetchGlyph(refBE); + } + if (!glyph) { + toast({ + status: "error", + title: "Couldn't load token", + description: + "The token's reveal transaction could not be fetched from the network.", + }); + return; + } + navigate( + glyph.tokenType === SmartTokenType.FT + ? `/fungible/token/${refBE}` + : `/objects/token/${refBE}` + ); + } finally { + setOpeningRef(null); + } + }; + + const empty = loaded && !loading && tokens.length === 0; + + return ( + + + + + Newest tokens on the network + + Every glyph as it is minted — NFTs, fungible tokens, dMint + contracts and WAVE names. Open a token to view its content and + trade it from its detail page. + + + + + + + + + + + + + + {unavailable && ( + + + The connected server doesn't serve the token discovery index + yet. Try another server under Settings → Servers. + + )} + + + {!loaded ? ( + connected ? ( + + + + + + ) : ( + + + Connecting to the network… + + + ) + ) : empty ? ( + + Nothing minted yet + + ) : ( + + {/* Header row */} + + + Token + + + Type + + + Minted + + + + + {tokens.map((row) => { + const refBE = rowRefBE(row); + const glyph = refBE ? glyphByRef.get(refBE) : undefined; + const meta = TYPE_META[row.type] || DEFAULT_TYPE_META; + const name = displayName(row, glyph); + const blocksAgo = + tipHeight > 0 && row.deploy_height > 0 + ? Math.max(0, tipHeight - row.deploy_height) + : null; + return ( + openToken(row)} + > + + + {glyph ? ( + + + + ) : ( + + + + )} + + + {name || (refBE ? shortRef(refBE) : row.ref)} + + + {row.ticker ? `$${row.ticker} · ` : ""} + {refBE ? shortRef(refBE) : ""} + + + + + + + + {row.type_name} + + {row.is_wave_duplicate && ( + + Duplicate + + )} + + + + + + {row.deploy_height > 0 + ? `Block ${row.deploy_height.toLocaleString()}` + : "Pending"} + + {blocksAgo !== null && ( + + {blocksAgo === 0 + ? "just now" + : `${blocksAgo.toLocaleString()} block${ + blocksAgo === 1 ? "" : "s" + } ago`} + + )} + + + + {openingRef === refBE ? ( + + ) : ( + + )} + + + ); + })} + + {nextCursor && ( + + + + )} + + )} + + + ); +} diff --git a/packages/app/src/components/ViewDigitalObject.tsx b/packages/app/src/components/ViewDigitalObject.tsx index 7f8c161..3d07085 100644 --- a/packages/app/src/components/ViewDigitalObject.tsx +++ b/packages/app/src/components/ViewDigitalObject.tsx @@ -128,14 +128,17 @@ export default function ViewDigitalObject({ const successDisclosure = useDisclosure(); // No default result: `undefined` means the live query hasn't resolved yet // (loading), which we surface distinctly from a token that isn't in the - // wallet (resolved but missing). + // DB at all (resolved but missing). A glyph without a TxO is a token the + // wallet doesn't own (e.g. opened from the Market's Recently Minted feed, + // or an author/container ref) — rendered read-only below: token info and + // content, no owner actions. const result = useLiveQuery(async () => { const nft = await db.glyph.get({ ref: sref }); - if (!nft?.lastTxoId) return [undefined, undefined]; - const txo = await db.txo.get(nft.lastTxoId); + if (!nft) return [undefined, undefined]; + const txo = nft.lastTxoId ? await db.txo.get(nft.lastTxoId) : undefined; const a = nft?.author && (await db.glyph.get({ ref: nft.author })); const c = nft?.container && (await db.glyph.get({ ref: nft.container })); - return [nft, txo, a, c] as [SmartToken, TxO, SmartToken?, SmartToken?]; + return [nft, txo, a, c] as [SmartToken, TxO?, SmartToken?, SmartToken?]; }, [sref]); const [nft, txo, author, container] = result ?? []; const txid = useRef(""); @@ -157,7 +160,7 @@ export default function ViewDigitalObject({ ); } - if (!txo || !nft) { + if (!nft) { return ( @@ -230,7 +233,7 @@ export default function ViewDigitalObject({ "image/gif", "image/avif", ].includes(nft.embed?.t || ""); - const location = Outpoint.fromUTXO(txo.txid, txo.vout); + const location = txo ? Outpoint.fromUTXO(txo.txid, txo.vout) : undefined; const isLink = !!nft.location; return ( @@ -369,6 +372,19 @@ export default function ViewDigitalObject({ Swap pending )} + {!txo && ( + + + {"Not in your wallet — token info from the network"} + + )} {!nft.embed && nft.remote && !isIPFS && ( {"URLs may be unsafe and result in loss of funds"} @@ -414,7 +430,9 @@ export default function ViewDigitalObject({ )} - {isMutable && ( + {/* Owner actions require the backing TxO — a glyph-only row + (unowned token opened from the Market) is view-only. */} + {txo && isMutable && ( )} - - - {nft.royalty && ( + {txo && ( + <> + + + + )} + {txo && nft.royalty && ( + // The recently-minted feed has its own refresh (its cursor state + // lives in the component, not here). + tab === "listings" ? ( + + ) : undefined } > Market @@ -705,6 +724,31 @@ export default function MarketHub() { + {/* Hub tabs */} + + + + + + {tab === "recent" ? ( + + ) : ( + <> @@ -1117,6 +1161,8 @@ export default function MarketHub() { + + )}