Browse Source

make payto more self-explanatory

imwald
Silberengel 4 weeks ago
parent
commit
56636d0671
  1. 8
      src/assets/payto_logos/README.md
  2. 8
      src/components/Profile/index.tsx
  3. 78
      src/components/ProfileEditor/PaymentMethodRow.tsx
  4. 7
      src/components/ProfileList/index.tsx
  5. 376
      src/data/payto-types.json
  6. 93
      src/hooks/useProfileReportsEvents.tsx
  7. 34
      src/hooks/useProfileWall.tsx
  8. 7
      src/i18n/locales/de.ts
  9. 3
      src/i18n/locales/en.ts
  10. 31
      src/lib/payto-editor-hints.test.ts
  11. 88
      src/lib/payto-logos.ts
  12. 111
      src/lib/payto-registry.ts
  13. 174
      src/lib/payto.ts
  14. 54
      src/pages/secondary/ProfileEditorPage/index.tsx

8
src/assets/payto_logos/README.md

@ -1,9 +1,9 @@
# Payto logos # Payto logos
Icons for payment types (crypto, etc.) used by payto links. Icons for payment types used by payto links in the app.
**Supported formats:** SVG, GIF, JPG/JPEG, PNG, WebP, etc. — any format the browser can display in `<img>` is fine. SVG scales best at different sizes. **Supported formats:** SVG, GIF, JPG/JPEG, PNG, WebP, etc.
Filenames are mapped in `src/lib/payto.ts``PAYTO_LOGO_FILES`. Use whatever extension the asset has (.svg, .gif, .jpg, .png, …). **Catalog:** Each type’s `logoAssetPath` in [`src/data/payto-types.json`](../../data/payto-types.json) points at a file here (e.g. `src/assets/payto_logos/ethereum-eth-logo.svg`). Add or change logos by editing that JSON and placing the image in this folder.
Bundled via Vite (`import.meta.glob`) so URLs live under `/assets/` and deploy with the app build. Bundled via Vite `import.meta.glob` in [`src/lib/payto-logos.ts`](../../lib/payto-logos.ts); runtime URLs are under `/assets/…`.

8
src/components/Profile/index.tsx

