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)) {