Browse Source

bug-fixes

remove long-press zaps
imwald
Silberengel 3 weeks ago
parent
commit
677050d931
  1. 46
      src/components/NoteStats/ZapButton.tsx
  2. 29
      src/components/ProfileZapButton/index.tsx
  3. 25
      src/lib/nip05-well-known.test.ts
  4. 186
      src/lib/nip05.ts

46
src/components/NoteStats/ZapButton.tsx

@ -1,6 +1,4 @@ @@ -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' @@ -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 @@ -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 @@ -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 ? (
<Skeleton className="size-4 shrink-0 rounded-full" aria-hidden />
) : (
<Zap
<Zap
className={cn(
hasZapped && 'fill-yellow-400',
disable
@ -281,7 +244,6 @@ export function ZapButtonWithStats({ event, hideCount = false, noteStats }: ZapB @@ -281,7 +244,6 @@ export function ZapButtonWithStats({ event, hideCount = false, noteStats }: ZapB
)
)}
/>
)}
</button>
{showZapAmount ? (
<ZapCountHover noteStats={noteStats}>

29
src/components/ProfileZapButton/index.tsx

@ -1,7 +1,4 @@ @@ -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({ @@ -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({ @@ -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 ? (
<Skeleton className="size-4 shrink-0 rounded-full" aria-hidden />
) : (
<Zap className="text-yellow-400" />
)}
<Zap className="text-yellow-400" />
</Button>
{!setOpenZapDialog && (
<ZapDialog open={open} setOpen={setInternalOpen} pubkey={pubkey} prefetchedPayment={recipientPayment} />

25
src/lib/nip05-well-known.test.ts

@ -1,6 +1,6 @@ @@ -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', () => { @@ -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)
})
})

186
src/lib/nip05.ts

@ -6,7 +6,7 @@ import { @@ -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 = { @@ -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<string, unknown> | null; schema: number }
@ -169,6 +169,20 @@ function getNamesEntryRaw(names: Record<string, unknown>, nip05Name: string): st @@ -169,6 +169,20 @@ function getNamesEntryRaw(names: Record<string, unknown>, 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<string, unknown>).filter(
(x): x is string => typeof x === 'string' && x.trim().length > 0
)
return urls.length > 0 ? urls : []
}
return undefined
}
function pickRelayListForPubkey(
relays: Record<string, unknown> | undefined,
userPubkeyHex: string,
@ -192,13 +206,13 @@ function pickRelayListForPubkey( @@ -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( @@ -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<TVerifyNip05 @@ -231,9 +245,9 @@ async function _verifyNip05(nip05: string, pubkey: string): Promise<TVerifyNip05
const nip05Name = split?.name ?? ''
const nip05Domain = split?.domain ?? ''
const result: TVerifyNip05Result = { isVerified: false, nip05Name, nip05Domain }
if (!split || !pubkey || !isValidPubkey(pubkey)) return result
const userHex = normalizePubkeyForNip05Lookup(pubkey)
if (!split || !userHex) return result
const userHex = normalizeHexPubkey(pubkey)
const json = await fetchWellKnownNostrJson(nip05Domain, nip05Name)
if (!json) return result
@ -251,13 +265,16 @@ async function _verifyNip05(nip05: string, pubkey: string): Promise<TVerifyNip05 @@ -251,13 +265,16 @@ async function _verifyNip05(nip05: string, pubkey: string): Promise<TVerifyNip05
resolved.name,
names
)
return { ...result, isVerified: true, relays: Array.isArray(relayList) ? (relayList as string[]) : undefined }
return {
...result,
isVerified: true,
relays: Array.isArray(relayList) ? (relayList as string[]) : undefined
}
}
export async function verifyNip05(nip05: string, pubkey: string): Promise<TVerifyNip05Result> {
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 { @@ -278,43 +295,59 @@ export function getWellKnownNip05Url(domain: string, name?: string): string {
return url.toString()
}
function isWellKnownNostrJsonDocument(data: unknown): data is Record<string, unknown> {
if (!data || typeof data !== 'object' || Array.isArray(data)) return false
const names = (data as Record<string, unknown>).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<Record<string, unknown> | null> {
try {
const data: unknown = await res.json()
return isWellKnownNostrJsonDocument(data) ? data : null
} catch {
return null
function normalizeWellKnownNamesField(raw: unknown): Record<string, unknown> | null {
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
return raw as Record<string, unknown>
}
if (!Array.isArray(raw)) return null
const out: Record<string, unknown> = {}
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<string, unknown>
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<Record<string, unknown> | null> {
function normalizeWellKnownDocument(data: unknown): Record<string, unknown> | null {
if (!data || typeof data !== 'object' || Array.isArray(data)) return null
const doc = data as Record<string, unknown>
const names = normalizeWellKnownNamesField(doc.names)
if (!names) return null
return { ...doc, names }
}
function isWellKnownNostrJsonDocument(data: unknown): data is Record<string, unknown> {
return normalizeWellKnownDocument(data) != null
}
async function readWellKnownNostrJsonResponse(res: Response): Promise<Record<string, unknown> | 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<Record<string, unknown> | null> {
const fetchUrl = buildViteProxySitesFetchUrl(targetUrl, proxyServer)
try {
const res = await fetchWithTimeout(fetchUrl, {
credentials: 'omit',
@ -325,63 +358,61 @@ async function fetchWellKnownNostrJsonViaProxy( @@ -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<Record<string, unknown> | 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<Record<string, unknown> | 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<Record<string, unknown> | 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<string, unknown>, 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<string, unknown>, 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<Record<string, unknown> | null> {
return fetchWellKnownNostrJsonOnce(domain, undefined)
}
async function getOrFetchWellKnownJsonForDomain(
domain: string,
nameHint?: string
domain: string
): Promise<Record<string, unknown> | null> {
const key = normalizeNip05Domain(domain)
if (!key) return null
@ -394,7 +425,7 @@ async function getOrFetchWellKnownJsonForDomain( @@ -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( @@ -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<Record<string, unknown> | 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<Record<string, unknown> | null> {
return getOrFetchWellKnownJsonForDomain(domain)
}
export async function fetchPubkeysFromDomain(domain: string): Promise<string[]> {
@ -421,8 +450,9 @@ export async function fetchPubkeysFromDomain(domain: string): Promise<string[]> @@ -421,8 +450,9 @@ export async function fetchPubkeysFromDomain(domain: string): Promise<string[]>
export function parseNip05NamePubkeysFromWellKnownJson(
json: Record<string, unknown>
): Array<{ name: string; pubkey: string }> {
const names = json.names as Record<string, unknown> | undefined
if (!names || typeof names !== 'object') return []
const normalized = normalizeWellKnownDocument(json)
if (!normalized) return []
const names = normalized.names as Record<string, unknown>
const out: Array<{ name: string; pubkey: string }> = []
const seen = new Set<string>()
for (const [key, v] of Object.entries(names)) {

Loading…
Cancel
Save