feat: add Silent Payment (BIP 352) support to kind 0 profiles (#758)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: BoltTouring <BoltTouring@users.noreply.github.com>
Co-authored-by: Tanjiro <claude@tricycle.cc>
This commit is contained in:
robos 2026-03-09 11:16:08 +09:00 committed by GitHub
parent 4c144c3da1
commit 071c740145
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 131 additions and 2 deletions

View file

@ -14,11 +14,12 @@ import { SecondaryPageLink, useSecondaryPage } from '@/PageManager'
import { useMuteList } from '@/providers/MuteListProvider'
import { useNostr } from '@/providers/NostrProvider'
import client from '@/services/client.service'
import { Link, Zap } from 'lucide-react'
import { Link, Zap, Bitcoin, Check, Copy } from 'lucide-react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import NotFound from '../NotFound'
import SearchInput from '../SearchInput'
import SpQrCode from '../SpQrCode'
import TextWithEmojis from '../TextWithEmojis'
import TrustScoreBadge from '../TrustScoreBadge'
import AvatarWithLightbox from './AvatarWithLightbox'
@ -112,7 +113,7 @@ export default function Profile({ id }: { id?: string }) {
}
if (!profile) return <NotFound />
const { banner, username, about, pubkey, website, lightningAddress, emojis } = profile
const { banner, username, about, pubkey, website, lightningAddress, sp, emojis } = profile
return (
<>
<div ref={topContainerRef}>
@ -160,6 +161,13 @@ export default function Profile({ id }: { id?: string }) {
<div className="w-0 max-w-fit flex-1 truncate">{lightningAddress}</div>
</div>
)}
{sp && (
<div className="flex select-text items-center gap-1 text-sm text-orange-500">
<Bitcoin className="size-4 shrink-0" />
<SpCopy sp={sp} />
<SpQrCode sp={sp} />
</div>
)}
<div className="mt-1 flex gap-1">
<PubkeyCopy pubkey={pubkey} />
<NpubQrCode pubkey={pubkey} />
@ -210,3 +218,24 @@ export default function Profile({ id }: { id?: string }) {
</>
)
}
function SpCopy({ sp }: { sp: string }) {
const [copied, setCopied] = useState(false)
const truncated = sp.length > 24 ? sp.slice(0, 12) + '...' + sp.slice(-6) : sp
const copy = () => {
navigator.clipboard.writeText(sp)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<div
className="clickable flex w-fit items-center gap-1 font-mono text-xs"
onClick={copy}
>
<div>{truncated}</div>
{copied ? <Check size={14} /> : <Copy size={14} />}
</div>
)
}

View file

@ -0,0 +1,64 @@
import { Dialog, DialogContent, DialogTrigger } from '@/components/ui/dialog'
import { Drawer, DrawerContent, DrawerTrigger } from '@/components/ui/drawer'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { Bitcoin, Check, Copy, QrCodeIcon } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import QrCode from '../QrCode'
export default function SpQrCode({ sp }: { sp: string }) {
const { t } = useTranslation()
const { isSmallScreen } = useScreenSize()
const [copied, setCopied] = useState(false)
if (!sp) return null
const truncated = sp.length > 24 ? sp.slice(0, 12) + '...' + sp.slice(-6) : sp
const copySp = () => {
navigator.clipboard.writeText(sp)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
const trigger = (
<div className="flex h-5 w-5 flex-col items-center justify-center rounded-full bg-muted text-muted-foreground hover:text-foreground">
<QrCodeIcon size={14} />
</div>
)
const content = (
<div className="flex w-full flex-col items-center gap-4 p-8">
<div className="flex items-center gap-2 text-orange-500">
<Bitcoin size={24} />
<div className="text-lg font-semibold">{t('Silent Payment')}</div>
</div>
<QrCode size={512} value={sp} />
<div
className="clickable flex w-fit items-center gap-2 rounded-full bg-muted px-2 text-sm text-muted-foreground"
onClick={copySp}
>
<div className="font-mono text-xs">{truncated}</div>
{copied ? <Check size={14} /> : <Copy size={14} />}
</div>
</div>
)
if (isSmallScreen) {
return (
<Drawer>
<DrawerTrigger>{trigger}</DrawerTrigger>
<DrawerContent>{content}</DrawerContent>
</Drawer>
)
}
return (
<Dialog>
<DialogTrigger>{trigger}</DialogTrigger>
<DialogContent className="m-0 w-80 p-0" onOpenAutoFocus={(e) => e.preventDefault()}>
{content}
</DialogContent>
</Dialog>
)
}

View file

@ -74,6 +74,7 @@ export function getProfileFromEvent(event: Event) {
lud06: profileObj.lud06,
lud16: profileObj.lud16,
lightningAddress: getLightningAddressFromProfile(profileObj),
sp: profileObj.sp,
created_at: event.created_at,
emojis: emojis.length > 0 ? emojis : undefined
}

View file

@ -30,6 +30,8 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
const [nip05Error, setNip05Error] = useState<string>('')
const [lightningAddress, setLightningAddress] = useState<string>('')
const [lightningAddressError, setLightningAddressError] = useState<string>('')
const [silentPaymentAddress, setSilentPaymentAddress] = useState<string>('')
const [silentPaymentAddressError, setSilentPaymentAddressError] = useState<string>('')
const [hasChanged, setHasChanged] = useState(false)
const [saving, setSaving] = useState(false)
const [uploadingBanner, setUploadingBanner] = useState(false)
@ -48,6 +50,7 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
setWebsite(profile.website ?? '')
setNip05(profile.nip05 ?? '')
setLightningAddress(profile.lightningAddress || '')
setSilentPaymentAddress(profile.sp || '')
} else {
setBanner('')
setAvatar('')
@ -56,6 +59,7 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
setWebsite('')
setNip05('')
setLightningAddress('')
setSilentPaymentAddress('')
}
}, [profile])
@ -93,6 +97,17 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
delete newProfileContent.lud16
}
if (silentPaymentAddress) {
if (silentPaymentAddress.startsWith('sp1')) {
newProfileContent.sp = silentPaymentAddress
} else {
setSilentPaymentAddressError(t('Silent Payment address must start with sp1'))
return
}
} else {
delete newProfileContent.sp
}
setSaving(true)
setHasChanged(false)
const profileDraftEvent = createProfileDraftEvent(
@ -228,6 +243,25 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
<div className="pl-3 text-xs text-destructive">{lightningAddressError}</div>
)}
</Item>
<Item>
<Label htmlFor="profile-sp-address-input">
{t('Silent Payment Address (BIP 352)')}
</Label>
<Input
id="profile-sp-address-input"
value={silentPaymentAddress}
placeholder="sp1qq..."
onChange={(e) => {
setSilentPaymentAddressError('')
setSilentPaymentAddress(e.target.value)
setHasChanged(true)
}}
className={silentPaymentAddressError ? 'border-destructive' : ''}
/>
{silentPaymentAddressError && (
<div className="pl-3 text-xs text-destructive">{silentPaymentAddressError}</div>
)}
</Item>
</div>
</SecondaryPageLayout>
)

View file

@ -27,6 +27,7 @@ export type TProfile = {
lud06?: string
lud16?: string
lightningAddress?: string
sp?: string
created_at?: number
emojis?: TEmoji[]
}