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:
parent
4c144c3da1
commit
071c740145
5 changed files with 131 additions and 2 deletions
|
|
@ -14,11 +14,12 @@ import { SecondaryPageLink, useSecondaryPage } from '@/PageManager'
|
||||||
import { useMuteList } from '@/providers/MuteListProvider'
|
import { useMuteList } from '@/providers/MuteListProvider'
|
||||||
import { useNostr } from '@/providers/NostrProvider'
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
import client from '@/services/client.service'
|
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 { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import NotFound from '../NotFound'
|
import NotFound from '../NotFound'
|
||||||
import SearchInput from '../SearchInput'
|
import SearchInput from '../SearchInput'
|
||||||
|
import SpQrCode from '../SpQrCode'
|
||||||
import TextWithEmojis from '../TextWithEmojis'
|
import TextWithEmojis from '../TextWithEmojis'
|
||||||
import TrustScoreBadge from '../TrustScoreBadge'
|
import TrustScoreBadge from '../TrustScoreBadge'
|
||||||
import AvatarWithLightbox from './AvatarWithLightbox'
|
import AvatarWithLightbox from './AvatarWithLightbox'
|
||||||
|
|
@ -112,7 +113,7 @@ export default function Profile({ id }: { id?: string }) {
|
||||||
}
|
}
|
||||||
if (!profile) return <NotFound />
|
if (!profile) return <NotFound />
|
||||||
|
|
||||||
const { banner, username, about, pubkey, website, lightningAddress, emojis } = profile
|
const { banner, username, about, pubkey, website, lightningAddress, sp, emojis } = profile
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div ref={topContainerRef}>
|
<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 className="w-0 max-w-fit flex-1 truncate">{lightningAddress}</div>
|
||||||
</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">
|
<div className="mt-1 flex gap-1">
|
||||||
<PubkeyCopy pubkey={pubkey} />
|
<PubkeyCopy pubkey={pubkey} />
|
||||||
<NpubQrCode 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
64
src/components/SpQrCode/index.tsx
Normal file
64
src/components/SpQrCode/index.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -74,6 +74,7 @@ export function getProfileFromEvent(event: Event) {
|
||||||
lud06: profileObj.lud06,
|
lud06: profileObj.lud06,
|
||||||
lud16: profileObj.lud16,
|
lud16: profileObj.lud16,
|
||||||
lightningAddress: getLightningAddressFromProfile(profileObj),
|
lightningAddress: getLightningAddressFromProfile(profileObj),
|
||||||
|
sp: profileObj.sp,
|
||||||
created_at: event.created_at,
|
created_at: event.created_at,
|
||||||
emojis: emojis.length > 0 ? emojis : undefined
|
emojis: emojis.length > 0 ? emojis : undefined
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,8 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
|
||||||
const [nip05Error, setNip05Error] = useState<string>('')
|
const [nip05Error, setNip05Error] = useState<string>('')
|
||||||
const [lightningAddress, setLightningAddress] = useState<string>('')
|
const [lightningAddress, setLightningAddress] = useState<string>('')
|
||||||
const [lightningAddressError, setLightningAddressError] = useState<string>('')
|
const [lightningAddressError, setLightningAddressError] = useState<string>('')
|
||||||
|
const [silentPaymentAddress, setSilentPaymentAddress] = useState<string>('')
|
||||||
|
const [silentPaymentAddressError, setSilentPaymentAddressError] = useState<string>('')
|
||||||
const [hasChanged, setHasChanged] = useState(false)
|
const [hasChanged, setHasChanged] = useState(false)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [uploadingBanner, setUploadingBanner] = useState(false)
|
const [uploadingBanner, setUploadingBanner] = useState(false)
|
||||||
|
|
@ -48,6 +50,7 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
|
||||||
setWebsite(profile.website ?? '')
|
setWebsite(profile.website ?? '')
|
||||||
setNip05(profile.nip05 ?? '')
|
setNip05(profile.nip05 ?? '')
|
||||||
setLightningAddress(profile.lightningAddress || '')
|
setLightningAddress(profile.lightningAddress || '')
|
||||||
|
setSilentPaymentAddress(profile.sp || '')
|
||||||
} else {
|
} else {
|
||||||
setBanner('')
|
setBanner('')
|
||||||
setAvatar('')
|
setAvatar('')
|
||||||
|
|
@ -56,6 +59,7 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
|
||||||
setWebsite('')
|
setWebsite('')
|
||||||
setNip05('')
|
setNip05('')
|
||||||
setLightningAddress('')
|
setLightningAddress('')
|
||||||
|
setSilentPaymentAddress('')
|
||||||
}
|
}
|
||||||
}, [profile])
|
}, [profile])
|
||||||
|
|
||||||
|
|
@ -93,6 +97,17 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
|
||||||
delete newProfileContent.lud16
|
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)
|
setSaving(true)
|
||||||
setHasChanged(false)
|
setHasChanged(false)
|
||||||
const profileDraftEvent = createProfileDraftEvent(
|
const profileDraftEvent = createProfileDraftEvent(
|
||||||
|
|
@ -228,6 +243,25 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
|
||||||
<div className="pl-3 text-xs text-destructive">{lightningAddressError}</div>
|
<div className="pl-3 text-xs text-destructive">{lightningAddressError}</div>
|
||||||
)}
|
)}
|
||||||
</Item>
|
</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>
|
</div>
|
||||||
</SecondaryPageLayout>
|
</SecondaryPageLayout>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
1
src/types/index.d.ts
vendored
1
src/types/index.d.ts
vendored
|
|
@ -27,6 +27,7 @@ export type TProfile = {
|
||||||
lud06?: string
|
lud06?: string
|
||||||
lud16?: string
|
lud16?: string
|
||||||
lightningAddress?: string
|
lightningAddress?: string
|
||||||
|
sp?: string
|
||||||
created_at?: number
|
created_at?: number
|
||||||
emojis?: TEmoji[]
|
emojis?: TEmoji[]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue