From 74d7a872d25dba58e97713183f73c41681c79183 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Wed, 3 Dec 2025 16:48:07 +0100 Subject: [PATCH] support multiple profile tags and payto data --- src/components/Nip05List/index.tsx | 125 +++++++++++++++++++++ src/components/Profile/index.tsx | 133 +++++++++++++++++++++- src/constants.ts | 3 +- src/lib/event-metadata.ts | 171 ++++++++++++++++++++++++----- src/types/index.d.ts | 23 ++++ 5 files changed, 427 insertions(+), 28 deletions(-) create mode 100644 src/components/Nip05List/index.tsx diff --git a/src/components/Nip05List/index.tsx b/src/components/Nip05List/index.tsx new file mode 100644 index 0000000..5b19e39 --- /dev/null +++ b/src/components/Nip05List/index.tsx @@ -0,0 +1,125 @@ +import { Skeleton } from '@/components/ui/skeleton' +import { verifyNip05 } from '@/lib/nip05' +import { toNoteList } from '@/lib/link' +import { SecondaryPageLink } from '@/PageManager' +import { BadgeAlert, BadgeCheck } from 'lucide-react' +import { Favicon } from '../Favicon' +import { useEffect, useState } from 'react' + +interface Nip05Verification { + nip05: string + isVerified: boolean + nip05Name: string + nip05Domain: string + isFetching: boolean +} + +export default function Nip05List({ nip05List, pubkey }: { nip05List: string[]; pubkey: string }) { + const [verifications, setVerifications] = useState>(new Map()) + + useEffect(() => { + if (!nip05List || nip05List.length === 0 || !pubkey) return + + const verifyAll = async () => { + const newVerifications = new Map() + + // Initialize all as fetching + nip05List.forEach(nip05 => { + const [nip05Name, nip05Domain] = nip05.split('@') + newVerifications.set(nip05, { + nip05, + isVerified: false, + nip05Name: nip05Name || '', + nip05Domain: nip05Domain || '', + isFetching: true + }) + }) + setVerifications(newVerifications) + + // Verify each NIP-05 address + await Promise.all( + nip05List.map(async (nip05) => { + try { + const result = await verifyNip05(nip05, pubkey) + setVerifications(prev => { + const updated = new Map(prev) + updated.set(nip05, { + nip05, + isVerified: result.isVerified, + nip05Name: result.nip05Name || nip05.split('@')[0] || '', + nip05Domain: result.nip05Domain || nip05.split('@')[1] || '', + isFetching: false + }) + return updated + }) + } catch (error) { + setVerifications(prev => { + const updated = new Map(prev) + const existing = updated.get(nip05) || { + nip05, + isVerified: false, + nip05Name: nip05.split('@')[0] || '', + nip05Domain: nip05.split('@')[1] || '', + isFetching: false + } + updated.set(nip05, { ...existing, isFetching: false }) + return updated + }) + } + }) + ) + } + + verifyAll() + }, [nip05List, pubkey]) + + if (nip05List.length === 0) return null + + return ( +
+ {nip05List.map((nip05, idx) => { + const verification = verifications.get(nip05) + const isFetching = verification?.isFetching ?? true + const isVerified = verification?.isVerified ?? false + const nip05Name = verification?.nip05Name || nip05.split('@')[0] || '' + const nip05Domain = verification?.nip05Domain || nip05.split('@')[1] || '' + + if (isFetching) { + return ( +
+ +
+ ) + } + + return ( +
e.stopPropagation()} + > + {nip05Name !== '_' ? ( + @{nip05Name} + ) : null} + {isVerified ? ( + } + /> + ) : ( + + )} + + {nip05Domain} + +
+ ) + })} +
+ ) +} + diff --git a/src/components/Profile/index.tsx b/src/components/Profile/index.tsx index cb248a1..d0b0484 100644 --- a/src/components/Profile/index.tsx +++ b/src/components/Profile/index.tsx @@ -1,6 +1,7 @@ import Collapsible from '@/components/Collapsible' import FollowButton from '@/components/FollowButton' import Nip05 from '@/components/Nip05' +import Nip05List from '@/components/Nip05List' import NpubQrCode from '@/components/NpubQrCode' import ProfileAbout from '@/components/ProfileAbout' import ProfileBanner from '@/components/ProfileBanner' @@ -20,9 +21,10 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select' -import { ExtendedKind } from '@/constants' +import { ExtendedKind, BIG_RELAY_URLS } from '@/constants' import { useFetchProfile } from '@/hooks' import { Event, kinds } from 'nostr-tools' +import { getPaymentInfoFromEvent } from '@/lib/event-metadata' import { toProfileEditor } from '@/lib/link' import { generateImageByPubkey } from '@/lib/pubkey' import { useSecondaryPage } from '@/PageManager' @@ -54,6 +56,36 @@ export default function Profile({ id }: { id?: string }) { const { push } = useSecondaryPage() const { profile, isFetching } = useFetchProfile(id) const { pubkey: accountPubkey } = useNostr() + const [paymentInfo, setPaymentInfo] = useState | null>(null) + + // Fetch payment info (kind 10133) for this profile + useEffect(() => { + if (!profile?.pubkey) { + setPaymentInfo(null) + return + } + + const fetchPaymentInfo = async () => { + try { + const events = await client.fetchEvents(BIG_RELAY_URLS, [{ + authors: [profile.pubkey], + kinds: [ExtendedKind.PAYMENT_INFO], + limit: 1 + }]) + const paymentEvent = events[0] + if (paymentEvent) { + setPaymentInfo(getPaymentInfoFromEvent(paymentEvent)) + } else { + setPaymentInfo(null) + } + } catch (error) { + logger.error('Failed to fetch payment info', { error, pubkey: profile.pubkey }) + setPaymentInfo(null) + } + } + + fetchPaymentInfo() + }, [profile?.pubkey]) const [activeTab, setActiveTab] = useState('posts') const [searchQuery, setSearchQuery] = useState('') const [articleKindFilter, setArticleKindFilter] = useState('all') @@ -263,7 +295,7 @@ export default function Profile({ id }: { id?: string }) { } if (!profile) return - const { banner, username, about, avatar, pubkey, website, lightningAddress } = profile + const { banner, username, about, avatar, pubkey, website, websiteList, lightningAddress, lightningAddressList, nip05List } = profile logger.component('Profile', 'Profile data loaded', { pubkey, @@ -321,12 +353,27 @@ export default function Profile({ id }: { id?: string }) { )} + {/* Display multiple NIP-05 values if available, with verification */} + {nip05List && nip05List.length > 1 && ( + + )} + {/* Display lightning addresses - show first one prominently, others below */} {lightningAddress && (
{lightningAddress}
)} + {lightningAddressList && lightningAddressList.length > 1 && ( +
+ {lightningAddressList.slice(1).map((addr, idx) => ( +
+ + {addr} +
+ ))} +
+ )}
@@ -337,18 +384,100 @@ export default function Profile({ id }: { id?: string }) { className="text-wrap break-words whitespace-pre-wrap mt-2 select-text" /> + {/* Display websites - show first one prominently, others below */} {website && ( )} + {websiteList && websiteList.length > 1 && ( +
+ {websiteList.slice(1).map((url, idx) => ( + + ))} +
+ )} + {/* Display payment info from kind 10133 */} + {paymentInfo && ((paymentInfo.methods && paymentInfo.methods.length > 0) || paymentInfo.payto) && ( +
+
Payment Methods
+
+ {paymentInfo.methods && paymentInfo.methods.length > 0 ? ( + paymentInfo.methods.map((method, idx) => { + // NIP-A3: type is in method.type, authority is in method.authority + const displayType = method.displayType || method.type || 'Payment' + const authority = method.authority || method.address || '' + const paytoUri = method.payto || (method.type && authority ? `payto://${method.type}/${authority}` : undefined) + + return ( +
+
{displayType}
+ {authority && ( +
+ {method.type === 'lightning' && } + {authority} +
+ )} + {paytoUri && ( + e.stopPropagation()} + > + {paytoUri} + + )} + {(method.currency || (method.minAmount !== undefined && method.maxAmount !== undefined)) && ( +
+ {method.currency && ({method.currency})} + {method.minAmount !== undefined && method.maxAmount !== undefined && ( + + {method.minAmount}-{method.maxAmount} + + )} +
+ )} +
+ ) + }) + ) : ( + // Display payto from root level if methods array is empty + paymentInfo.payto && ( +
+
Lightning Network
+
+ + {paymentInfo.payto} +
+ {paymentInfo.currency && ( +
({paymentInfo.currency})
+ )} +
+ ) + )} +
+
+ )}
diff --git a/src/constants.ts b/src/constants.ts index 38c8ede..ec87b3b 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -162,7 +162,8 @@ export const ExtendedKind = { RSS_FEED_LIST: 10895, // NIP-89 Application Handlers APPLICATION_HANDLER_RECOMMENDATION: 31989, - APPLICATION_HANDLER_INFO: 31990 + APPLICATION_HANDLER_INFO: 31990, + PAYMENT_INFO: 10133 } export const SUPPORTED_KINDS = [ diff --git a/src/lib/event-metadata.ts b/src/lib/event-metadata.ts index 7e4d307..b492e0e 100644 --- a/src/lib/event-metadata.ts +++ b/src/lib/event-metadata.ts @@ -1,5 +1,5 @@ import { BIG_RELAY_URLS, POLL_TYPE } from '@/constants' -import { TEmoji, TPollType, TRelayList, TRelaySet } from '@/types' +import { TEmoji, TPollType, TRelayList, TRelaySet, TPaymentInfo, TProfile } from '@/types' import { Event, kinds } from 'nostr-tools' import { buildATag } from './draft-event' import { getReplaceableEventIdentifier } from './event' @@ -58,35 +58,156 @@ export function getRelayListFromEvent(event?: Event | null, blockedRelays?: stri } export function getProfileFromEvent(event: Event) { + // Parse JSON content as fallback + let profileObj: any = {} try { - const profileObj = JSON.parse(event.content) - const username = - profileObj.display_name?.trim() || - profileObj.name?.trim() || - profileObj.nip05?.split('@')[0]?.trim() - return { - pubkey: event.pubkey, - npub: pubkeyToNpub(event.pubkey) ?? '', - banner: profileObj.banner, - avatar: profileObj.picture, - username: username || formatPubkey(event.pubkey), - original_username: username, - nip05: profileObj.nip05, - about: profileObj.about, - website: profileObj.website ? normalizeHttpUrl(profileObj.website) : undefined, - lud06: profileObj.lud06, - lud16: profileObj.lud16, - lightningAddress: getLightningAddressFromProfile(profileObj), - created_at: event.created_at + profileObj = JSON.parse(event.content || '{}') + } catch (err) { + logger.error('Failed to parse event metadata JSON', { error: err, content: event.content }) + } + + // Extract values from tags (preferred over JSON content) + const nip05Tags = event.tags.filter(tag => tag[0] === 'nip05' && tag[1]).map(tag => tag[1]) + const websiteTags = event.tags.filter(tag => tag[0] === 'website' && tag[1]).map(tag => tag[1]) + const lud06Tags = event.tags.filter(tag => tag[0] === 'lud06' && tag[1]).map(tag => tag[1]) + const lud16Tags = event.tags.filter(tag => tag[0] === 'lud16' && tag[1]).map(tag => tag[1]) + + // Use first tag entry for single values, or fallback to JSON + const nip05 = nip05Tags.length > 0 ? nip05Tags[0] : profileObj.nip05 + const nip05List = nip05Tags.length > 0 ? nip05Tags : (profileObj.nip05 ? [profileObj.nip05] : undefined) + + const website = websiteTags.length > 0 + ? normalizeHttpUrl(websiteTags[0]) + : (profileObj.website ? normalizeHttpUrl(profileObj.website) : undefined) + const websiteList = websiteTags.length > 0 + ? websiteTags.map(w => normalizeHttpUrl(w)) + : (profileObj.website ? [normalizeHttpUrl(profileObj.website)] : undefined) + + // Use FIRST lightning tag from kind 0 only (for zap button - do not use subsequent tags or kind 10133) + const lud06 = lud06Tags.length > 0 ? lud06Tags[0] : profileObj.lud06 + const lud16 = lud16Tags.length > 0 ? lud16Tags[0] : profileObj.lud16 + + // Build lightning address from FIRST tag or JSON (prefer first tag, fallback to JSON) + // This is used by the zap button and should only come from kind 0, not kind 10133 payto + const lightningAddressFromTags = lud16 || lud06 + const lightningAddressFromJson = getLightningAddressFromProfile({ lud06: profileObj.lud06, lud16: profileObj.lud16 } as TProfile) + const lightningAddress = lightningAddressFromTags || lightningAddressFromJson + + // Build list of all lightning addresses (from tags first, then JSON) + const lightningAddressList = [...new Set([ + ...(lud16Tags.length > 0 ? lud16Tags : []), + ...(lud06Tags.length > 0 ? lud06Tags : []), + ...(profileObj.lud16 ? [profileObj.lud16] : []), + ...(profileObj.lud06 ? [profileObj.lud06] : []), + ...(lightningAddressFromJson && !lightningAddressFromTags ? [lightningAddressFromJson] : []) + ])].filter(Boolean) + + const username = + profileObj.display_name?.trim() || + profileObj.name?.trim() || + nip05?.split('@')[0]?.trim() + + return { + pubkey: event.pubkey, + npub: pubkeyToNpub(event.pubkey) ?? '', + banner: profileObj.banner, + avatar: profileObj.picture, + username: username || formatPubkey(event.pubkey), + original_username: username, + nip05, + nip05List: nip05List && nip05List.length > 0 ? nip05List : undefined, + about: profileObj.about, + website, + websiteList: websiteList && websiteList.length > 0 ? websiteList : undefined, + lud06, + lud16, + lightningAddress, + lightningAddressList: lightningAddressList.length > 0 ? lightningAddressList : undefined, + created_at: event.created_at + } +} + +export function getPaymentInfoFromEvent(event: Event): TPaymentInfo | null { + if (event.kind !== 10133) return null + + // Parse JSON content as fallback + let paymentInfo: any = {} + try { + if (event.content) { + paymentInfo = JSON.parse(event.content) } } catch (err) { - logger.error('Failed to parse event metadata', { error: err, content: event.content }) - return { - pubkey: event.pubkey, - npub: pubkeyToNpub(event.pubkey) ?? '', - username: formatPubkey(event.pubkey) + logger.error('Failed to parse payment info JSON', { error: err, content: event.content }) + } + + // Extract payment methods from tags (preferred over JSON content) + // NIP-A3 format: ["payto", "", "", "", ...] + // tag[0] = "payto", tag[1] = type, tag[2] = authority + const paytoTags = event.tags.filter(tag => tag[0] === 'payto' && tag[1] && tag[2]) + + // Build methods array from tags + const methods: TPaymentInfo['methods'] = [] + + // Parse each payto tag according to NIP-A3 spec + paytoTags.forEach((tag) => { + const type = tag[1]?.toLowerCase() || 'lightning' // Normalize to lowercase per spec + const authority = tag[2] || '' + const extra = tag.slice(3) // Optional extra fields + + // Build payto URI: payto:/// + const paytoUri = `payto://${type}/${authority}` + + const method: any = { + type, + authority, + payto: paytoUri, + // Map common types to display names + displayType: type === 'lightning' ? 'Lightning Network' : + type === 'bitcoin' ? 'Bitcoin' : + type === 'ethereum' ? 'Ethereum' : + type === 'monero' ? 'Monero' : + type === 'nano' ? 'Nano' : + type === 'cashme' ? 'Cash App' : + type === 'revolut' ? 'Revolut' : + type === 'venmo' ? 'Venmo' : + type.charAt(0).toUpperCase() + type.slice(1), + ...(extra.length > 0 && { extra }) } + methods.push(method) + }) + + // If we have methods in JSON but no tags, use JSON methods + if (methods.length === 0 && paymentInfo.methods && Array.isArray(paymentInfo.methods)) { + methods.push(...paymentInfo.methods.map((m: any) => ({ + ...m, + payto: m.payto || (m.type && m.authority ? `payto://${m.type}/${m.authority}` : undefined) + }))) } + + // If we have payto at root level in JSON but no methods array + if (methods.length === 0 && paymentInfo.payto) { + methods.push({ + payto: paymentInfo.payto, + type: paymentInfo.type || 'lightning', + authority: paymentInfo.authority, + displayType: paymentInfo.type === 'lightning' ? 'Lightning Network' : paymentInfo.type || 'Payment' + }) + } + + // Build result + const result: TPaymentInfo = { + ...paymentInfo, + methods: methods.length > 0 ? methods : undefined + } + + logger.debug('Parsed payment info', { + hasMethods: !!result.methods, + methodsCount: result.methods?.length || 0, + paytoTagsCount: paytoTags.length, + content: event.content?.substring(0, 200) + }) + + return result } export function getRelaySetFromEvent(event: Event, blockedRelays?: string[]): TRelaySet { diff --git a/src/types/index.d.ts b/src/types/index.d.ts index a73fb97..6591f98 100644 --- a/src/types/index.d.ts +++ b/src/types/index.d.ts @@ -16,13 +16,36 @@ export type TProfile = { banner?: string avatar?: string nip05?: string + nip05List?: string[] about?: string website?: string + websiteList?: string[] lud06?: string lud16?: string lightningAddress?: string + lightningAddressList?: string[] created_at?: number } + +export type TPaymentInfo = { + methods?: Array<{ + type: string // Payment type (e.g., "bitcoin", "lightning", "ethereum") + authority?: string // Payment authority/address (from NIP-A3 tag[2]) + payto?: string // Full payto:// URI + displayType?: string // Human-readable type name + address?: string // Legacy field, use authority instead + currency?: string + minAmount?: number + maxAmount?: number + extra?: string[] // Optional extra fields from NIP-A3 + [key: string]: any + }> + payto?: string // Root-level payto (legacy) + type?: string // Root-level type (legacy) + authority?: string // Root-level authority (legacy) + currency?: string // Root-level currency (legacy) + [key: string]: any +} export type TMailboxRelayScope = 'read' | 'write' | 'both' export type TMailboxRelay = { url: string