Browse Source

support multiple profile tags and payto data

imwald
Silberengel 3 months ago
parent
commit
74d7a872d2
  1. 125
      src/components/Nip05List/index.tsx
  2. 133
      src/components/Profile/index.tsx
  3. 3
      src/constants.ts
  4. 147
      src/lib/event-metadata.ts
  5. 23
      src/types/index.d.ts

125
src/components/Nip05List/index.tsx

@ -0,0 +1,125 @@ @@ -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<Map<string, Nip05Verification>>(new Map())
useEffect(() => {
if (!nip05List || nip05List.length === 0 || !pubkey) return
const verifyAll = async () => {
const newVerifications = new Map<string, Nip05Verification>()
// 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 (
<div className="text-sm text-muted-foreground flex flex-col gap-1 mt-1">
{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 (
<div key={idx} className="flex items-center gap-1">
<Skeleton className="h-3 w-32" />
</div>
)
}
return (
<div
key={idx}
className="flex items-center gap-1 truncate [&_svg]:!size-3.5 [&_svg]:shrink-0"
onClick={(e) => e.stopPropagation()}
>
{nip05Name !== '_' ? (
<span className="text-sm text-muted-foreground truncate">@{nip05Name}</span>
) : null}
{isVerified ? (
<Favicon
domain={nip05Domain}
className="w-3.5 h-3.5 rounded-full"
fallback={<BadgeCheck className="text-primary" />}
/>
) : (
<BadgeAlert className="text-muted-foreground" />
)}
<SecondaryPageLink
to={toNoteList({ domain: nip05Domain })}
className={`hover:underline truncate text-sm ${isVerified ? 'text-primary' : 'text-muted-foreground'}`}
>
{nip05Domain}
</SecondaryPageLink>
</div>
)
})}
</div>
)
}

133
src/components/Profile/index.tsx

@ -1,6 +1,7 @@ @@ -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 { @@ -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 }) { @@ -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<ReturnType<typeof getPaymentInfoFromEvent> | 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<ProfileTabValue>('posts')
const [searchQuery, setSearchQuery] = useState('')
const [articleKindFilter, setArticleKindFilter] = useState<string>('all')
@ -263,7 +295,7 @@ export default function Profile({ id }: { id?: string }) { @@ -263,7 +295,7 @@ export default function Profile({ id }: { id?: string }) {
}
if (!profile) return <NotFound />
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 }) { @@ -321,12 +353,27 @@ export default function Profile({ id }: { id?: string }) {
)}
</div>
<Nip05 pubkey={pubkey} />
{/* Display multiple NIP-05 values if available, with verification */}
{nip05List && nip05List.length > 1 && (
<Nip05List nip05List={nip05List.slice(1)} pubkey={pubkey} />
)}
{/* Display lightning addresses - show first one prominently, others below */}
{lightningAddress && (
<div className="text-sm text-yellow-400 flex gap-1 items-center select-text">
<Zap className="size-4 shrink-0" />
<div className="flex-1 max-w-fit w-0 truncate">{lightningAddress}</div>
</div>
)}
{lightningAddressList && lightningAddressList.length > 1 && (
<div className="text-sm text-yellow-400/70 flex flex-wrap gap-2 mt-1">
{lightningAddressList.slice(1).map((addr, idx) => (
<div key={idx} className="flex gap-1 items-center select-text">
<Zap className="size-3 shrink-0" />
<span className="truncate">{addr}</span>
</div>
))}
</div>
)}
<div className="flex gap-1 mt-1">
<PubkeyCopy pubkey={pubkey} />
<NpubQrCode pubkey={pubkey} />
@ -337,18 +384,100 @@ export default function Profile({ id }: { id?: string }) { @@ -337,18 +384,100 @@ export default function Profile({ id }: { id?: string }) {
className="text-wrap break-words whitespace-pre-wrap mt-2 select-text"
/>
</Collapsible>
{/* Display websites - show first one prominently, others below */}
{website && (
<div className="flex gap-1 items-center text-primary mt-2 truncate select-text">
<Link size={14} className="shrink-0" />
<a
href={website}
target="_blank"
rel="noopener noreferrer"
className="hover:underline truncate flex-1 max-w-fit w-0"
>
{website}
</a>
</div>
)}
{websiteList && websiteList.length > 1 && (
<div className="flex flex-col gap-1 mt-1">
{websiteList.slice(1).map((url, idx) => (
<div key={idx} className="flex gap-1 items-center text-primary truncate select-text">
<Link size={12} className="shrink-0" />
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="hover:underline truncate text-sm"
>
{url}
</a>
</div>
))}
</div>
)}
{/* Display payment info from kind 10133 */}
{paymentInfo && ((paymentInfo.methods && paymentInfo.methods.length > 0) || paymentInfo.payto) && (
<div className="mt-2 p-2 border rounded-lg bg-muted/50">
<div className="text-xs font-semibold text-muted-foreground mb-2">Payment Methods</div>
<div className="space-y-2">
{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 (
<div key={idx} className="text-sm">
<div className="font-medium">{displayType}</div>
{authority && (
<div className="text-muted-foreground mt-1 flex items-center gap-2">
{method.type === 'lightning' && <Zap className="size-3 text-yellow-400" />}
<span className="select-text">{authority}</span>
</div>
)}
{paytoUri && (
<a
href={paytoUri}
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground text-xs mt-1 hover:underline"
onClick={(e) => e.stopPropagation()}
>
{paytoUri}
</a>
)}
{(method.currency || (method.minAmount !== undefined && method.maxAmount !== undefined)) && (
<div className="text-muted-foreground text-xs mt-1">
{method.currency && <span>({method.currency})</span>}
{method.minAmount !== undefined && method.maxAmount !== undefined && (
<span className="ml-2">
{method.minAmount}-{method.maxAmount}
</span>
)}
</div>
)}
</div>
)
})
) : (
// Display payto from root level if methods array is empty
paymentInfo.payto && (
<div className="text-sm">
<div className="font-medium">Lightning Network</div>
<div className="text-muted-foreground mt-1 flex items-center gap-2">
<Zap className="size-3 text-yellow-400" />
<span>{paymentInfo.payto}</span>
</div>
{paymentInfo.currency && (
<div className="text-muted-foreground text-xs mt-1">({paymentInfo.currency})</div>
)}
</div>
)
)}
</div>
</div>
)}
<div className="flex justify-between items-center mt-2 text-sm">
<div className="flex gap-4 items-center">
<SmartFollowings pubkey={pubkey} />

3
src/constants.ts

@ -162,7 +162,8 @@ export const ExtendedKind = { @@ -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 = [

147
src/lib/event-metadata.ts

@ -1,5 +1,5 @@ @@ -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,12 +58,55 @@ export function getRelayListFromEvent(event?: Event | null, blockedRelays?: stri @@ -58,12 +58,55 @@ 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)
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() ||
profileObj.nip05?.split('@')[0]?.trim()
nip05?.split('@')[0]?.trim()
return {
pubkey: event.pubkey,
npub: pubkeyToNpub(event.pubkey) ?? '',
@ -71,22 +114,100 @@ export function getProfileFromEvent(event: Event) { @@ -71,22 +114,100 @@ export function getProfileFromEvent(event: Event) {
avatar: profileObj.picture,
username: username || formatPubkey(event.pubkey),
original_username: username,
nip05: profileObj.nip05,
nip05,
nip05List: nip05List && nip05List.length > 0 ? nip05List : undefined,
about: profileObj.about,
website: profileObj.website ? normalizeHttpUrl(profileObj.website) : undefined,
lud06: profileObj.lud06,
lud16: profileObj.lud16,
lightningAddress: getLightningAddressFromProfile(profileObj),
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", "<type>", "<authority>", "<optional_extra_1>", ...]
// 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://<type>/<authority>
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 {

23
src/types/index.d.ts vendored

@ -16,13 +16,36 @@ export type TProfile = { @@ -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

Loading…
Cancel
Save