diff --git a/src/components/NoteStats/ZapButton.tsx b/src/components/NoteStats/ZapButton.tsx index be54ee83..3f51648d 100644 --- a/src/components/NoteStats/ZapButton.tsx +++ b/src/components/NoteStats/ZapButton.tsx @@ -1,6 +1,4 @@ import { useNoteStatsById } from '@/hooks/useNoteStatsById' -import { useLongPressAction } from '@/hooks/use-long-press-action' -import { useNip57QuickZap } from '@/hooks/useNip57QuickZap' import { recipientHasAnyPaymentOptions } from '@/lib/merge-payment-methods' import { buildRecipientPaymentData, @@ -18,7 +16,6 @@ import { kinds, type Event } from 'nostr-tools' import { Zap } from 'lucide-react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { Skeleton } from '@/components/ui/skeleton' import ZapDialog from '../ZapDialog' import PostPaymentMessagePrompt from '../ZapDialog/PostPaymentMessagePrompt' import { mergePostPaymentContext, type PostPaymentContext } from '@/lib/post-payment-context' @@ -213,41 +210,14 @@ export function ZapButtonWithStats({ event, hideCount = false, noteStats }: ZapB } }, [authorPubkey, isSelf, feedProfileSyncKey, applyTipAvailability]) - const recipientPaymentForZap = useMemo( - () => - tipPaymentData ?? - buildRecipientPaymentData( - null, - feedProfile && !feedProfile.batchPlaceholder ? feedProfile : null, - null - ), - [tipPaymentData, feedProfile] - ) - - const { canQuickNip57Zap, sendQuickZap, zapping } = useNip57QuickZap({ - recipientPubkey: event.pubkey, - referencedEvent: event, - recipientPayment: recipientPaymentForZap, - onZapDialogClose: () => setOpenPaymentDialog(false) - }) - - const longPressZap = useLongPressAction(() => sendQuickZap(), { - enabled: canQuickNip57Zap && !disable - }) - const handleOpenPaymentMethods = (e: React.MouseEvent) => { e.stopPropagation() e.preventDefault() - if (longPressZap.consumeIfLongPress()) return if (disable) return setOpenPaymentDialog(true) } - const zapButtonTitle = disable - ? t('Zaps') - : canQuickNip57Zap - ? t('Payment methods — long-press to zap') - : t('Payment methods') + const zapButtonTitle = disable ? t('Zaps') : t('Payment methods') return ( <> @@ -256,21 +226,14 @@ export function ZapButtonWithStats({ event, hideCount = false, noteStats }: ZapB type="button" className={cn( 'group flex h-full items-center pl-3 pr-1', - disable || zapping ? 'cursor-not-allowed' : 'cursor-pointer' + disable ? 'cursor-not-allowed' : 'cursor-pointer' )} title={zapButtonTitle} aria-label={zapButtonTitle} - disabled={disable || zapping} + disabled={disable} onClick={handleOpenPaymentMethods} - onPointerDown={longPressZap.onPointerDown} - onPointerUp={longPressZap.onPointerUp} - onPointerLeave={longPressZap.onPointerLeave} - onPointerCancel={longPressZap.onPointerCancel} > - {zapping ? ( - - ) : ( - - )} {showZapAmount ? ( diff --git a/src/components/ProfileZapButton/index.tsx b/src/components/ProfileZapButton/index.tsx index c4858745..5fe08c8b 100644 --- a/src/components/ProfileZapButton/index.tsx +++ b/src/components/ProfileZapButton/index.tsx @@ -1,7 +1,4 @@ import { Button } from '@/components/ui/button' -import { Skeleton } from '@/components/ui/skeleton' -import { useLongPressAction } from '@/hooks/use-long-press-action' -import { useNip57QuickZap } from '@/hooks/useNip57QuickZap' import { useRecipientPaymentData } from '@/hooks/useRecipientAlternativePayments' import { useNostr } from '@/providers/NostrProvider' import { Zap } from 'lucide-react' @@ -25,15 +22,7 @@ export default function ProfileZapButton({ const setOpen = setOpenZapDialog ?? setInternalOpen const recipientPayment = useRecipientPaymentData(pubkey, true) - const { canQuickNip57Zap, sendQuickZap, zapping } = useNip57QuickZap({ - recipientPubkey: pubkey, - recipientPayment, - onZapDialogClose: () => setOpen(false) - }) - - const longPressZap = useLongPressAction(() => sendQuickZap(), { enabled: canQuickNip57Zap }) - - const title = canQuickNip57Zap ? t('Payment methods — long-press to zap') : t('Payment methods') + const title = t('Payment methods') return ( <> @@ -43,21 +32,9 @@ export default function ProfileZapButton({ className="rounded-full" title={title} aria-label={title} - disabled={zapping} - onClick={() => { - if (longPressZap.consumeIfLongPress()) return - checkLogin(() => setOpen(true)) - }} - onPointerDown={longPressZap.onPointerDown} - onPointerUp={longPressZap.onPointerUp} - onPointerLeave={longPressZap.onPointerLeave} - onPointerCancel={longPressZap.onPointerCancel} + onClick={() => checkLogin(() => setOpen(true))} > - {zapping ? ( - - ) : ( - - )} + {!setOpenZapDialog && ( diff --git a/src/lib/nip05-well-known.test.ts b/src/lib/nip05-well-known.test.ts index 46098f28..74c817e5 100644 --- a/src/lib/nip05-well-known.test.ts +++ b/src/lib/nip05-well-known.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { nip19 } from 'nostr-tools' -import { parseNip05NamePubkeyEntry, parseNip05NamePubkeysFromWellKnownJson } from '@/lib/nip05' +import { parseNip05NamePubkeysFromWellKnownJson } from '@/lib/nip05' const THEFOREST_WELL_KNOWN = { names: { @@ -60,12 +60,25 @@ describe('parseNip05NamePubkeysFromWellKnownJson', () => { expect(rows).toEqual([{ name: 'laeserin', pubkey: laeserinHex }]) }) - it('matches npub keys when resolving entries', () => { + it('parses names provided as [name, pubkey] pairs', () => { const laeserinHex = 'dd664d5e4016433a8cd69f005ae1480804351789b59de5af06276de65633d319' - const npub = nip19.npubEncode(laeserinHex) - expect(parseNip05NamePubkeyEntry(npub, 'laeserin')).toEqual({ - name: 'laeserin', - pubkey: laeserinHex + const rows = parseNip05NamePubkeysFromWellKnownJson({ + names: [ + ['laeserin', laeserinHex], + ['137', '6da819f91d69cbe591c08b31f555c6d0ab9905197eb515856e339049c018c1af'] + ] }) + expect(rows.find((r) => r.name === 'laeserin')?.pubkey).toBe(laeserinHex) + }) + + it('partial name-filtered documents omit other users', () => { + const partial = { + names: { + cloudfodder: '7cc328a08ddb2afdf9f9be77beff4c83489ff979721827d628a542f32a247c0e' + } + } + const rows = parseNip05NamePubkeysFromWellKnownJson(partial) + expect(rows.some((r) => r.name === 'laeserin')).toBe(false) + expect(rows.some((r) => r.name === 'cloudfodder')).toBe(true) }) }) diff --git a/src/lib/nip05.ts b/src/lib/nip05.ts index 8398aba1..58e4fa73 100644 --- a/src/lib/nip05.ts +++ b/src/lib/nip05.ts @@ -6,7 +6,7 @@ import { markSitesProxyUnavailableFromHttpStatus } from '@/lib/optional-proxy-session' import { buildViteProxySitesFetchUrl } from '@/lib/vite-proxy-url' -import { hexPubkeysEqual, isValidPubkey, normalizeHexPubkey } from './pubkey' +import { hexPubkeysEqual, isValidPubkey, normalizeHexPubkey, userIdToPubkey } from './pubkey' import { fetchWithTimeout } from '@/lib/fetch-with-timeout' import logger from '@/lib/logger' @@ -18,10 +18,10 @@ type TVerifyNip05Result = { } /** Bumps when verification rules change so LRU does not serve stale false negatives. */ -const VERIFY_CACHE_SCHEMA = 5 +const VERIFY_CACHE_SCHEMA = 7 /** Bumps when well-known fetch/parse rules change so stale empty cache entries are not reused. */ -const WELL_KNOWN_CACHE_SCHEMA = 3 +const WELL_KNOWN_CACHE_SCHEMA = 5 type WellKnownCacheEntry = { json: Record | null; schema: number } @@ -169,6 +169,20 @@ function getNamesEntryRaw(names: Record, nip05Name: string): st return undefined } +function asRelayUrlList(value: unknown): string[] | undefined { + if (Array.isArray(value)) { + const urls = value.filter((x): x is string => typeof x === 'string' && x.trim().length > 0) + return urls.length > 0 ? urls : [] + } + if (value && typeof value === 'object' && !Array.isArray(value)) { + const urls = Object.values(value as Record).filter( + (x): x is string => typeof x === 'string' && x.trim().length > 0 + ) + return urls.length > 0 ? urls : [] + } + return undefined +} + function pickRelayListForPubkey( relays: Record | undefined, userPubkeyHex: string, @@ -192,13 +206,13 @@ function pickRelayListForPubkey( } for (const k of keysToTry) { if (!k) continue - const v = relays[k] - if (Array.isArray(v)) return v + const urls = asRelayUrlList(relays[k]) + if (urls !== undefined) return urls } for (const k of Object.keys(relays)) { if (pubkeyHexFromWellKnownNamesValue(k) === user) { - const v = relays[k] - if (Array.isArray(v)) return v + const urls = asRelayUrlList(relays[k]) + if (urls !== undefined) return urls } } const local = @@ -207,8 +221,8 @@ function pickRelayListForPubkey( const want = local.toLowerCase() for (const k of Object.keys(relays)) { if (k.toLowerCase() === want) { - const v = relays[k] - if (Array.isArray(v)) return v + const urls = asRelayUrlList(relays[k]) + if (urls !== undefined) return urls } } } @@ -231,9 +245,9 @@ async function _verifyNip05(nip05: string, pubkey: string): Promise { const nip05Str = asNip05LookupString(nip05).trim() - const pubkeyStr = typeof pubkey === 'string' ? pubkey.trim() : '' - const pubkeyNorm = isValidPubkey(pubkeyStr) ? normalizeHexPubkey(pubkeyStr) : pubkeyStr + const pubkeyNorm = normalizePubkeyForNip05Lookup(pubkey) ?? pubkey.trim() const cached = await verifyNip05ResultCache.fetch( JSON.stringify({ s: VERIFY_CACHE_SCHEMA, nip05: nip05Str, pubkey: pubkeyNorm }) ) @@ -278,43 +295,59 @@ export function getWellKnownNip05Url(domain: string, name?: string): string { return url.toString() } -function isWellKnownNostrJsonDocument(data: unknown): data is Record { - if (!data || typeof data !== 'object' || Array.isArray(data)) return false - const names = (data as Record).names - return typeof names === 'object' && names != null && !Array.isArray(names) +function normalizePubkeyForNip05Lookup(pubkey: string): string | null { + const hex = userIdToPubkey(pubkey.trim()) + return hex && isValidPubkey(hex) ? normalizeHexPubkey(hex) : null } -async function readWellKnownNostrJsonResponse(res: Response): Promise | null> { - try { - const data: unknown = await res.json() - return isWellKnownNostrJsonDocument(data) ? data : null - } catch { - return null +function normalizeWellKnownNamesField(raw: unknown): Record | null { + if (raw && typeof raw === 'object' && !Array.isArray(raw)) { + return raw as Record } + if (!Array.isArray(raw)) return null + const out: Record = {} + for (const item of raw) { + if (Array.isArray(item) && item.length >= 2) { + out[String(item[0])] = item[1] + continue + } + if (item && typeof item === 'object' && !Array.isArray(item)) { + const rec = item as Record + if (typeof rec.name === 'string' && rec.pubkey != null) { + out[rec.name] = rec.pubkey + } else if (typeof rec.key === 'string' && rec.value != null) { + out[rec.key] = rec.value + } + } + } + return Object.keys(out).length > 0 ? out : null } -async function fetchWellKnownNostrJsonDirect(targetUrl: string): Promise | null> { +function normalizeWellKnownDocument(data: unknown): Record | null { + if (!data || typeof data !== 'object' || Array.isArray(data)) return null + const doc = data as Record + const names = normalizeWellKnownNamesField(doc.names) + if (!names) return null + return { ...doc, names } +} + +function isWellKnownNostrJsonDocument(data: unknown): data is Record { + return normalizeWellKnownDocument(data) != null +} + +async function readWellKnownNostrJsonResponse(res: Response): Promise | null> { try { - const res = await fetchWithTimeout(targetUrl, { - credentials: 'omit', - headers: { - Accept: 'application/nostr+json, application/json;q=0.9, text/plain;q=0.8, */*;q=0.1' - }, - timeoutMs: 15_000, - mode: 'cors' - }) - if (!res.ok) return null - return readWellKnownNostrJsonResponse(res) + const data: unknown = await res.json() + return normalizeWellKnownDocument(data) } catch { return null } } -async function fetchWellKnownNostrJsonViaProxy( - targetUrl: string, - proxyServer: string +async function fetchWellKnownNostrJsonFromUrl( + fetchUrl: string, + opts?: { viaProxy?: boolean } ): Promise | null> { - const fetchUrl = buildViteProxySitesFetchUrl(targetUrl, proxyServer) try { const res = await fetchWithTimeout(fetchUrl, { credentials: 'omit', @@ -325,63 +358,61 @@ async function fetchWellKnownNostrJsonViaProxy( mode: 'cors' }) if (!res.ok) { - if (!res.redirected) markSitesProxyUnavailableFromHttpStatus(res.status) + if (opts?.viaProxy && !res.redirected) { + markSitesProxyUnavailableFromHttpStatus(res.status) + } return null } const json = await readWellKnownNostrJsonResponse(res) - if (json) clearSitesProxyUnavailableThisSession() + if (json && opts?.viaProxy) clearSitesProxyUnavailableThisSession() return json } catch { return null } } +async function fetchWellKnownNostrJsonDirect(targetUrl: string): Promise | null> { + return fetchWellKnownNostrJsonFromUrl(targetUrl) +} + /** * Fetch `/.well-known/nostr.json` in the browser without tripping third-party CORS: - * when `VITE_PROXY_SERVER` is set (production), use same-origin `/sites/?url=…` like OG preview. + * direct first (NIP-05 hosts usually allow *), then optional `/sites` proxy, then public CORS proxy in dev. */ async function fetchWellKnownNostrJsonOnce( domain: string, nameInQuery: string | undefined ): Promise | null> { const targetUrl = getWellKnownNip05Url(domain, nameInQuery) - const proxyServer = import.meta.env.VITE_PROXY_SERVER?.trim() - const useProxy = Boolean(proxyServer && !isSitesProxyUnavailableThisSession()) - if (useProxy) { - const viaProxy = await fetchWellKnownNostrJsonViaProxy(targetUrl, proxyServer!) + const direct = await fetchWellKnownNostrJsonDirect(targetUrl) + if (direct) return direct + + const proxyServer = import.meta.env.VITE_PROXY_SERVER?.trim() + if (proxyServer && !isSitesProxyUnavailableThisSession()) { + const viaProxy = await fetchWellKnownNostrJsonFromUrl( + buildViteProxySitesFetchUrl(targetUrl, proxyServer), + { viaProxy: true } + ) if (viaProxy) return viaProxy - return fetchWellKnownNostrJsonDirect(targetUrl) } - return fetchWellKnownNostrJsonDirect(targetUrl) + if (!import.meta.env.PROD) { + return fetchWellKnownNostrJsonFromUrl( + `https://api.allorigins.win/raw?url=${encodeURIComponent(targetUrl)}` + ) + } + + return null } -/** Uncached network: optional `?name=` then full document. */ -async function fetchWellKnownNostrJsonNetwork( - domain: string, - name?: string -): Promise | null> { - const trimmedName = typeof name === 'string' ? name.trim() : '' - const withQuery = - trimmedName.length > 0 ? await fetchWellKnownNostrJsonOnce(domain, trimmedName) : null - if (withQuery && typeof withQuery.names === 'object' && withQuery.names != null) { - if (resolveNamesEntry(withQuery.names as Record, trimmedName) != null) { - return withQuery - } - } - const full = await fetchWellKnownNostrJsonOnce(domain, undefined) - if (full && trimmedName && typeof full.names === 'object' && full.names != null) { - if (resolveNamesEntry(full.names as Record, trimmedName) != null) { - return full - } - } - return withQuery ?? full +/** Always fetch the full domain document (never `?name=` — partial responses must not poison domain cache). */ +async function fetchWellKnownFullDocument(domain: string): Promise | null> { + return fetchWellKnownNostrJsonOnce(domain, undefined) } async function getOrFetchWellKnownJsonForDomain( - domain: string, - nameHint?: string + domain: string ): Promise | null> { const key = normalizeNip05Domain(domain) if (!key) return null @@ -394,7 +425,7 @@ async function getOrFetchWellKnownJsonForDomain( } let inflight = wellKnownDomainInFlight.get(key) if (!inflight) { - inflight = fetchWellKnownNostrJsonNetwork(key, nameHint).then((json) => { + inflight = fetchWellKnownFullDocument(key).then((json) => { wellKnownDomainInFlight.delete(key) if (isWellKnownNostrJsonDocument(json)) { wellKnownJsonByDomain.set(key, { json, schema: WELL_KNOWN_CACHE_SCHEMA }) @@ -406,11 +437,9 @@ async function getOrFetchWellKnownJsonForDomain( return inflight } -/** Fetch `/.well-known/nostr.json` (with optional `?name=`). Retries without `name` if the entry is missing. */ -async function fetchWellKnownNostrJson(domain: string, name?: string): Promise | null> { - const trimmedName = typeof name === 'string' ? name.trim() : '' - const hint = trimmedName.length > 0 ? trimmedName : undefined - return getOrFetchWellKnownJsonForDomain(domain, hint) +/** Fetch `/.well-known/nostr.json` for a domain (full document; `name` is resolved locally). */ +async function fetchWellKnownNostrJson(domain: string, _name?: string): Promise | null> { + return getOrFetchWellKnownJsonForDomain(domain) } export async function fetchPubkeysFromDomain(domain: string): Promise { @@ -421,8 +450,9 @@ export async function fetchPubkeysFromDomain(domain: string): Promise export function parseNip05NamePubkeysFromWellKnownJson( json: Record ): Array<{ name: string; pubkey: string }> { - const names = json.names as Record | undefined - if (!names || typeof names !== 'object') return [] + const normalized = normalizeWellKnownDocument(json) + if (!normalized) return [] + const names = normalized.names as Record const out: Array<{ name: string; pubkey: string }> = [] const seen = new Set() for (const [key, v] of Object.entries(names)) {