@ -865,10 +865,14 @@ export default function Profile({
<ProfilePublicationsFeed ref={publicationsFeedRef} pubkey={pubkey} /> <ProfilePublicationsFeed ref={publicationsFeedRef} pubkey={pubkey} />
</TabsContent> </TabsContent>
<TabsContent value="reports" className="min-w-0 focus-visible:outline-none"> <TabsContent value="reports" className="min-w-0 focus-visible:outline-none">
<ProfileReportsFeed ref={reportsFeedRef} pubkey={pubkey} /> {profileFeedTab === 'reports' ? (
<ProfileReportsFeed ref={reportsFeedRef} pubkey={pubkey} />
) : null}
</TabsContent> </TabsContent>
<TabsContent value="wall" className="min-w-0 focus-visible:outline-none"> <TabsContent value="wall" className="min-w-0 focus-visible:outline-none">
<ProfileWallFeed ref={wallFeedRef} pubkey={pubkey} profileEventId={profileEvent?.id} /> {profileFeedTab === 'wall' ? (
<ProfileWallFeed ref={wallFeedRef} pubkey={pubkey} profileEventId={profileEvent?.id} />
) : null}
</TabsContent> </TabsContent>
{isSelf && ( {isSelf && (
<TabsContent value="liked" className="min-w-0 focus-visible:outline-none"> <TabsContent value="liked" className="min-w-0 focus-visible:outline-none">

78
src/components/ProfileEditor/PaymentMethodRow.tsx

@ -0,0 +1,78 @@
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import {
getCanonicalPaytoType,
getPaytoAuthorityFieldHelp,
getPaytoEditorTypeLabel,
paytoEditorSelectTypes
} from '@/lib/payto'
import { Trash2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
export type PaymentMethodRowValue = { type: string; authority: string }
type PaymentMethodRowProps = {
row: PaymentMethodRowValue
onChange: (row: PaymentMethodRowValue) => void
onRemove: () => void
}
export default function PaymentMethodRow({ row, onChange, onRemove }: PaymentMethodRowProps) {
const { t } = useTranslation()
const selectTypes = paytoEditorSelectTypes(row.type)
const canonicalType = getCanonicalPaytoType(row.type || 'lightning')
const fieldHelp = getPaytoAuthorityFieldHelp(canonicalType)
return (
<div className="flex gap-2 items-start">
<Select
value={canonicalType}
onValueChange={(type) => onChange({ ...row, type })}
>
<SelectTrigger className="w-[11.5rem] shrink-0 font-medium text-sm">
<SelectValue placeholder={t('Payment type')} />
</SelectTrigger>
<SelectContent className="max-h-[min(20rem,70vh)]">
{selectTypes.map((type) => (
<SelectItem key={type} value={type}>
{getPaytoEditorTypeLabel(type)}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex-1 min-w-0 space-y-1">
<Input
value={row.authority}
onChange={(e) => onChange({ ...row, authority: e.target.value })}
placeholder={t(`paytoEditor.placeholder.${canonicalType}`, {
defaultValue: fieldHelp.placeholder
})}
className="font-mono text-sm"
aria-describedby={`payto-hint-${canonicalType}`}
/>
<p id={`payto-hint-${canonicalType}`} className="text-xs text-muted-foreground leading-snug">
{t(`paytoEditor.hint.${canonicalType}`, { defaultValue: fieldHelp.hint })}
</p>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0 text-muted-foreground hover:text-destructive mt-0.5"
onClick={onRemove}
aria-label={t('Remove')}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
)
}

7
src/components/ProfileList/index.tsx

@ -1,13 +1,14 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import UserItem from '../UserItem' import UserItem from '../UserItem'
export default function ProfileList({ pubkeys }: { pubkeys: string[] }) { export default function ProfileList({ pubkeys }: { pubkeys: string[] }) {
const [visiblePubkeys, setVisiblePubkeys] = useState<string[]>([]) const [visiblePubkeys, setVisiblePubkeys] = useState<string[]>([])
const bottomRef = useRef<HTMLDivElement>(null) const bottomRef = useRef<HTMLDivElement>(null)
const pubkeysKey = useMemo(() => pubkeys.join('\u0001'), [pubkeys])
useEffect(() => { useEffect(() => {
setVisiblePubkeys(pubkeys.slice(0, 10)) setVisiblePubkeys(pubkeys.slice(0, 10))
}, [pubkeys]) }, [pubkeysKey, pubkeys])
useEffect(() => { useEffect(() => {
const options = { const options = {
@ -32,7 +33,7 @@ export default function ProfileList({ pubkeys }: { pubkeys: string[] }) {
observerInstance.unobserve(currentBottomRef) observerInstance.unobserve(currentBottomRef)
} }
} }
}, [visiblePubkeys, pubkeys]) }, [visiblePubkeys, pubkeysKey, pubkeys])
return ( return (
<div className="px-4 pt-2"> <div className="px-4 pt-2">

376
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://<type>/<this value> (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"
}
}
}

93
src/hooks/useProfileReportsEvents.tsx

@ -10,7 +10,7 @@ import { useDeletedEvent } from '@/providers/DeletedEventProvider'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useNostrOptional } from '@/providers/nostr-context' import { useNostrOptional } from '@/providers/nostr-context'
import client from '@/services/client.service' 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' import { Event, kinds, type Filter } from 'nostr-tools'
const REPORT_KINDS = [kinds.Report, ExtendedKind.REPORT] as const 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) 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' type FetchMode = 'received' | 'made'
function buildFilter(pubkey: string, mode: FetchMode, limit: number): Filter { function buildFilter(pubkey: string, mode: FetchMode, limit: number): Filter {
@ -102,6 +110,12 @@ export function useProfileReportsEvents({
const relayUrlsBuilderRef = useRef(relayUrlsBuilder) const relayUrlsBuilderRef = useRef(relayUrlsBuilder)
relayUrlsBuilderRef.current = 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( const resolveFeedUrls = useCallback(
( (
@ -110,19 +124,24 @@ export function useProfileReportsEvents({
) => { ) => {
const custom = relayUrlsBuilderRef.current const custom = relayUrlsBuilderRef.current
if (custom) { if (custom) {
return custom(favoriteRelays, blockedRelays, authorRelayList, includeAuthorLocal) return custom(
favoriteRelaysRef.current,
blockedRelaysRef.current,
authorRelayList,
includeAuthorLocal
)
} }
return buildProfilePageReadRelayUrls( return buildProfilePageReadRelayUrls(
favoriteRelays, favoriteRelaysRef.current,
blockedRelays, blockedRelaysRef.current,
authorRelayList, authorRelayList,
false, false,
includeAuthorLocal, includeAuthorLocal,
[...REPORT_KINDS], [...REPORT_KINDS],
useGlobalRelayBootstrap useGlobalRelayBootstrapRef.current
) )
}, },
[favoriteRelays, blockedRelays, useGlobalRelayBootstrap] []
) )
useEffect(() => { useEffect(() => {
@ -142,12 +161,11 @@ export function useProfileReportsEvents({
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
const closers: (() => void)[] = []
const loadMode = async ( const loadMode = async (
mode: FetchMode, mode: FetchMode,
cacheKey: string, cacheKey: string,
setEvents: (events: Event[]) => void setEvents: Dispatch<SetStateAction<Event[]>>
) => { ) => {
const mem = memoryByKey.get(cacheKey) const mem = memoryByKey.get(cacheKey)
const cacheAge = mem ? Date.now() - mem.lastUpdated : Infinity const cacheAge = mem ? Date.now() - mem.lastUpdated : Infinity
@ -166,7 +184,7 @@ export function useProfileReportsEvents({
postFilter(pubkey, mode) postFilter(pubkey, mode)
) )
memoryByKey.set(cacheKey, { events: processed, lastUpdated: Date.now() }) memoryByKey.set(cacheKey, { events: processed, lastUpdated: Date.now() })
setEvents(processed) setEvents((prev) => (eventsEqualById(prev, processed) ? prev : processed))
} }
let pkNorm = pubkey let pkNorm = pubkey
@ -207,28 +225,6 @@ export function useProfileReportsEvents({
/* ignore */ /* 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) const authorRl = await client.fetchRelayList(pubkey).catch(() => emptyAuthor)
if (cancelled) return if (cancelled) return
const fullUrls = resolveFeedUrls(authorRl, includeAuthorLocalRelays) const fullUrls = resolveFeedUrls(authorRl, includeAuthorLocalRelays)
@ -246,23 +242,15 @@ export function useProfileReportsEvents({
/* ignore */ /* ignore */
} }
try { try {
const { closer } = await client.subscribeTimeline( const fetchedDelta = await client.fetchEvents(deltaUrls, filter, {
deltaRequests, cache: true,
{ eoseTimeout: 4500,
onEvents: (rows) => { globalTimeout: 14_000
if (cancelled) return })
for (const e of rows as Event[]) pool.set(e.id, e) if (!cancelled) {
flush() for (const e of fetchedDelta) 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 { } catch {
/* ignore */ /* ignore */
} }
@ -280,13 +268,13 @@ export function useProfileReportsEvents({
if (madeFresh && madeMem) { if (madeFresh && madeMem) {
setMade(madeMem.events) setMade(madeMem.events)
} }
if (recvFresh && madeFresh) { if (recvFresh && madeFresh && refreshToken === 0) {
setIsLoading(false) setIsLoading(false)
if (refreshToken === 0) return return
} else {
setIsLoading(true)
} }
setIsLoading(true)
await Promise.all([ await Promise.all([
loadMode('received', receivedCacheKey, setReceived), loadMode('received', receivedCacheKey, setReceived),
loadMode('made', madeCacheKey, setMade) loadMode('made', madeCacheKey, setMade)
@ -299,7 +287,6 @@ export function useProfileReportsEvents({
return () => { return () => {
cancelled = true cancelled = true
closers.forEach((c) => c())
} }
}, [ }, [
pubkey, pubkey,

34
src/hooks/useProfileWall.tsx

@ -12,6 +12,7 @@ import {
} from '@/lib/nip58-profile-badges' } from '@/lib/nip58-profile-badges'
import { isDirectProfileWallComment } from '@/lib/profile-wall-comments' import { isDirectProfileWallComment } from '@/lib/profile-wall-comments'
import { normalizeHexPubkey } from '@/lib/pubkey' import { normalizeHexPubkey } from '@/lib/pubkey'
import { normalizeAnyRelayUrl } from '@/lib/url'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useDeletedEvent } from '@/providers/DeletedEventProvider' import { useDeletedEvent } from '@/providers/DeletedEventProvider'
import client, { replaceableEventService } from '@/services/client.service' 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 CACHE_DURATION = 5 * 60 * 1000
const wallCacheByKey = new Map<string, { badges: ResolvedProfileBadge[]; comments: Event[]; lastUpdated: number }>() const wallCacheByKey = new Map<string, { badges: ResolvedProfileBadge[]; comments: Event[]; lastUpdated: number }>()
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) { export function useProfileWall(pubkey: string, profileEventId: string | undefined) {
const { favoriteRelays, blockedRelays } = useFavoriteRelays() const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const useGlobalRelayBootstrap = useGlobalRelayBootstrapDefaults() const useGlobalRelayBootstrap = useGlobalRelayBootstrapDefaults()
@ -36,6 +43,17 @@ export function useProfileWall(pubkey: string, profileEventId: string | undefine
const [isLoading, setIsLoading] = useState(!cached) const [isLoading, setIsLoading] = useState(!cached)
const [refreshToken, setRefreshToken] = useState(0) 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(() => { useEffect(() => {
let cancelled = false let cancelled = false
@ -62,13 +80,13 @@ export function useProfileWall(pubkey: string, profileEventId: string | undefine
if (cancelled) return if (cancelled) return
const relayUrls = buildProfilePageReadRelayUrls( const relayUrls = buildProfilePageReadRelayUrls(
favoriteRelays, favoriteRelaysRef.current,
blockedRelays, blockedRelaysRef.current,
authorRl, authorRl,
false, false,
false, false,
[ExtendedKind.COMMENT, ExtendedKind.PROFILE_BADGES_LIST, ExtendedKind.BADGE_DEFINITION], [ExtendedKind.COMMENT, ExtendedKind.PROFILE_BADGES_LIST, ExtendedKind.BADGE_DEFINITION],
useGlobalRelayBootstrap useGlobalRelayBootstrapRef.current
) )
// --- Badges (NIP-58) --- // --- Badges (NIP-58) ---
@ -159,15 +177,7 @@ export function useProfileWall(pubkey: string, profileEventId: string | undefine
return () => { return () => {
cancelled = true cancelled = true
} }
}, [ }, [pubkey, profileEventId, cacheKey, refreshToken, relayListsKey])
pubkey,
profileEventId,
cacheKey,
refreshToken,
favoriteRelays,
blockedRelays,
useGlobalRelayBootstrap
])
const refresh = useCallback(() => { const refresh = useCallback(() => {
wallCacheByKey.delete(cacheKey) wallCacheByKey.delete(cacheKey)

7
src/i18n/locales/de.ts

@ -152,8 +152,11 @@ export default {
"Payment info updated": "Payment info updated", "Payment info updated": "Payment info updated",
"Failed to publish payment info": "Failed to publish payment info", "Failed to publish payment info": "Failed to publish payment info",
"Invalid tags JSON": "Invalid tags JSON", "Invalid tags JSON": "Invalid tags JSON",
"Payment methods": "Payment methods", "Payment methods": "Zahlungsmethoden",
"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 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)", "Type (e.g. lightning)": "Type (e.g. lightning)",
"Authority (e.g. user@domain.com)": "Authority (e.g. user@domain.com)", "Authority (e.g. user@domain.com)": "Authority (e.g. user@domain.com)",
"Add payment method": "Add payment method", "Add payment method": "Add payment method",

3
src/i18n/locales/en.ts

@ -158,6 +158,9 @@ export default {
"Failed to publish payment info": "Failed to publish payment info", "Failed to publish payment info": "Failed to publish payment info",
"Invalid tags JSON": "Invalid tags JSON", "Invalid tags JSON": "Invalid tags JSON",
"Payment methods": "Payment methods", "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).", "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)", "Type (e.g. lightning)": "Type (e.g. lightning)",
"Authority (e.g. user@domain.com)": "Authority (e.g. user@domain.com)", "Authority (e.g. user@domain.com)": "Authority (e.g. user@domain.com)",

31
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)
})
})

88
src/lib/payto-logos.ts

@ -1,61 +1,35 @@
/** /**
* Explicit Vite `?url` imports so every payto logo is emitted under `/assets/` in production. * Resolves payto logo paths from {@link ../data/payto-types.json} `logoAssetPath` values.
* Keep files in `src/assets/payto_logos/` (not `public/`). * 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<string, string> = { const logoModules = import.meta.glob<string>('../assets/payto_logos/*', {
'apple_pay.svg': applePaySvg, eager: true,
'bitcoin-cash-bch-logo.svg': bitcoinCashLogo, query: '?url',
'BNB.png': bnbPng, import: 'default'
'buymeacoffee.png': buyMeACoffeePng, })
'cashapp.webp': cashappWebp,
'multi-collateral-dai-dai-logo.svg': daiLogo, /** Repo-relative path or basename → bundled URL (e.g. `/assets/…`). */
'dogecoin-doge-logo.svg': dogecoinLogo, const URL_BY_ASSET_PATH = new Map<string, string>()
'ethereum-eth-logo.svg': ethLogo,
'EurC.png': eurocPng, for (const [modulePath, url] of Object.entries(logoModules)) {
'geyser_fund.webp': geyserWebp, const filename = modulePath.split('/payto_logos/')[1]
'github_sponsors.png': githubSponsorsPng, if (!filename || !url) continue
'gofundme.jpeg': gofundmeJpeg, const assetPath = `src/assets/payto_logos/${filename}`
'google_pay.jpeg': googlePayJpeg, URL_BY_ASSET_PATH.set(assetPath, url)
'kickstarter.webp': kickstarterWebp, URL_BY_ASSET_PATH.set(filename, url)
'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
} }
/**
* 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<string, string> = Object.fromEntries(
[...URL_BY_ASSET_PATH.entries()].filter(([k]) => !k.startsWith('src/'))
)

111
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<string, string>
types: Record<string, PaytoTypeRecord>
}
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<string, string> = catalog.aliases
const PAYTO_TYPES: Record<string, PaytoTypeRecord> = 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 `<img src>` (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'
}

174
src/lib/payto.ts

@ -1,9 +1,27 @@
/** /**
* payto: URI handling (RFC-8905 / NIP-A3) * payto: URI handling (RFC-8905 / NIP-A3)
* Parse and normalize payto://<type>/<authority> 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 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()) const a = encodeURIComponent(authority.trim())
return `payto://${t}/${a}` 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<string, string> = {
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<string, string> = {
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<string, string> = {
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'
}

54
src/pages/secondary/ProfileEditorPage/index.tsx

@ -34,6 +34,7 @@ import {
} from '@/components/ui/select' } from '@/components/ui/select'
import { canUseNostrBuildThumb, toNostrBuildThumbUrl } from '@/lib/nostr-build' import { canUseNostrBuildThumb, toNostrBuildThumbUrl } from '@/lib/nostr-build'
import { isVideo } from '@/lib/url' import { isVideo } from '@/lib/url'
import PaymentMethodRow from '@/components/ProfileEditor/PaymentMethodRow'
import { ChevronDown, Fingerprint, Pencil, Plus, RefreshCw, Trash2, Upload } from 'lucide-react' import { ChevronDown, Fingerprint, Pencil, Plus, RefreshCw, Trash2, Upload } from 'lucide-react'
import type { Event } from 'nostr-tools' import type { Event } from 'nostr-tools'
import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'react'
@ -816,44 +817,25 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
<Item> <Item>
<Label className="text-muted-foreground">{t('Payment methods')}</Label> <Label className="text-muted-foreground">{t('Payment methods')}</Label>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{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.'
})}
</p> </p>
<div className="space-y-2"> <div className="space-y-3">
{paymentInfoEditMethods.map((row, idx) => ( {paymentInfoEditMethods.map((row, idx) => (
<div key={idx} className="flex gap-2 items-center"> <PaymentMethodRow
<Input key={idx}
placeholder={t('Type (e.g. lightning)')} row={row}
value={row.type} onChange={(next) => {
onChange={(e) => { const methods = [...paymentInfoEditMethods]
const next = [...paymentInfoEditMethods] methods[idx] = next
next[idx] = { ...next[idx], type: e.target.value } setPaymentInfoEditMethods(methods)
setPaymentInfoEditMethods(next) }}
}} onRemove={() =>
className="flex-1 max-w-[140px] font-mono text-sm" setPaymentInfoEditMethods(paymentInfoEditMethods.filter((_, i) => i !== idx))
/> }
<Input />
placeholder={t('Authority (e.g. user@domain.com)')}
value={row.authority}
onChange={(e) => {
const next = [...paymentInfoEditMethods]
next[idx] = { ...next[idx], authority: e.target.value }
setPaymentInfoEditMethods(next)
}}
className="flex-1 font-mono text-sm"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0 text-muted-foreground hover:text-destructive"
onClick={() =>
setPaymentInfoEditMethods(paymentInfoEditMethods.filter((_, i) => i !== idx))
}
aria-label={t('Remove')}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))} ))}
<Button <Button
type="button" type="button"

Loading…
Cancel
Save