diff --git a/src/data/payto-types.json b/src/data/payto-types.json
new file mode 100644
index 00000000..c1c16a62
--- /dev/null
+++ b/src/data/payto-types.json
@@ -0,0 +1,376 @@
+{
+ "_meta": {
+ "logoAssetsDir": "src/assets/payto_logos",
+ "logoAssetPathNote": "Repo-relative path to the logo file. The app bundles these via Vite and exposes them under /assets/ at runtime (see getPaytoLogoPath)."
+ },
+ "editorOrder": [
+ "lightning",
+ "bitcoin",
+ "liquid",
+ "lbtc",
+ "sats",
+ "ethereum",
+ "monero",
+ "litecoin",
+ "dogecoin",
+ "bitcoin-cash",
+ "solana",
+ "nano",
+ "usdt",
+ "usdc",
+ "dai",
+ "euroc",
+ "paypal",
+ "cashme",
+ "venmo",
+ "revolut",
+ "buymeacoffee",
+ "ko-fi",
+ "patreon",
+ "github",
+ "geyser",
+ "gofundme",
+ "kickstarter",
+ "apple-pay",
+ "google-pay"
+ ],
+ "genericAuthorityHelp": {
+ "placeholder": "payment target",
+ "hint": "Authority segment of payto://
/ (address, username, or ID)"
+ },
+ "aliases": {
+ "btc": "bitcoin",
+ "doge": "dogecoin",
+ "eth": "ethereum",
+ "xmr": "monero",
+ "ltc": "litecoin",
+ "xno": "nano",
+ "sol": "solana",
+ "bch": "bitcoin-cash"
+ },
+ "types": {
+ "bitcoin": {
+ "label": "Bitcoin",
+ "symbol": "₿",
+ "category": "bitcoin",
+ "authority": {
+ "placeholder": "bc1q…",
+ "hint": "On-chain Bitcoin address (Bech32 bc1… preferred)"
+ }
+ },
+ "liquid": {
+ "label": "Liquid",
+ "symbol": "⛓",
+ "category": "bitcoin-layer",
+ "logoAssetPath": "src/assets/payto_logos/LBTC.svg",
+ "authority": {
+ "placeholder": "VJL… or bc1q… on Liquid",
+ "hint": "Liquid network address (confidential or explicit)"
+ }
+ },
+ "lbtc": {
+ "label": "Liquid Bitcoin",
+ "symbol": "₿",
+ "category": "bitcoin-layer",
+ "logoAssetPath": "src/assets/payto_logos/LBTC.svg",
+ "authority": {
+ "placeholder": "VJL…",
+ "hint": "Liquid Bitcoin (L-BTC) receiving address"
+ }
+ },
+ "sats": {
+ "label": "Satoshis",
+ "symbol": "丰",
+ "category": "bitcoin",
+ "authority": {
+ "placeholder": "bc1q… or lightning address",
+ "hint": "Satoshis payment target (same formats as Bitcoin / Lightning)"
+ }
+ },
+ "lightning": {
+ "label": "Lightning Network",
+ "symbol": "⚡",
+ "category": "bitcoin-layer",
+ "authority": {
+ "placeholder": "user@getalby.com",
+ "hint": "Lightning address (LUD-16): name@domain — not a BOLT11 invoice"
+ }
+ },
+ "ethereum": {
+ "label": "Ethereum",
+ "symbol": "Ξ",
+ "category": "crypto",
+ "logoAssetPath": "src/assets/payto_logos/ethereum-eth-logo.svg",
+ "authority": {
+ "placeholder": "0x…",
+ "hint": "Ethereum address (0x + 40 hex characters)"
+ }
+ },
+ "monero": {
+ "label": "Monero",
+ "symbol": "ɱ",
+ "category": "crypto",
+ "logoAssetPath": "src/assets/payto_logos/Monero.png",
+ "authority": {
+ "placeholder": "4… or 8…",
+ "hint": "Monero primary address (starts with 4 or 8)"
+ }
+ },
+ "nano": {
+ "label": "Nano",
+ "symbol": "Ӿ",
+ "category": "crypto",
+ "authority": {
+ "placeholder": "nano_…",
+ "hint": "Nano account address (nano_ prefix)"
+ }
+ },
+ "cashme": {
+ "label": "Cash App",
+ "symbol": "$",
+ "category": "fiat",
+ "logoAssetPath": "src/assets/payto_logos/cashapp.webp",
+ "profileUrlTemplate": "https://cash.app/{authority}",
+ "authority": {
+ "placeholder": "$cashtag",
+ "hint": "Cash App $cashtag or phone-linked ID"
+ }
+ },
+ "revolut": {
+ "label": "Revolut",
+ "symbol": "💳",
+ "category": "fiat",
+ "logoAssetPath": "src/assets/payto_logos/revolut.webp",
+ "profileUrlTemplate": "https://revolut.me/{authority}",
+ "authority": {
+ "placeholder": "username",
+ "hint": "Revolut.me username"
+ }
+ },
+ "venmo": {
+ "label": "Venmo",
+ "symbol": "$",
+ "category": "fiat",
+ "logoAssetPath": "src/assets/payto_logos/venmo.png",
+ "profileUrlTemplate": "https://venmo.com/{authority}",
+ "authority": {
+ "placeholder": "@username",
+ "hint": "Venmo username (with or without @)"
+ }
+ },
+ "bitcoin-cash": {
+ "label": "Bitcoin Cash",
+ "symbol": "₿",
+ "category": "crypto",
+ "logoAssetPath": "src/assets/payto_logos/bitcoin-cash-bch-logo.svg",
+ "authority": {
+ "placeholder": "bitcoincash:… or q…",
+ "hint": "Bitcoin Cash address (CashAddr or legacy)"
+ }
+ },
+ "dogecoin": {
+ "label": "Dogecoin",
+ "symbol": "Ð",
+ "category": "crypto",
+ "logoAssetPath": "src/assets/payto_logos/dogecoin-doge-logo.svg",
+ "authority": {
+ "placeholder": "D…",
+ "hint": "Dogecoin address (usually starts with D)"
+ }
+ },
+ "litecoin": {
+ "label": "Litecoin",
+ "symbol": "Ł",
+ "category": "crypto",
+ "logoAssetPath": "src/assets/payto_logos/Litecoin.png",
+ "authority": {
+ "placeholder": "ltc1q… or L… or M…",
+ "hint": "Litecoin address"
+ }
+ },
+ "usdt": {
+ "label": "Tether",
+ "symbol": "₮",
+ "category": "stablecoin",
+ "logoAssetPath": "src/assets/payto_logos/tether-usdt-logo.svg",
+ "authority": {
+ "placeholder": "0x… or T…",
+ "hint": "USDT address — include network if needed (ERC-20, TRC-20, etc.)"
+ }
+ },
+ "usdc": {
+ "label": "USD Coin",
+ "symbol": "◎",
+ "category": "stablecoin",
+ "logoAssetPath": "src/assets/payto_logos/usd-coin-usdc-logo.svg",
+ "authority": {
+ "placeholder": "0x…",
+ "hint": "USDC address — specify chain in notes if not obvious"
+ }
+ },
+ "dai": {
+ "label": "Dai",
+ "symbol": "◈",
+ "category": "crypto",
+ "logoAssetPath": "src/assets/payto_logos/multi-collateral-dai-dai-logo.svg",
+ "authority": {
+ "placeholder": "0x…",
+ "hint": "Dai (ERC-20) wallet address"
+ }
+ },
+ "euroc": {
+ "label": "Euro Coin",
+ "symbol": "€",
+ "category": "stablecoin",
+ "logoAssetPath": "src/assets/payto_logos/EurC.png",
+ "authority": {
+ "placeholder": "0x…",
+ "hint": "Euro Coin (EURC) wallet address"
+ }
+ },
+ "solana": {
+ "label": "Solana",
+ "symbol": "◎",
+ "category": "crypto",
+ "logoAssetPath": "src/assets/payto_logos/solana.png",
+ "authority": {
+ "placeholder": "Base58 pubkey…",
+ "hint": "Solana wallet address (base58, 32–44 characters)"
+ }
+ },
+ "paypal": {
+ "label": "PayPal",
+ "symbol": "💙",
+ "category": "fiat",
+ "logoAssetPath": "src/assets/payto_logos/paypal.webp",
+ "profileUrlTemplate": "https://paypal.me/{authority}",
+ "authority": {
+ "placeholder": "username",
+ "hint": "PayPal.me username (without paypal.me/)"
+ }
+ },
+ "buymeacoffee": {
+ "label": "Buy Me a Coffee",
+ "symbol": "☕",
+ "category": "tip",
+ "logoAssetPath": "src/assets/payto_logos/buymeacoffee.png",
+ "profileUrlTemplate": "https://buymeacoffee.com/{authority}",
+ "authority": {
+ "placeholder": "username",
+ "hint": "Buy Me a Coffee page slug"
+ }
+ },
+ "ko-fi": {
+ "label": "Ko-fi",
+ "symbol": "☕",
+ "category": "tip",
+ "logoAssetPath": "src/assets/payto_logos/ko-fi.png",
+ "profileUrlTemplate": "https://ko-fi.com/{authority}",
+ "authority": {
+ "placeholder": "username",
+ "hint": "Ko-fi profile name"
+ }
+ },
+ "kofi": {
+ "label": "Ko-fi",
+ "symbol": "☕",
+ "category": "tip",
+ "logoAssetPath": "src/assets/payto_logos/ko-fi.png",
+ "profileUrlTemplate": "https://ko-fi.com/{authority}",
+ "authority": {
+ "placeholder": "username",
+ "hint": "Ko-fi profile name"
+ }
+ },
+ "patreon": {
+ "label": "Patreon",
+ "symbol": "🎭",
+ "category": "tip",
+ "logoAssetPath": "src/assets/payto_logos/patreon.png",
+ "profileUrlTemplate": "https://patreon.com/{authority}",
+ "authority": {
+ "placeholder": "creator",
+ "hint": "Patreon creator / page slug"
+ }
+ },
+ "github": {
+ "label": "GitHub Sponsors",
+ "symbol": "🐙",
+ "category": "tip",
+ "logoAssetPath": "src/assets/payto_logos/github_sponsors.png",
+ "profileUrlTemplate": "https://github.com/sponsors/{authority}",
+ "authority": {
+ "placeholder": "username",
+ "hint": "GitHub Sponsors username"
+ }
+ },
+ "apple-pay": {
+ "label": "Apple Pay",
+ "symbol": "🍎",
+ "category": "fiat",
+ "logoAssetPath": "src/assets/payto_logos/apple_pay.svg",
+ "authority": {
+ "placeholder": "phone or email",
+ "hint": "Apple Pay contact (phone number or email)"
+ }
+ },
+ "google-pay": {
+ "label": "Google Pay",
+ "symbol": "G",
+ "category": "fiat",
+ "logoAssetPath": "src/assets/payto_logos/google_pay.jpeg",
+ "authority": {
+ "placeholder": "phone or email",
+ "hint": "Google Pay contact (phone number or email)"
+ }
+ },
+ "geyser": {
+ "label": "Geyser Fund",
+ "symbol": "⛲",
+ "category": "tip",
+ "logoAssetPath": "src/assets/payto_logos/geyser_fund.webp",
+ "profileUrlTemplate": "https://geyser.fund/project/{authority}",
+ "authority": {
+ "placeholder": "project-slug",
+ "hint": "Geyser Fund project identifier"
+ }
+ },
+ "gofundme": {
+ "label": "GoFundMe",
+ "symbol": "🎯",
+ "category": "tip",
+ "logoAssetPath": "src/assets/payto_logos/gofundme.jpeg",
+ "profileUrlTemplate": "https://www.gofundme.com/f/{authority}",
+ "authority": {
+ "placeholder": "campaign-slug",
+ "hint": "GoFundMe campaign path segment"
+ }
+ },
+ "kickstarter": {
+ "label": "Kickstarter",
+ "symbol": "🚀",
+ "category": "tip",
+ "logoAssetPath": "src/assets/payto_logos/kickstarter.webp",
+ "profileUrlTemplate": "https://www.kickstarter.com/projects/{authority}",
+ "authority": {
+ "placeholder": "project-slug",
+ "hint": "Kickstarter project slug"
+ }
+ },
+ "bnb": {
+ "label": "BNB",
+ "category": "crypto",
+ "logoAssetPath": "src/assets/payto_logos/BNB.png"
+ },
+ "tron": {
+ "label": "Tron",
+ "category": "crypto",
+ "logoAssetPath": "src/assets/payto_logos/Tron.png"
+ },
+ "xrp": {
+ "label": "XRP",
+ "category": "crypto",
+ "logoAssetPath": "src/assets/payto_logos/XRP.gif"
+ }
+ }
+}
diff --git a/src/hooks/useProfileReportsEvents.tsx b/src/hooks/useProfileReportsEvents.tsx
index 3bade308..919f24e5 100644
--- a/src/hooks/useProfileReportsEvents.tsx
+++ b/src/hooks/useProfileReportsEvents.tsx
@@ -10,7 +10,7 @@ import { useDeletedEvent } from '@/providers/DeletedEventProvider'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useNostrOptional } from '@/providers/nostr-context'
import client from '@/services/client.service'
-import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { useCallback, useEffect, useMemo, useRef, useState, type Dispatch, type SetStateAction } from 'react'
import { Event, kinds, type Filter } from 'nostr-tools'
const REPORT_KINDS = [kinds.Report, ExtendedKind.REPORT] as const
@@ -41,6 +41,14 @@ function mergeReportEvents(
return [...dedup.values()].sort((a, b) => b.created_at - a.created_at).slice(0, limit)
}
+function eventsEqualById(a: Event[], b: Event[]): boolean {
+ if (a.length !== b.length) return false
+ for (let i = 0; i < a.length; i++) {
+ if (a[i].id !== b[i].id) return false
+ }
+ return true
+}
+
type FetchMode = 'received' | 'made'
function buildFilter(pubkey: string, mode: FetchMode, limit: number): Filter {
@@ -102,6 +110,12 @@ export function useProfileReportsEvents({
const relayUrlsBuilderRef = useRef(relayUrlsBuilder)
relayUrlsBuilderRef.current = relayUrlsBuilder
+ const favoriteRelaysRef = useRef(favoriteRelays)
+ const blockedRelaysRef = useRef(blockedRelays)
+ favoriteRelaysRef.current = favoriteRelays
+ blockedRelaysRef.current = blockedRelays
+ const useGlobalRelayBootstrapRef = useRef(useGlobalRelayBootstrap)
+ useGlobalRelayBootstrapRef.current = useGlobalRelayBootstrap
const resolveFeedUrls = useCallback(
(
@@ -110,19 +124,24 @@ export function useProfileReportsEvents({
) => {
const custom = relayUrlsBuilderRef.current
if (custom) {
- return custom(favoriteRelays, blockedRelays, authorRelayList, includeAuthorLocal)
+ return custom(
+ favoriteRelaysRef.current,
+ blockedRelaysRef.current,
+ authorRelayList,
+ includeAuthorLocal
+ )
}
return buildProfilePageReadRelayUrls(
- favoriteRelays,
- blockedRelays,
+ favoriteRelaysRef.current,
+ blockedRelaysRef.current,
authorRelayList,
false,
includeAuthorLocal,
[...REPORT_KINDS],
- useGlobalRelayBootstrap
+ useGlobalRelayBootstrapRef.current
)
},
- [favoriteRelays, blockedRelays, useGlobalRelayBootstrap]
+ []
)
useEffect(() => {
@@ -142,12 +161,11 @@ export function useProfileReportsEvents({
useEffect(() => {
let cancelled = false
- const closers: (() => void)[] = []
const loadMode = async (
mode: FetchMode,
cacheKey: string,
- setEvents: (events: Event[]) => void
+ setEvents: Dispatch>
) => {
const mem = memoryByKey.get(cacheKey)
const cacheAge = mem ? Date.now() - mem.lastUpdated : Infinity
@@ -166,7 +184,7 @@ export function useProfileReportsEvents({
postFilter(pubkey, mode)
)
memoryByKey.set(cacheKey, { events: processed, lastUpdated: Date.now() })
- setEvents(processed)
+ setEvents((prev) => (eventsEqualById(prev, processed) ? prev : processed))
}
let pkNorm = pubkey
@@ -207,28 +225,6 @@ export function useProfileReportsEvents({
/* ignore */
}
- try {
- const { closer } = await client.subscribeTimeline(
- subRequests,
- {
- onEvents: (rows) => {
- if (cancelled) return
- for (const e of rows as Event[]) pool.set(e.id, e)
- flush()
- },
- onNew: (evt) => {
- if (cancelled) return
- pool.set((evt as Event).id, evt as Event)
- flush()
- }
- },
- { needSort: true }
- )
- closers.push(closer)
- } catch {
- /* ignore */
- }
-
const authorRl = await client.fetchRelayList(pubkey).catch(() => emptyAuthor)
if (cancelled) return
const fullUrls = resolveFeedUrls(authorRl, includeAuthorLocalRelays)
@@ -246,23 +242,15 @@ export function useProfileReportsEvents({
/* ignore */
}
try {
- const { closer } = await client.subscribeTimeline(
- deltaRequests,
- {
- onEvents: (rows) => {
- if (cancelled) return
- for (const e of rows as Event[]) pool.set(e.id, e)
- flush()
- },
- onNew: (evt) => {
- if (cancelled) return
- pool.set((evt as Event).id, evt as Event)
- flush()
- }
- },
- { needSort: true }
- )
- closers.push(closer)
+ const fetchedDelta = await client.fetchEvents(deltaUrls, filter, {
+ cache: true,
+ eoseTimeout: 4500,
+ globalTimeout: 14_000
+ })
+ if (!cancelled) {
+ for (const e of fetchedDelta) pool.set(e.id, e)
+ flush()
+ }
} catch {
/* ignore */
}
@@ -280,13 +268,13 @@ export function useProfileReportsEvents({
if (madeFresh && madeMem) {
setMade(madeMem.events)
}
- if (recvFresh && madeFresh) {
+ if (recvFresh && madeFresh && refreshToken === 0) {
setIsLoading(false)
- if (refreshToken === 0) return
- } else {
- setIsLoading(true)
+ return
}
+ setIsLoading(true)
+
await Promise.all([
loadMode('received', receivedCacheKey, setReceived),
loadMode('made', madeCacheKey, setMade)
@@ -299,7 +287,6 @@ export function useProfileReportsEvents({
return () => {
cancelled = true
- closers.forEach((c) => c())
}
}, [
pubkey,
diff --git a/src/hooks/useProfileWall.tsx b/src/hooks/useProfileWall.tsx
index f8166df8..9e829286 100644
--- a/src/hooks/useProfileWall.tsx
+++ b/src/hooks/useProfileWall.tsx
@@ -12,6 +12,7 @@ import {
} from '@/lib/nip58-profile-badges'
import { isDirectProfileWallComment } from '@/lib/profile-wall-comments'
import { normalizeHexPubkey } from '@/lib/pubkey'
+import { normalizeAnyRelayUrl } from '@/lib/url'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useDeletedEvent } from '@/providers/DeletedEventProvider'
import client, { replaceableEventService } from '@/services/client.service'
@@ -21,6 +22,12 @@ import { Event, kinds, type Filter } from 'nostr-tools'
const CACHE_DURATION = 5 * 60 * 1000
const wallCacheByKey = new Map()
+function relayListsContentKey(favoriteRelays: string[], blockedRelays: string[]): string {
+ const fav = [...favoriteRelays].map((u) => normalizeAnyRelayUrl(u) || u).filter(Boolean).sort().join('\u0001')
+ const blk = [...blockedRelays].map((u) => normalizeAnyRelayUrl(u) || u).filter(Boolean).sort().join('\u0001')
+ return `${fav}\u0000${blk}`
+}
+
export function useProfileWall(pubkey: string, profileEventId: string | undefined) {
const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const useGlobalRelayBootstrap = useGlobalRelayBootstrapDefaults()
@@ -36,6 +43,17 @@ export function useProfileWall(pubkey: string, profileEventId: string | undefine
const [isLoading, setIsLoading] = useState(!cached)
const [refreshToken, setRefreshToken] = useState(0)
+ const relayListsKey = useMemo(
+ () => relayListsContentKey(favoriteRelays, blockedRelays),
+ [favoriteRelays, blockedRelays]
+ )
+ const favoriteRelaysRef = useRef(favoriteRelays)
+ const blockedRelaysRef = useRef(blockedRelays)
+ favoriteRelaysRef.current = favoriteRelays
+ blockedRelaysRef.current = blockedRelays
+ const useGlobalRelayBootstrapRef = useRef(useGlobalRelayBootstrap)
+ useGlobalRelayBootstrapRef.current = useGlobalRelayBootstrap
+
useEffect(() => {
let cancelled = false
@@ -62,13 +80,13 @@ export function useProfileWall(pubkey: string, profileEventId: string | undefine
if (cancelled) return
const relayUrls = buildProfilePageReadRelayUrls(
- favoriteRelays,
- blockedRelays,
+ favoriteRelaysRef.current,
+ blockedRelaysRef.current,
authorRl,
false,
false,
[ExtendedKind.COMMENT, ExtendedKind.PROFILE_BADGES_LIST, ExtendedKind.BADGE_DEFINITION],
- useGlobalRelayBootstrap
+ useGlobalRelayBootstrapRef.current
)
// --- Badges (NIP-58) ---
@@ -159,15 +177,7 @@ export function useProfileWall(pubkey: string, profileEventId: string | undefine
return () => {
cancelled = true
}
- }, [
- pubkey,
- profileEventId,
- cacheKey,
- refreshToken,
- favoriteRelays,
- blockedRelays,
- useGlobalRelayBootstrap
- ])
+ }, [pubkey, profileEventId, cacheKey, refreshToken, relayListsKey])
const refresh = useCallback(() => {
wallCacheByKey.delete(cacheKey)
diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts
index 6d24a1be..2816d555 100644
--- a/src/i18n/locales/de.ts
+++ b/src/i18n/locales/de.ts
@@ -152,8 +152,11 @@ export default {
"Payment info updated": "Payment info updated",
"Failed to publish payment info": "Failed to publish payment info",
"Invalid tags JSON": "Invalid tags JSON",
- "Payment methods": "Payment methods",
- "NIP-A3 payto tags: type (e.g. lightning) and authority (e.g. user@domain.com).": "NIP-A3 payto tags: type (e.g. lightning) and authority (e.g. user@domain.com).",
+ "Payment methods": "Zahlungsmethoden",
+ "Payment type": "Zahlungsart",
+ "paytoEditor.intro":
+ "Zahlungsart wählen, dann Adresse oder Benutzername wie in der Hinweiszeile darunter eintragen.",
+ "NIP-A3 payto tags: type (e.g. lightning) and authority (e.g. user@domain.com).": "NIP-A3 payto-Tags: Typ (z. B. lightning) und Authority (z. B. user@domain.com).",
"Type (e.g. lightning)": "Type (e.g. lightning)",
"Authority (e.g. user@domain.com)": "Authority (e.g. user@domain.com)",
"Add payment method": "Add payment method",
diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts
index 6c8c47c4..d653fdfc 100644
--- a/src/i18n/locales/en.ts
+++ b/src/i18n/locales/en.ts
@@ -158,6 +158,9 @@ export default {
"Failed to publish payment info": "Failed to publish payment info",
"Invalid tags JSON": "Invalid tags JSON",
"Payment methods": "Payment methods",
+ "Payment type": "Payment type",
+ "paytoEditor.intro":
+ "Choose a payment type, then enter the address or username shown in the hint below each field.",
"NIP-A3 payto tags: type (e.g. lightning) and authority (e.g. user@domain.com).": "NIP-A3 payto tags: type (e.g. lightning) and authority (e.g. user@domain.com).",
"Type (e.g. lightning)": "Type (e.g. lightning)",
"Authority (e.g. user@domain.com)": "Authority (e.g. user@domain.com)",
diff --git a/src/lib/payto-editor-hints.test.ts b/src/lib/payto-editor-hints.test.ts
new file mode 100644
index 00000000..5fd602b9
--- /dev/null
+++ b/src/lib/payto-editor-hints.test.ts
@@ -0,0 +1,31 @@
+import { describe, expect, it } from 'vitest'
+import { getPaytoAuthorityFieldHelp, getPaytoLogoPath, paytoEditorSelectTypes } from './payto-registry'
+
+describe('getPaytoAuthorityFieldHelp', () => {
+ it('returns lightning-specific hint', () => {
+ const help = getPaytoAuthorityFieldHelp('lightning')
+ expect(help.placeholder).toContain('@')
+ expect(help.hint.toLowerCase()).toContain('lud')
+ })
+
+ it('falls back for unknown types', () => {
+ const help = getPaytoAuthorityFieldHelp('custom-coin')
+ expect(help.hint).toContain('payto://')
+ })
+})
+
+describe('paytoEditorSelectTypes', () => {
+ it('appends custom type not in curated list', () => {
+ const types = paytoEditorSelectTypes('custom-coin')
+ expect(types[0]).toBe('lightning')
+ expect(types).toContain('custom-coin')
+ })
+})
+
+describe('getPaytoLogoPath', () => {
+ it('resolves ethereum logo from catalog asset path', () => {
+ const url = getPaytoLogoPath('ethereum')
+ expect(url).toBeTruthy()
+ expect(url!.length).toBeGreaterThan(10)
+ })
+})
diff --git a/src/lib/payto-logos.ts b/src/lib/payto-logos.ts
index 18329bd4..df1b397a 100644
--- a/src/lib/payto-logos.ts
+++ b/src/lib/payto-logos.ts
@@ -1,61 +1,35 @@
/**
- * Explicit Vite `?url` imports so every payto logo is emitted under `/assets/` in production.
- * Keep files in `src/assets/payto_logos/` (not `public/`).
+ * Resolves payto logo paths from {@link ../data/payto-types.json} `logoAssetPath` values.
+ * All files under `src/assets/payto_logos/` are bundled via Vite `import.meta.glob`.
*/
-import applePaySvg from '../assets/payto_logos/apple_pay.svg?url'
-import bitcoinCashLogo from '../assets/payto_logos/bitcoin-cash-bch-logo.svg?url'
-import bnbPng from '../assets/payto_logos/BNB.png?url'
-import buyMeACoffeePng from '../assets/payto_logos/buymeacoffee.png?url'
-import cashappWebp from '../assets/payto_logos/cashapp.webp?url'
-import daiLogo from '../assets/payto_logos/multi-collateral-dai-dai-logo.svg?url'
-import dogecoinLogo from '../assets/payto_logos/dogecoin-doge-logo.svg?url'
-import ethLogo from '../assets/payto_logos/ethereum-eth-logo.svg?url'
-import eurocPng from '../assets/payto_logos/EurC.png?url'
-import geyserWebp from '../assets/payto_logos/geyser_fund.webp?url'
-import githubSponsorsPng from '../assets/payto_logos/github_sponsors.png?url'
-import gofundmeJpeg from '../assets/payto_logos/gofundme.jpeg?url'
-import googlePayJpeg from '../assets/payto_logos/google_pay.jpeg?url'
-import kickstarterWebp from '../assets/payto_logos/kickstarter.webp?url'
-import kofiPng from '../assets/payto_logos/ko-fi.png?url'
-import lbtcSvg from '../assets/payto_logos/LBTC.svg?url'
-import litecoinPng from '../assets/payto_logos/Litecoin.png?url'
-import moneroPng from '../assets/payto_logos/Monero.png?url'
-import patreonPng from '../assets/payto_logos/patreon.png?url'
-import paypalWebp from '../assets/payto_logos/paypal.webp?url'
-import revolutWebp from '../assets/payto_logos/revolut.webp?url'
-import solanaPng from '../assets/payto_logos/solana.png?url'
-import tetherLogo from '../assets/payto_logos/tether-usdt-logo.svg?url'
-import tronPng from '../assets/payto_logos/Tron.png?url'
-import usdcLogo from '../assets/payto_logos/usd-coin-usdc-logo.svg?url'
-import venmoPng from '../assets/payto_logos/venmo.png?url'
-import xrpGif from '../assets/payto_logos/XRP.gif?url'
-export const PAYTO_LOGO_URL_BY_FILENAME: Record = {
- 'apple_pay.svg': applePaySvg,
- 'bitcoin-cash-bch-logo.svg': bitcoinCashLogo,
- 'BNB.png': bnbPng,
- 'buymeacoffee.png': buyMeACoffeePng,
- 'cashapp.webp': cashappWebp,
- 'multi-collateral-dai-dai-logo.svg': daiLogo,
- 'dogecoin-doge-logo.svg': dogecoinLogo,
- 'ethereum-eth-logo.svg': ethLogo,
- 'EurC.png': eurocPng,
- 'geyser_fund.webp': geyserWebp,
- 'github_sponsors.png': githubSponsorsPng,
- 'gofundme.jpeg': gofundmeJpeg,
- 'google_pay.jpeg': googlePayJpeg,
- 'kickstarter.webp': kickstarterWebp,
- 'ko-fi.png': kofiPng,
- 'LBTC.svg': lbtcSvg,
- 'Litecoin.png': litecoinPng,
- 'Monero.png': moneroPng,
- 'patreon.png': patreonPng,
- 'paypal.webp': paypalWebp,
- 'revolut.webp': revolutWebp,
- 'solana.png': solanaPng,
- 'tether-usdt-logo.svg': tetherLogo,
- 'Tron.png': tronPng,
- 'usd-coin-usdc-logo.svg': usdcLogo,
- 'venmo.png': venmoPng,
- 'XRP.gif': xrpGif
+const logoModules = import.meta.glob('../assets/payto_logos/*', {
+ eager: true,
+ query: '?url',
+ import: 'default'
+})
+
+/** Repo-relative path or basename → bundled URL (e.g. `/assets/…`). */
+const URL_BY_ASSET_PATH = new Map()
+
+for (const [modulePath, url] of Object.entries(logoModules)) {
+ const filename = modulePath.split('/payto_logos/')[1]
+ if (!filename || !url) continue
+ const assetPath = `src/assets/payto_logos/${filename}`
+ URL_BY_ASSET_PATH.set(assetPath, url)
+ URL_BY_ASSET_PATH.set(filename, url)
}
+
+/**
+ * Resolve a catalog `logoAssetPath` (or legacy basename) to the app asset URL.
+ */
+export function resolvePaytoLogoAssetPath(assetPathOrFilename: string | undefined): string | null {
+ if (!assetPathOrFilename?.trim()) return null
+ const key = assetPathOrFilename.trim()
+ return URL_BY_ASSET_PATH.get(key) ?? URL_BY_ASSET_PATH.get(key.split('/').pop() ?? '') ?? null
+}
+
+/** @deprecated Use {@link resolvePaytoLogoAssetPath} with catalog `logoAssetPath`. */
+export const PAYTO_LOGO_URL_BY_FILENAME: Record = Object.fromEntries(
+ [...URL_BY_ASSET_PATH.entries()].filter(([k]) => !k.startsWith('src/'))
+)
diff --git a/src/lib/payto-registry.ts b/src/lib/payto-registry.ts
new file mode 100644
index 00000000..ce4f3812
--- /dev/null
+++ b/src/lib/payto-registry.ts
@@ -0,0 +1,111 @@
+/**
+ * Loads payto type metadata from {@link ../data/payto-types.json}.
+ * Edit that JSON to add types, editor order, hints, logos, and profile URL templates.
+ */
+
+import paytoTypesCatalog from '@/data/payto-types.json'
+import { resolvePaytoLogoAssetPath } from '@/lib/payto-logos'
+
+export type PaytoCategory = 'bitcoin' | 'bitcoin-layer' | 'crypto' | 'stablecoin' | 'fiat' | 'tip'
+
+export type PaytoAuthorityHelp = {
+ placeholder: string
+ hint: string
+}
+
+export type PaytoTypeRecord = {
+ label: string
+ symbol?: string
+ category: PaytoCategory
+ /** Repo-relative path, e.g. `src/assets/payto_logos/ethereum-eth-logo.svg`. */
+ logoAssetPath?: string
+ profileUrlTemplate?: string
+ authority?: PaytoAuthorityHelp
+}
+
+type PaytoTypesCatalogJson = {
+ editorOrder: string[]
+ genericAuthorityHelp: PaytoAuthorityHelp
+ aliases: Record
+ types: Record
+}
+
+const catalog = paytoTypesCatalog as PaytoTypesCatalogJson
+
+export const PAYTO_EDITOR_TYPE_ORDER: readonly string[] = catalog.editorOrder
+
+const GENERIC_AUTHORITY_HELP: PaytoAuthorityHelp = catalog.genericAuthorityHelp
+
+const PAYTO_TYPE_ALIASES: Record = catalog.aliases
+
+const PAYTO_TYPES: Record = catalog.types
+
+/** UI summary per canonical type (label, symbol, category). */
+export const PAYTO_KNOWN_TYPES: Record<
+ string,
+ { label: string; symbol?: string; category: PaytoCategory }
+> = Object.fromEntries(
+ Object.entries(PAYTO_TYPES).map(([id, row]) => [
+ id,
+ { label: row.label, symbol: row.symbol, category: row.category }
+ ])
+)
+
+export function getCanonicalPaytoType(type: string): string {
+ const key = type.toLowerCase().trim()
+ return PAYTO_TYPE_ALIASES[key] ?? key
+}
+
+export function getPaytoTypeRecord(type: string): PaytoTypeRecord | undefined {
+ return PAYTO_TYPES[getCanonicalPaytoType(type)]
+}
+
+export function getPaytoTypeInfo(type: string): (typeof PAYTO_KNOWN_TYPES)[string] | undefined {
+ return PAYTO_KNOWN_TYPES[getCanonicalPaytoType(type)]
+}
+
+export function isKnownPaytoType(type: string): boolean {
+ return getCanonicalPaytoType(type) in PAYTO_KNOWN_TYPES
+}
+
+export function getPaytoAuthorityFieldHelp(type: string): PaytoAuthorityHelp {
+ const row = getPaytoTypeRecord(type)
+ return row?.authority ?? GENERIC_AUTHORITY_HELP
+}
+
+export function getPaytoEditorTypeLabel(type: string): string {
+ return getPaytoTypeInfo(type)?.label ?? getCanonicalPaytoType(type)
+}
+
+/** Dropdown options: catalog order plus the row's type when not listed. */
+export function paytoEditorSelectTypes(currentType: string): string[] {
+ const key = getCanonicalPaytoType(currentType)
+ const ordered = new Set(PAYTO_EDITOR_TYPE_ORDER)
+ const out = [...PAYTO_EDITOR_TYPE_ORDER]
+ if (key && !ordered.has(key)) out.push(key)
+ return out
+}
+
+/** Bundled asset URL for ` ` (resolved from catalog `logoAssetPath`). */
+export function getPaytoLogoPath(type: string): string | null {
+ return resolvePaytoLogoAssetPath(getPaytoTypeRecord(type)?.logoAssetPath)
+}
+
+/** Same as {@link getPaytoLogoPath}; alias for callers that expect a URL field name. */
+export function getPaytoLogoUrl(type: string): string | null {
+ return getPaytoLogoPath(type)
+}
+
+export function getPaytoProfileUrl(type: string, authority: string): string | null {
+ const template = getPaytoTypeRecord(type)?.profileUrlTemplate
+ if (!template || !authority.trim()) return null
+ return template.replace('{authority}', encodeURIComponent(authority.trim()))
+}
+
+export function getPaytoIconChar(type: string): string | null {
+ return getPaytoTypeRecord(type)?.symbol ?? null
+}
+
+export function isLightningPaytoType(type: string): boolean {
+ return getCanonicalPaytoType(type) === 'lightning'
+}
diff --git a/src/lib/payto.ts b/src/lib/payto.ts
index af935fb2..49c744c1 100644
--- a/src/lib/payto.ts
+++ b/src/lib/payto.ts
@@ -1,9 +1,27 @@
/**
* payto: URI handling (RFC-8905 / NIP-A3)
- * Parse and normalize payto:/// URIs; known types for UI (icons, labels, dialogs).
+ * Type metadata lives in {@link ../data/payto-types.json} via {@link ./payto-registry}.
*/
-import { PAYTO_LOGO_URL_BY_FILENAME } from '@/lib/payto-logos'
+import { getCanonicalPaytoType } from '@/lib/payto-registry'
+
+export {
+ getCanonicalPaytoType,
+ getPaytoAuthorityFieldHelp,
+ getPaytoEditorTypeLabel,
+ getPaytoIconChar,
+ getPaytoLogoPath,
+ getPaytoLogoUrl,
+ getPaytoProfileUrl,
+ getPaytoTypeInfo,
+ isKnownPaytoType,
+ isLightningPaytoType,
+ paytoEditorSelectTypes,
+ PAYTO_EDITOR_TYPE_ORDER,
+ PAYTO_KNOWN_TYPES,
+ type PaytoAuthorityHelp,
+ type PaytoCategory
+} from '@/lib/payto-registry'
export const PAYTO_URI_REGEX = /payto:\/\/([a-z0-9-]+)\/([^\s\]\)\<\"']+)/gi
@@ -35,155 +53,3 @@ export function buildPaytoUri(type: string, authority: string): string {
const a = encodeURIComponent(authority.trim())
return `payto://${t}/${a}`
}
-
-/** Known payment types: NIP-A3 recommended + common extras (crypto, fiat, tipping) */
-export const PAYTO_KNOWN_TYPES: Record<
- string,
- { label: string; symbol?: string; category: 'bitcoin' | 'bitcoin-layer' | 'crypto' | 'stablecoin' | 'fiat' | 'tip' }
-> = {
- bitcoin: { label: 'Bitcoin', symbol: '₿', category: 'bitcoin' },
- /**
- * Liquid sidechain — Bitcoin L3 (settlement layer), analogous in role to Lightning (L2) as a
- * Bitcoin-native extension; not an alt-L1 “crypto” bucket.
- */
- liquid: { label: 'Liquid', symbol: '⛓', category: 'bitcoin-layer' },
- /** Confidential Bitcoin on Liquid (L-BTC). */
- lbtc: { label: 'Liquid Bitcoin', symbol: '₿', category: 'bitcoin-layer' },
- sats: { label: 'Satoshis', symbol: '丰', category: 'bitcoin' },
- lightning: { label: 'Lightning Network', symbol: '⚡', category: 'bitcoin-layer' },
- ethereum: { label: 'Ethereum', symbol: 'Ξ', category: 'crypto' },
- monero: { label: 'Monero', symbol: 'ɱ', category: 'crypto' },
- nano: { label: 'Nano', symbol: 'Ӿ', category: 'crypto' },
- cashme: { label: 'Cash App', symbol: '$', category: 'fiat' },
- revolut: { label: 'Revolut', symbol: '💳', category: 'fiat' },
- venmo: { label: 'Venmo', symbol: '$', category: 'fiat' },
-
- // Common crypto
- 'bitcoin-cash': { label: 'Bitcoin Cash', symbol: '₿', category: 'crypto' },
- dogecoin: { label: 'Dogecoin', symbol: 'Ð', category: 'crypto' },
- litecoin: { label: 'Litecoin', symbol: 'Ł', category: 'crypto' },
- usdt: { label: 'Tether', symbol: '₮', category: 'stablecoin' },
- usdc: { label: 'USD Coin', symbol: '◎', category: 'stablecoin' },
- dai: { label: 'Dai', symbol: '◈', category: 'crypto' },
- euroc: { label: 'Euro Coin', symbol: '€', category: 'stablecoin' },
- solana: { label: 'Solana', symbol: '◎', category: 'crypto' },
-
- // Tipping / donation
- paypal: { label: 'PayPal', symbol: '💙', category: 'fiat' },
- buymeacoffee: { label: 'Buy Me a Coffee', symbol: '☕', category: 'tip' },
- 'ko-fi': { label: 'Ko-fi', symbol: '☕', category: 'tip' },
- kofi: { label: 'Ko-fi', symbol: '☕', category: 'tip' },
- patreon: { label: 'Patreon', symbol: '🎭', category: 'tip' },
- github: { label: 'GitHub Sponsors', symbol: '🐙', category: 'tip' },
-
- // Fiat / wallets
- 'apple-pay': { label: 'Apple Pay', symbol: '🍎', category: 'fiat' },
- 'google-pay': { label: 'Google Pay', symbol: 'G', category: 'fiat' },
-
- // Crowdfunding / fundraising
- geyser: { label: 'Geyser Fund', symbol: '⛲', category: 'tip' },
- gofundme: { label: 'GoFundMe', symbol: '🎯', category: 'tip' },
- kickstarter: { label: 'Kickstarter', symbol: '🚀', category: 'tip' }
-}
-
-/**
- * Short labels accepted after payto:// that map to a canonical type.
- * e.g. payto://BTC/... maps to bitcoin; payto://LBTC/... maps to Liquid Bitcoin (not Lightning).
- */
-const PAYTO_TYPE_ALIASES: Record = {
- btc: 'bitcoin',
- doge: 'dogecoin',
- eth: 'ethereum',
- xmr: 'monero',
- ltc: 'litecoin',
- xno: 'nano',
- sol: 'solana',
- bch: 'bitcoin-cash'
-}
-
-export function getCanonicalPaytoType(type: string): string {
- const key = type.toLowerCase().trim()
- return PAYTO_TYPE_ALIASES[key] ?? key
-}
-
-/** Icon character/symbol for known types; null for unknown (render HelpCircle or ?) */
-export function getPaytoIconChar(type: string): string | null {
- const info = getPaytoTypeInfo(type)
- return info?.symbol ?? null
-}
-
-/** Logo filename in `src/assets/payto_logos/` for types that have an asset. Any image format works: .svg, .gif, .jpg, .png, .webp, etc. */
-const PAYTO_LOGO_FILES: Record = {
- liquid: 'LBTC.svg',
- lbtc: 'LBTC.svg',
- ethereum: 'ethereum-eth-logo.svg',
- monero: 'Monero.png',
- litecoin: 'Litecoin.png',
- dogecoin: 'dogecoin-doge-logo.svg',
- usdt: 'tether-usdt-logo.svg',
- usdc: 'usd-coin-usdc-logo.svg',
- dai: 'multi-collateral-dai-dai-logo.svg',
- euroc: 'EurC.png',
- solana: 'solana.png',
- bnb: 'BNB.png',
- tron: 'Tron.png',
- xrp: 'XRP.gif',
- 'bitcoin-cash': 'bitcoin-cash-bch-logo.svg',
- cashme: 'cashapp.webp',
- venmo: 'venmo.png',
- paypal: 'paypal.webp',
- revolut: 'revolut.webp',
- buymeacoffee: 'buymeacoffee.png',
- 'ko-fi': 'ko-fi.png',
- kofi: 'ko-fi.png',
- patreon: 'patreon.png',
- github: 'github_sponsors.png',
- 'apple-pay': 'apple_pay.svg',
- 'google-pay': 'google_pay.jpeg',
- geyser: 'geyser_fund.webp',
- gofundme: 'gofundme.jpeg',
- kickstarter: 'kickstarter.webp'
-}
-
-/** Profile/page URL template for types that have a web profile. Use {authority} as placeholder. Null = no direct link. */
-const PAYTO_PROFILE_URL_TEMPLATES: Record = {
- paypal: 'https://paypal.me/{authority}',
- venmo: 'https://venmo.com/{authority}',
- revolut: 'https://revolut.me/{authority}',
- buymeacoffee: 'https://buymeacoffee.com/{authority}',
- 'ko-fi': 'https://ko-fi.com/{authority}',
- kofi: 'https://ko-fi.com/{authority}',
- patreon: 'https://patreon.com/{authority}',
- github: 'https://github.com/sponsors/{authority}',
- geyser: 'https://geyser.fund/project/{authority}',
- gofundme: 'https://www.gofundme.com/f/{authority}',
- kickstarter: 'https://www.kickstarter.com/projects/{authority}',
- cashme: 'https://cash.app/{authority}'
-}
-
-export function getPaytoProfileUrl(type: string, authority: string): string | null {
- const key = type.toLowerCase()
- const template = PAYTO_PROFILE_URL_TEMPLATES[key]
- if (!template || !authority) return null
- return template.replace('{authority}', encodeURIComponent(authority.trim()))
-}
-
-export function getPaytoLogoPath(type: string): string | null {
- const key = getCanonicalPaytoType(type)
- const file = PAYTO_LOGO_FILES[key]
- if (!file) return null
- return PAYTO_LOGO_URL_BY_FILENAME[file] ?? null
-}
-
-export function getPaytoTypeInfo(type: string): (typeof PAYTO_KNOWN_TYPES)[string] | undefined {
- return PAYTO_KNOWN_TYPES[getCanonicalPaytoType(type)]
-}
-
-export function isKnownPaytoType(type: string): boolean {
- return getCanonicalPaytoType(type) in PAYTO_KNOWN_TYPES
-}
-
-/** Check if type is lightning (opens Zap flow when pubkey available) */
-export function isLightningPaytoType(type: string): boolean {
- return getCanonicalPaytoType(type) === 'lightning'
-}
diff --git a/src/pages/secondary/ProfileEditorPage/index.tsx b/src/pages/secondary/ProfileEditorPage/index.tsx
index 06af2006..fc63e524 100644
--- a/src/pages/secondary/ProfileEditorPage/index.tsx
+++ b/src/pages/secondary/ProfileEditorPage/index.tsx
@@ -34,6 +34,7 @@ import {
} from '@/components/ui/select'
import { canUseNostrBuildThumb, toNostrBuildThumbUrl } from '@/lib/nostr-build'
import { isVideo } from '@/lib/url'
+import PaymentMethodRow from '@/components/ProfileEditor/PaymentMethodRow'
import { ChevronDown, Fingerprint, Pencil, Plus, RefreshCw, Trash2, Upload } from 'lucide-react'
import type { Event } from 'nostr-tools'
import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'react'
@@ -816,44 +817,25 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
-
{t('Payment methods')}
- {t('NIP-A3 payto tags: type (e.g. lightning) and authority (e.g. user@domain.com).')}
+ {t('paytoEditor.intro', {
+ defaultValue:
+ 'Choose a payment type, then enter the address or username shown in the hint below each field.'
+ })}
-