Browse Source

update zap modal

imwald
Silberengel 4 weeks ago
parent
commit
6b94d2147c
  1. 4
      src/App.tsx
  2. 14
      src/PageManager.tsx
  3. 7
      src/assets/payto_logos/Bitcoin.svg
  4. 1
      src/components/ErrorBoundary.tsx
  5. 44
      src/components/HelpAndAccountMenu.tsx
  6. 40
      src/components/PaymentMethodsSection/index.tsx
  7. 4
      src/components/PaytoLink/index.tsx
  8. 52
      src/components/Profile/index.tsx
  9. 95
      src/components/ZapDialog/TipPublicMessagePrompt.tsx
  10. 115
      src/components/ZapDialog/index.tsx
  11. 6
      src/contexts/cache-browser-context.tsx
  12. 15
      src/data/payto-types.json
  13. 27
      src/hooks/useFetchProfile.tsx
  14. 31
      src/hooks/useRecipientAlternativePayments.ts
  15. 3
      src/i18n/locales/de.ts
  16. 3
      src/i18n/locales/en.ts
  17. 59
      src/lib/merge-payment-methods.test.ts
  18. 146
      src/lib/merge-payment-methods.ts
  19. 14
      src/lib/payto-editor-hints.test.ts
  20. 4
      src/lib/payto-registry.ts
  21. 17
      src/pages/secondary/ProfileEditorPage/index.tsx
  22. 19
      src/services/lightning.service.ts

4
src/App.tsx

@ -39,6 +39,7 @@ export default function App(): JSX.Element { @@ -39,6 +39,7 @@ export default function App(): JSX.Element {
<ScreenSizeProvider>
<DeletedEventProvider>
<NostrProvider>
<CacheBrowserProvider>
<div className="flex min-h-[100dvh] flex-col">
<VersionUpdateBanner />
<StartupSessionBanner />
@ -59,9 +60,7 @@ export default function App(): JSX.Element { @@ -59,9 +60,7 @@ export default function App(): JSX.Element {
<KindFilterProvider>
<UserPreferencesProvider>
<LiveActivitiesProvider>
<CacheBrowserProvider>
<PageManager />
</CacheBrowserProvider>
</LiveActivitiesProvider>
<ReadAloudPlayerModal />
<PublishSuccessSubtleIndicator />
@ -81,6 +80,7 @@ export default function App(): JSX.Element { @@ -81,6 +80,7 @@ export default function App(): JSX.Element {
</ZapProvider>
</div>
</div>
</CacheBrowserProvider>
</NostrProvider>
<Toaster />
</DeletedEventProvider>

14
src/PageManager.tsx

@ -1124,6 +1124,8 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -1124,6 +1124,8 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
const [secondaryStack, setSecondaryStack] = useState<TStackItem[]>([])
/** Latest stack for popstate / pop() — avoids stale length when history and React state race. */
const secondaryStackRef = useRef<TStackItem[]>([])
/** Suppress duplicate pushSecondaryPage calls (e.g. React Strict Mode) within a short window. */
const recentSecondaryPushRef = useRef<{ url: string; at: number } | null>(null)
useLayoutEffect(() => {
secondaryStackRef.current = secondaryStack
}, [secondaryStack])
@ -1963,6 +1965,18 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -1963,6 +1965,18 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
const pushSecondaryPage = (url: string, index?: number) => {
logger.component('PageManager', 'pushSecondaryPage called', { url })
const now = Date.now()
const recent = recentSecondaryPushRef.current
if (recent?.url === url && now - recent.at < 400) {
logger.component('PageManager', 'pushSecondaryPage skipped (recent duplicate)', { url })
return
}
if (isCurrentPage(secondaryStackRef.current, url)) {
logger.component('PageManager', 'pushSecondaryPage skipped (already on stack)', { url })
return
}
recentSecondaryPushRef.current = { url, at: now }
// Small screens render either the primary overlay OR the secondary stack — not both.
// Clear overlays (e.g. full-screen note) so pushes from Seen-on, settings deep links, etc. show the target page.
if (isSmallScreen && primaryNoteView) {

7
src/assets/payto_logos/Bitcoin.svg

@ -0,0 +1,7 @@ @@ -0,0 +1,7 @@
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://www.w3.org/2000/svg" height="64" width="64" version="1.1" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/">
<g transform="translate(0.00630876,-0.00301984)">
<path fill="#f7931a" d="m63.033,39.744c-4.274,17.143-21.637,27.576-38.782,23.301-17.138-4.274-27.571-21.638-23.295-38.78,4.272-17.145,21.635-27.579,38.775-23.305,17.144,4.274,27.576,21.64,23.302,38.784z"/>
<path fill="#FFF" d="m46.103,27.444c0.637-4.258-2.605-6.547-7.038-8.074l1.438-5.768-3.511-0.875-1.4,5.616c-0.923-0.23-1.871-0.447-2.813-0.662l1.41-5.653-3.509-0.875-1.439,5.766c-0.764-0.174-1.514-0.346-2.242-0.527l0.004-0.018-4.842-1.209-0.934,3.75s2.605,0.597,2.55,0.634c1.422,0.355,1.679,1.296,1.636,2.042l-1.638,6.571c0.098,0.025,0.225,0.061,0.365,0.117-0.117-0.029-0.242-0.061-0.371-0.092l-2.296,9.205c-0.174,0.432-0.615,1.08-1.609,0.834,0.035,0.051-2.552-0.637-2.552-0.637l-1.743,4.019,4.569,1.139c0.85,0.213,1.683,0.436,2.503,0.646l-1.453,5.834,3.507,0.875,1.439-5.772c0.958,0.26,1.888,0.5,2.798,0.726l-1.434,5.745,3.511,0.875,1.453-5.823c5.987,1.133,10.489,0.676,12.384-4.739,1.527-4.36-0.076-6.875-3.226-8.515,2.294-0.529,4.022-2.038,4.483-5.155zm-8.022,11.249c-1.085,4.36-8.426,2.003-10.806,1.412l1.928-7.729c2.38,0.594,10.012,1.77,8.878,6.317zm1.086-11.312c-0.99,3.966-7.1,1.951-9.082,1.457l1.748-7.01c1.982,0.494,8.365,1.416,7.334,5.553z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

1
src/components/ErrorBoundary.tsx

@ -18,6 +18,7 @@ function isLikelyBrokenReactContextFromHmr(message: string): boolean { @@ -18,6 +18,7 @@ function isLikelyBrokenReactContextFromHmr(message: string): boolean {
message.includes('useNostr must be used within') ||
message.includes('useContentPolicy must be used within') ||
message.includes('useInterestList must be used within') ||
message.includes('useCacheBrowser must be used within') ||
(message.includes('useContext') && message.includes('null'))
)
}

44
src/components/HelpAndAccountMenu.tsx

@ -14,7 +14,7 @@ import { Skeleton } from '@/components/ui/skeleton' @@ -14,7 +14,7 @@ import { Skeleton } from '@/components/ui/skeleton'
import { formatPubkey, formatNpub, generateImageByPubkey, pubkeyToNpub } from '@/lib/pubkey'
import { isVideo } from '@/lib/url'
import { cn } from '@/lib/utils'
import { useCacheBrowser } from '../contexts/cache-browser-context'
import { useCacheBrowserOptional } from '../contexts/cache-browser-context'
import { usePrimaryPage } from '@/contexts/primary-page-context'
import { useFetchProfile } from '@/hooks/useFetchProfile'
import { useNostr } from '@/providers/NostrProvider'
@ -26,14 +26,15 @@ export type HelpAndAccountMenuVariant = 'sidebar' | 'titlebar' @@ -26,14 +26,15 @@ export type HelpAndAccountMenuVariant = 'sidebar' | 'titlebar'
function AccountDropdownItems({
onSwitchAccount,
onLogoutClick
onLogoutClick,
onBrowseCache
}: {
onSwitchAccount: () => void
onLogoutClick: () => void
onBrowseCache?: () => void
}) {
const { t } = useTranslation()
const { navigate } = usePrimaryPage()
const { openBrowseCache } = useCacheBrowser()
return (
<>
@ -45,14 +46,12 @@ function AccountDropdownItems({ @@ -45,14 +46,12 @@ function AccountDropdownItems({
<Settings className="size-4" />
{t('Settings')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
openBrowseCache()
}}
>
<Database className="size-4" />
{t('Browse Cache')}
</DropdownMenuItem>
{onBrowseCache ? (
<DropdownMenuItem onClick={onBrowseCache}>
<Database className="size-4" />
{t('Browse Cache')}
</DropdownMenuItem>
) : null}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onSwitchAccount}>
<ArrowDownUp className="size-4" />
@ -68,10 +67,12 @@ function AccountDropdownItems({ @@ -68,10 +67,12 @@ function AccountDropdownItems({
function SidebarAccountMenu({
onSwitchAccount,
onLogoutClick
onLogoutClick,
onBrowseCache
}: {
onSwitchAccount: () => void
onLogoutClick: () => void
onBrowseCache?: () => void
}) {
const { t } = useTranslation()
const { account, profile } = useNostr()
@ -118,7 +119,11 @@ function SidebarAccountMenu({ @@ -118,7 +119,11 @@ function SidebarAccountMenu({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="end" className="z-[220]">
<AccountDropdownItems onSwitchAccount={onSwitchAccount} onLogoutClick={onLogoutClick} />
<AccountDropdownItems
onSwitchAccount={onSwitchAccount}
onLogoutClick={onLogoutClick}
onBrowseCache={onBrowseCache}
/>
</DropdownMenuContent>
</DropdownMenu>
)
@ -126,10 +131,12 @@ function SidebarAccountMenu({ @@ -126,10 +131,12 @@ function SidebarAccountMenu({
function TitlebarAccountMenu({
onSwitchAccount,
onLogoutClick
onLogoutClick,
onBrowseCache
}: {
onSwitchAccount: () => void
onLogoutClick: () => void
onBrowseCache?: () => void
}) {
const { t } = useTranslation()
const { account, profile } = useNostr()
@ -172,7 +179,11 @@ function TitlebarAccountMenu({ @@ -172,7 +179,11 @@ function TitlebarAccountMenu({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="bottom" className="z-[220]">
<AccountDropdownItems onSwitchAccount={onSwitchAccount} onLogoutClick={onLogoutClick} />
<AccountDropdownItems
onSwitchAccount={onSwitchAccount}
onLogoutClick={onLogoutClick}
onBrowseCache={onBrowseCache}
/>
</DropdownMenuContent>
</DropdownMenu>
)
@ -182,6 +193,7 @@ function TitlebarAccountMenu({ @@ -182,6 +193,7 @@ function TitlebarAccountMenu({
export default function HelpAndAccountMenu({ variant }: { variant: HelpAndAccountMenuVariant }) {
const { t } = useTranslation()
const { pubkey, checkLogin } = useNostr()
const onBrowseCache = useCacheBrowserOptional()?.openBrowseCache
const [loginDialogOpen, setLoginDialogOpen] = useState(false)
const [logoutDialogOpen, setLogoutDialogOpen] = useState(false)
@ -192,11 +204,13 @@ export default function HelpAndAccountMenu({ variant }: { variant: HelpAndAccoun @@ -192,11 +204,13 @@ export default function HelpAndAccountMenu({ variant }: { variant: HelpAndAccoun
<SidebarAccountMenu
onSwitchAccount={() => setLoginDialogOpen(true)}
onLogoutClick={() => setLogoutDialogOpen(true)}
onBrowseCache={onBrowseCache}
/>
) : (
<TitlebarAccountMenu
onSwitchAccount={() => setLoginDialogOpen(true)}
onLogoutClick={() => setLogoutDialogOpen(true)}
onBrowseCache={onBrowseCache}
/>
)
} else if (variant === 'sidebar') {

40
src/components/PaymentMethodsSection/index.tsx

@ -1,5 +1,7 @@ @@ -1,5 +1,7 @@
import PaytoLink from '@/components/PaytoLink'
import type { PaymentMethodGroup } from '@/lib/merge-payment-methods'
import { isLightningPaytoType } from '@/lib/payto'
import { cn } from '@/lib/utils'
import { Copy } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
@ -9,14 +11,17 @@ export default function PaymentMethodsSection({ @@ -9,14 +11,17 @@ export default function PaymentMethodsSection({
recipientPubkey,
onOpenZap,
title,
className
className,
headerHelpText
}: {
groups: PaymentMethodGroup[]
recipientPubkey?: string
/** When set, lightning rows can open the zap flow for this profile. */
onOpenZap?: () => void
/** When set, lightning rows open the zap flow with that address as the default. */
onOpenZap?: (lightningAuthority: string) => void
title?: string
className?: string
/** Prominent note above the list (e.g. on-chain Bitcoin eligibility in zap dialog). */
headerHelpText?: string
}) {
const { t } = useTranslation()
@ -27,10 +32,27 @@ export default function PaymentMethodsSection({ @@ -27,10 +32,27 @@ export default function PaymentMethodsSection({
<div className="text-xs font-semibold text-muted-foreground mb-2">
{title ?? t('Payment Methods')}
</div>
{headerHelpText ? (
<p
className="mb-3 rounded-md border border-amber-500/45 bg-amber-500/15 px-3 py-2.5 text-sm font-semibold leading-snug text-foreground"
role="note"
>
{headerHelpText}
</p>
) : null}
<div className="space-y-3 min-w-0">
{groups.map((group, groupIdx) => (
<div key={groupIdx} className="text-sm min-w-0">
<div className="font-medium">{group.displayType}</div>
<div
key={groupIdx}
className={cn(
'text-sm min-w-0',
group.highlighted &&
'rounded-md border border-amber-500/50 bg-amber-500/10 px-2.5 py-2'
)}
>
<div className={cn('font-medium', group.highlighted && 'text-foreground')}>
{group.displayType}
</div>
<div className="space-y-1.5 mt-1">
{group.methods.map((method, idx) => (
<div key={idx} className="min-w-0">
@ -40,8 +62,12 @@ export default function PaymentMethodsSection({ @@ -40,8 +62,12 @@ export default function PaymentMethodsSection({
type={method.type}
authority={method.authority}
paytoUri={method.payto}
pubkey={method.type === 'lightning' ? recipientPubkey : undefined}
onOpenZap={method.type === 'lightning' ? onOpenZap : undefined}
pubkey={isLightningPaytoType(method.type) ? recipientPubkey : undefined}
onOpenZap={
isLightningPaytoType(method.type) && onOpenZap
? (_pk, authority) => onOpenZap(authority)
: undefined
}
className="hover:underline break-all min-w-0 text-primary flex-1"
>
{method.authority}

4
src/components/PaytoLink/index.tsx

@ -32,7 +32,7 @@ export default function PaytoLink({ @@ -32,7 +32,7 @@ export default function PaytoLink({
authority?: string
/** When set with lightning type, clicking can open Zap dialog via onOpenZap */
pubkey?: string
onOpenZap?: (pubkey: string) => void
onOpenZap?: (pubkey: string, lightningAuthority: string) => void
className?: string
children?: React.ReactNode
linkTitle?: string
@ -64,7 +64,7 @@ export default function PaytoLink({ @@ -64,7 +64,7 @@ export default function PaytoLink({
e.preventDefault()
e.stopPropagation()
if (canZap) {
onOpenZap(pubkey!)
onOpenZap(pubkey!, authority)
return
}
if (!known) {

52
src/components/Profile/index.tsx

@ -86,11 +86,11 @@ import { FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS } from '@/constants' @@ -86,11 +86,11 @@ import { FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS } from '@/constants'
import { nip66Service } from '@/services/nip66.service'
import PaymentMethodsSection from '@/components/PaymentMethodsSection'
import {
getAlternativePaymentMethods,
groupPaymentMethodsByDisplayType,
mergePaymentMethods,
sortMergedPaymentMethods
} from '@/lib/merge-payment-methods'
import { isLightningPaytoType } from '@/lib/payto'
export default function Profile({
id,
@ -118,6 +118,7 @@ export default function Profile({ @@ -118,6 +118,7 @@ export default function Profile({
'posts' | 'media' | 'publications' | 'reports' | 'wall' | 'liked'
>('posts')
const profilePubkeyRef = useRef<string | null>(null)
const pendingReportsRefreshRef = useRef(false)
const { profile, isFetching } = useFetchProfile(id)
profilePubkeyRef.current = profile?.pubkey ?? null
@ -125,6 +126,7 @@ export default function Profile({ @@ -125,6 +126,7 @@ export default function Profile({
const [paymentInfo, setPaymentInfo] = useState<ReturnType<typeof getPaymentInfoFromEvent> | null>(null)
const [profileEvent, setProfileEvent] = useState<NostrEvent | undefined>(undefined)
const [openZapDialog, setOpenZapDialog] = useState(false)
const [zapLightningDefault, setZapLightningDefault] = useState<string | null>(null)
const [openPublicMessageTo, setOpenPublicMessageTo] = useState<string | null>(null)
const [openCallInviteTo, setOpenCallInviteTo] = useState<{ pubkey: string; url: string } | null>(null)
const [openScheduleOwnCall, setOpenScheduleOwnCall] = useState(false)
@ -145,13 +147,8 @@ export default function Profile({ @@ -145,13 +147,8 @@ export default function Profile({
[mergedPaymentMethods]
)
const alternativePaymentGroups = useMemo(() => {
const alts = getAlternativePaymentMethods(mergedPaymentMethods, profile?.lightningAddress)
return groupPaymentMethodsByDisplayType(alts)
}, [mergedPaymentMethods, profile?.lightningAddress])
const hasLightningForZap = useMemo(
() => paymentMethodsByType.some((g) => g.methods.some((m) => m.type === 'lightning')),
() => paymentMethodsByType.some((g) => g.methods.some((m) => isLightningPaytoType(m.type))),
[paymentMethodsByType]
)
@ -177,6 +174,11 @@ export default function Profile({ @@ -177,6 +174,11 @@ export default function Profile({
void syncAuthorReplaceablesFromCache(profile.pubkey)
}, [profile?.pubkey, syncAuthorReplaceablesFromCache])
const refreshAuthorReplaceables = useCallback(async (pubkey: string) => {
await client.forceRefreshProfileAndPaymentInfoCache(pubkey)
await syncAuthorReplaceablesFromCache(pubkey)
}, [syncAuthorReplaceablesFromCache])
useEffect(() => {
if (!profile?.pubkey) return
void client.refreshAuthorPublishedReplaceablesOnProfileView(profile.pubkey)
@ -276,19 +278,23 @@ export default function Profile({ @@ -276,19 +278,23 @@ export default function Profile({
postsFeedRef.current?.refresh()
mediaFeedRef.current?.refresh()
publicationsFeedRef.current?.refresh()
reportsFeedRef.current?.refresh()
wallFeedRef.current?.refresh()
likedFeedRef.current?.refresh()
const pk = profilePubkeyRef.current
if (reportsFeedRef.current) {
reportsFeedRef.current.refresh()
} else {
pendingReportsRefreshRef.current = true
}
if (pk) {
void client.refreshAuthorPublishedReplaceablesOnProfileView(pk)
void refreshAuthorReplaceables(pk)
}
}
}
return () => {
m.current = null
}
}, [])
}, [refreshAuthorReplaceables])
useEffect(() => {
if (!profile?.pubkey) return
@ -312,6 +318,9 @@ export default function Profile({ @@ -312,6 +318,9 @@ export default function Profile({
} else if (profileFeedTab === 'publications') {
publicationsFeedRef.current?.refresh()
} else if (profileFeedTab === 'reports') {
if (pendingReportsRefreshRef.current) {
pendingReportsRefreshRef.current = false
}
reportsFeedRef.current?.refresh()
} else if (profileFeedTab === 'wall') {
wallFeedRef.current?.refresh()
@ -514,7 +523,15 @@ export default function Profile({ @@ -514,7 +523,15 @@ export default function Profile({
{!isSelf ? (
<>
{hasLightningForZap && (
<ProfileZapButton pubkey={pubkey} openZapDialog={openZapDialog} setOpenZapDialog={setOpenZapDialog} />
<ProfileZapButton
pubkey={pubkey}
openZapDialog={openZapDialog}
setOpenZapDialog={(open) => {
if (open) setZapLightningDefault(null)
setOpenZapDialog(open)
if (!open) setZapLightningDefault(null)
}}
/>
)}
<FollowButton pubkey={pubkey} />
</>
@ -577,15 +594,22 @@ export default function Profile({ @@ -577,15 +594,22 @@ export default function Profile({
<PaymentMethodsSection
groups={paymentMethodsByType}
recipientPubkey={pubkey}
onOpenZap={() => setOpenZapDialog(true)}
onOpenZap={(lightningAuthority) => {
setZapLightningDefault(lightningAuthority)
setOpenZapDialog(true)
}}
className="mt-2 mb-4 p-3 pb-4 border rounded-lg bg-muted/50 min-w-0"
/>
)}
<ZapDialog
open={openZapDialog}
setOpen={setOpenZapDialog}
setOpen={(next) => {
const willOpen = typeof next === 'function' ? next(openZapDialog) : next
setOpenZapDialog(willOpen)
if (!willOpen) setZapLightningDefault(null)
}}
pubkey={pubkey}
alternativePaymentGroups={alternativePaymentGroups}
defaultLightningAddress={zapLightningDefault}
/>
<div className="flex flex-wrap justify-between items-center gap-x-4 gap-y-2 mt-2 text-sm min-w-0">
<div className="flex flex-wrap gap-4 items-center min-w-0">

95
src/components/ZapDialog/TipPublicMessagePrompt.tsx

@ -14,20 +14,30 @@ import { @@ -14,20 +14,30 @@ import {
DrawerHeader,
DrawerTitle
} from '@/components/ui/drawer'
import { Textarea } from '@/components/ui/textarea'
import { ExtendedKind } from '@/constants'
import { createPublicMessageDraftEvent } from '@/lib/draft-event'
import { createFakeEvent } from '@/lib/event'
import { showSimplePublishSuccess } from '@/lib/publishing-feedback'
import { LoginRequiredError } from '@/lib/nostr-errors'
import { pubkeyToNpub } from '@/lib/pubkey'
import { cn } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { LoginRequiredError } from '@/lib/nostr-errors'
import { useEffect, useRef, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import MarkdownArticle from '../Note/MarkdownArticle/MarkdownArticle'
import UserAvatar from '../UserAvatar'
import Username from '../Username'
const TIP_NOTICE_DEFAULT_KEY = 'I just sent you a tip!'
function defaultTipNoticeMessage(recipientPubkey: string, tipText: string): string {
const npub = pubkeyToNpub(recipientPubkey)
return `nostr:${npub} ${tipText}`
}
export default function TipPublicMessagePrompt({
open,
onOpenChange,
@ -41,22 +51,43 @@ export default function TipPublicMessagePrompt({ @@ -41,22 +51,43 @@ export default function TipPublicMessagePrompt({
const { isSmallScreen } = useScreenSize()
const { publish, checkLogin, pubkey: selfPubkey } = useNostr()
const [sending, setSending] = useState(false)
const [message, setMessage] = useState('')
const cancelRef = useRef<HTMLButtonElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const tipText = t(TIP_NOTICE_DEFAULT_KEY)
const npub = recipientPubkey ? pubkeyToNpub(recipientPubkey) : null
const previewContent = npub ? `nostr:${npub} ${tipText}` : tipText
useEffect(() => {
if (!open || !recipientPubkey) return
setMessage(defaultTipNoticeMessage(recipientPubkey, tipText))
}, [open, recipientPubkey, tipText])
useEffect(() => {
if (!open) return
const id = requestAnimationFrame(() => {
cancelRef.current?.focus()
textareaRef.current?.focus()
textareaRef.current?.setSelectionRange(
textareaRef.current.value.length,
textareaRef.current.value.length
)
})
return () => cancelAnimationFrame(id)
}, [open])
const previewEvent = useMemo(() => {
if (!recipientPubkey) return null
return createFakeEvent({
kind: ExtendedKind.PUBLIC_MESSAGE,
pubkey: selfPubkey ?? '',
content: message,
tags: [['p', recipientPubkey]]
})
}, [message, recipientPubkey, selfPubkey])
const handleSend = () => {
if (!recipientPubkey) return
const trimmed = message.trim()
if (!trimmed) return
checkLogin(async () => {
if (selfPubkey === recipientPubkey) {
onOpenChange(false)
@ -64,7 +95,7 @@ export default function TipPublicMessagePrompt({ @@ -64,7 +95,7 @@ export default function TipPublicMessagePrompt({
}
setSending(true)
try {
const draft = await createPublicMessageDraftEvent(previewContent, [recipientPubkey], {
const draft = await createPublicMessageDraftEvent(trimmed, [recipientPubkey], {
addClientTag: true
})
await publish(draft, { disableFallbacks: true })
@ -84,11 +115,31 @@ export default function TipPublicMessagePrompt({ @@ -84,11 +115,31 @@ export default function TipPublicMessagePrompt({
}
const body = (
<>
<div className="min-w-0">
<p className="text-sm font-medium text-foreground">{t('Tip notice success only note')}</p>
<p className="mt-2 text-sm text-muted-foreground">{t('Tip notice prompt description')}</p>
<p className="mt-3 rounded-md border border-border bg-muted/40 px-3 py-2 text-sm break-words">{previewContent}</p>
</>
<Textarea
ref={textareaRef}
value={message}
onChange={(e) => setMessage(e.target.value)}
disabled={sending}
rows={6}
className="mt-3 min-h-[10rem] resize-y text-sm leading-relaxed"
aria-label={t('Tip notice prompt description')}
/>
{previewEvent ? (
<div className="mt-4 min-w-0">
<p className="text-xs font-medium text-muted-foreground">{t('Preview')}</p>
<div
className={cn(
'mt-1.5 max-h-56 min-w-0 overflow-y-auto overflow-x-hidden rounded-md border border-border',
'bg-muted/25 px-3 py-2'
)}
>
<MarkdownArticle event={previewEvent} hideMetadata lazyMedia={false} className="text-sm" />
</div>
</div>
) : null}
</div>
)
const actions = (
@ -106,7 +157,7 @@ export default function TipPublicMessagePrompt({ @@ -106,7 +157,7 @@ export default function TipPublicMessagePrompt({
type="button"
variant="outline"
onClick={handleSend}
disabled={sending || !recipientPubkey}
disabled={sending || !recipientPubkey || !message.trim()}
>
{t('Send')}
</Button>
@ -118,12 +169,12 @@ export default function TipPublicMessagePrompt({ @@ -118,12 +169,12 @@ export default function TipPublicMessagePrompt({
if (isSmallScreen) {
return (
<Drawer open={open} onOpenChange={onOpenChange}>
<DrawerContent className="px-4 pb-6" onOpenAutoFocus={(e) => e.preventDefault()}>
<DrawerContent className="min-w-0 overflow-hidden px-4 pb-6" onOpenAutoFocus={(e) => e.preventDefault()}>
<DrawerHeader>
<DrawerTitle className="flex items-center gap-2">
{t('Tip notice prompt title')}
<UserAvatar size="small" userId={recipientPubkey} />
<Username userId={recipientPubkey} className="truncate" />
<DrawerTitle className="flex min-w-0 items-center gap-2">
<span className="shrink-0">{t('Tip notice prompt title')}</span>
<UserAvatar size="small" userId={recipientPubkey} className="shrink-0" />
<Username userId={recipientPubkey} className="min-w-0 flex-1 truncate" />
</DrawerTitle>
</DrawerHeader>
<div className="px-0 pb-4">{body}</div>
@ -136,14 +187,14 @@ export default function TipPublicMessagePrompt({ @@ -136,14 +187,14 @@ export default function TipPublicMessagePrompt({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="sm:max-w-md"
className="w-[calc(100vw-2rem)] max-w-lg min-w-0 overflow-hidden sm:max-w-lg"
onOpenAutoFocus={(e) => e.preventDefault()}
>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{t('Tip notice prompt title')}
<UserAvatar size="small" userId={recipientPubkey} />
<Username userId={recipientPubkey} className="truncate" />
<DialogHeader className="min-w-0">
<DialogTitle className="flex min-w-0 items-center gap-2">
<span className="shrink-0">{t('Tip notice prompt title')}</span>
<UserAvatar size="small" userId={recipientPubkey} className="shrink-0" />
<Username userId={recipientPubkey} className="min-w-0 flex-1 truncate" />
</DialogTitle>
<DialogDescription>{t('Tip notice prompt description')}</DialogDescription>
</DialogHeader>

115
src/components/ZapDialog/index.tsx

@ -26,9 +26,19 @@ import { NostrEvent } from 'nostr-tools' @@ -26,9 +26,19 @@ import { NostrEvent } from 'nostr-tools'
import { Dispatch, SetStateAction, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import type { PaymentMethodGroup } from '@/lib/merge-payment-methods'
import {
buildOrderedZapLightningAddresses,
prepareZapDialogAlternativePayments
} from '@/lib/merge-payment-methods'
import PaymentMethodsSection from '@/components/PaymentMethodsSection'
import { useRecipientAlternativePayments } from '@/hooks/useRecipientAlternativePayments'
import { useRecipientZapPaymentData } from '@/hooks/useRecipientAlternativePayments'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import TipPublicMessagePrompt from './TipPublicMessagePrompt'
import UserAvatar from '../UserAvatar'
import Username from '../Username'
@ -40,7 +50,7 @@ export default function ZapDialog({ @@ -40,7 +50,7 @@ export default function ZapDialog({
event,
defaultAmount,
defaultComment,
alternativePaymentGroups
defaultLightningAddress
}: {
open: boolean
setOpen: Dispatch<SetStateAction<boolean>>
@ -48,15 +58,13 @@ export default function ZapDialog({ @@ -48,15 +58,13 @@ export default function ZapDialog({
event?: NostrEvent
defaultAmount?: number
defaultComment?: string
/** Non-Lightning (and non-zap-duplicate) payto targets from kind 10133 / profile. */
alternativePaymentGroups?: PaymentMethodGroup[]
/** Lightning address to pre-select (e.g. from a profile payto link click). */
defaultLightningAddress?: string | null
}) {
const { t } = useTranslation()
const { isSmallScreen } = useScreenSize()
const drawerContentRef = useRef<HTMLDivElement | null>(null)
const { pubkey: selfPubkey } = useNostr()
const fetchedAlternativeGroups = useRecipientAlternativePayments(pubkey, open)
const effectiveAlternativeGroups = alternativePaymentGroups ?? fetchedAlternativeGroups
const [tipNoticeOpen, setTipNoticeOpen] = useState(false)
const skipTipNoticeOnCloseRef = useRef(false)
@ -131,7 +139,7 @@ export default function ZapDialog({ @@ -131,7 +139,7 @@ export default function ZapDialog({
event={event}
defaultAmount={defaultAmount}
defaultComment={defaultComment}
alternativePaymentGroups={effectiveAlternativeGroups}
defaultLightningAddress={defaultLightningAddress}
onBeforeZapDialogClose={(withPublicReceipt) => {
if (withPublicReceipt) skipTipNoticeOnCloseRef.current = true
}}
@ -165,7 +173,7 @@ export default function ZapDialog({ @@ -165,7 +173,7 @@ export default function ZapDialog({
event={event}
defaultAmount={defaultAmount}
defaultComment={defaultComment}
alternativePaymentGroups={effectiveAlternativeGroups}
defaultLightningAddress={defaultLightningAddress}
onBeforeZapDialogClose={(withPublicReceipt) => {
if (withPublicReceipt) skipTipNoticeOnCloseRef.current = true
}}
@ -182,12 +190,13 @@ export default function ZapDialog({ @@ -182,12 +190,13 @@ export default function ZapDialog({
}
function ZapDialogContent({
open,
setOpen,
recipient,
event,
defaultAmount,
defaultComment,
alternativePaymentGroups,
defaultLightningAddress,
onBeforeZapDialogClose
}: {
open: boolean
@ -196,7 +205,7 @@ function ZapDialogContent({ @@ -196,7 +205,7 @@ function ZapDialogContent({
event?: NostrEvent
defaultAmount?: number
defaultComment?: string
alternativePaymentGroups?: PaymentMethodGroup[]
defaultLightningAddress?: string | null
/** Runs before the zap dialog closes (e.g. after payment); skip tip notice if a public receipt was sent. */
onBeforeZapDialogClose?: (withPublicReceipt: boolean) => void
}) {
@ -207,6 +216,30 @@ function ZapDialogContent({ @@ -207,6 +216,30 @@ function ZapDialogContent({
const [sats, setSats] = useState(defaultAmount ?? defaultZapSats)
const [comment, setComment] = useState(defaultComment ?? defaultZapComment)
const [zapping, setZapping] = useState(false)
const [selectedLightning, setSelectedLightning] = useState('')
const { paymentInfo, profileEvent, alternativeGroups } = useRecipientZapPaymentData(recipient, open)
const lightningAddressOptions = useMemo(
() =>
buildOrderedZapLightningAddresses({
profileEvent,
paymentInfo,
preferredAddress: defaultLightningAddress
}),
[profileEvent, paymentInfo, defaultLightningAddress]
)
useEffect(() => {
if (!open) return
setSelectedLightning(lightningAddressOptions[0] ?? '')
}, [open, lightningAddressOptions])
const zapAlternativePayments = useMemo(
() => prepareZapDialogAlternativePayments(alternativeGroups, sats),
[alternativeGroups, sats]
)
const presetAmounts = useMemo(() => {
if (i18n.language.startsWith('zh')) {
return [
@ -257,7 +290,11 @@ function ZapDialogContent({ @@ -257,7 +290,11 @@ function ZapDialogContent({
sats,
comment,
closeZapDialog,
includePublicZapReceipt
includePublicZapReceipt,
{
address: selectedLightning || undefined,
candidates: lightningAddressOptions.length > 0 ? lightningAddressOptions : undefined
}
)
// user canceled
if (!zapResult) {
@ -340,21 +377,55 @@ function ZapDialogContent({ @@ -340,21 +377,55 @@ function ZapDialogContent({
/>
</div>
{lightningAddressOptions.length > 0 ? (
<div className="min-w-0 space-y-1.5">
<Label htmlFor="zap-lightning-address">{t('Lightning address for zap')}</Label>
<Select value={selectedLightning} onValueChange={setSelectedLightning}>
<SelectTrigger id="zap-lightning-address" className="min-w-0 gap-2">
<SelectValue placeholder={t('Select lightning address')}>
{selectedLightning ? (
<span className="flex min-w-0 items-center gap-2">
<span className="shrink-0 text-lg leading-none text-yellow-400" aria-hidden>
</span>
<span className="min-w-0 truncate">{selectedLightning}</span>
</span>
) : null}
</SelectValue>
</SelectTrigger>
<SelectContent>
{lightningAddressOptions.map((addr) => (
<SelectItem key={addr} value={addr} className="break-all">
<span className="flex items-start gap-2">
<span className="shrink-0 text-lg leading-none text-yellow-400" aria-hidden>
</span>
<span className="min-w-0 break-all">{addr}</span>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : null}
<Button onClick={handleZap} className="w-full">
{zapping && <Skeleton className="mr-2 inline-block size-4 shrink-0 rounded-full align-middle" aria-hidden />}{' '}
{t('Zap n sats', { n: sats })}
</Button>
{alternativePaymentGroups && alternativePaymentGroups.length > 0 ? (
<div>
<PaymentMethodsSection
groups={alternativePaymentGroups}
recipientPubkey={recipient}
title={t('Other payment methods')}
className="rounded-lg border border-border bg-muted/40 p-3 min-w-0"
/>
<p className="mt-2 text-xs text-muted-foreground">{t('Zap dialog other payment hint')}</p>
</div>
{zapAlternativePayments.groups.length > 0 ? (
<PaymentMethodsSection
groups={zapAlternativePayments.groups}
recipientPubkey={recipient}
title={t('Other payment methods')}
headerHelpText={
zapAlternativePayments.showBitcoinOnChainHint
? t('Tips above 10k sats can use Bitcoin on-chain.')
: undefined
}
className="rounded-lg border border-border bg-muted/40 p-3 min-w-0"
/>
) : null}
</div>
</div>

6
src/contexts/cache-browser-context.tsx

@ -20,8 +20,12 @@ export function CacheBrowserProvider({ children }: { children: ReactNode }) { @@ -20,8 +20,12 @@ export function CacheBrowserProvider({ children }: { children: ReactNode }) {
)
}
export function useCacheBrowserOptional(): CacheBrowserContextValue | undefined {
return useContext(CacheBrowserContext)
}
export function useCacheBrowser(): CacheBrowserContextValue {
const ctx = useContext(CacheBrowserContext)
const ctx = useCacheBrowserOptional()
if (!ctx) {
throw new Error('useCacheBrowser must be used within CacheBrowserProvider')
}

15
src/data/payto-types.json

@ -59,6 +59,7 @@ @@ -59,6 +59,7 @@
"label": "Bitcoin",
"symbol": "₿",
"category": "bitcoin",
"logoAssetPath": "src/assets/payto_logos/Bitcoin.svg",
"authority": {
"placeholder": "bc1q…",
"hint": "On-chain Bitcoin address (Bech32 bc1… preferred)"
@ -68,24 +69,26 @@ @@ -68,24 +69,26 @@
"label": "Bolt 12",
"symbol": "₿",
"category": "bitcoin",
"logoAssetPath": "src/assets/payto_logos/Bitcoin.svg",
"authority": {
"placeholder": "lno1…",
"hint": "BOLT-12 offer (static offer string, e.g. lno1…)"
}
},
"bip353": {
"label": "BIP-353",
"symbol": "",
"category": "bitcoin",
"label": "DNS Payment Instructions (BIP-353)",
"symbol": "",
"category": "bitcoin-layer",
"authority": {
"placeholder": "user@example.com",
"hint": "BIP-353 DNS payment address (human-readable name@domain)"
"placeholder": "user@example.com",
"hint": "BIP-353 DNS payment instructions (human-readable name@domain, resolves to Lightning)"
}
},
"bip352": {
"label": "BIP-352",
"label": "Silent Payments (BIP-352)",
"symbol": "₿",
"category": "bitcoin",
"logoAssetPath": "src/assets/payto_logos/Bitcoin.svg",
"authority": {
"placeholder": "sp1…",
"hint": "BIP-352 silent payment address (sp1…)"

27
src/hooks/useFetchProfile.tsx

@ -6,6 +6,7 @@ import { normalizeHexPubkey, userIdToPubkey } from '@/lib/pubkey' @@ -6,6 +6,7 @@ import { normalizeHexPubkey, userIdToPubkey } from '@/lib/pubkey'
import { useNostrOptional } from '@/providers/nostr-context'
import { useNoteFeedProfileContext } from '@/providers/NoteFeedProfileContext'
import { eventService, replaceableEventService } from '@/services/client.service'
import { ReplaceableEventService } from '@/services/client-replaceable-events.service'
import indexedDb from '@/services/indexed-db.service'
import { TProfile } from '@/types'
import { kinds } from 'nostr-tools'
@ -677,5 +678,31 @@ export function useFetchProfile(id?: string, skipCache = false) { @@ -677,5 +678,31 @@ export function useFetchProfile(id?: string, skipCache = false) {
effectRunCountRef.current.delete(targetPk)
}, [currentAccountProfile, id, profile])
const profileRefreshCancelledRef = useRef(false)
useEffect(() => {
profileRefreshCancelledRef.current = false
return () => {
profileRefreshCancelledRef.current = true
}
}, [pkLowerResolved])
useEffect(() => {
if (!pkLowerResolved) return
const onAuthorReplaceablesRefreshed: EventListener = (domEvt) => {
const detailPk = (domEvt as CustomEvent<{ pubkey?: string }>).detail?.pubkey?.toLowerCase()
if (detailPk !== pkLowerResolved) return
void checkProfile(pkLowerResolved, { current: profileRefreshCancelledRef.current })
}
window.addEventListener(
ReplaceableEventService.AUTHOR_REPLACEABLES_REFRESHED_EVENT,
onAuthorReplaceablesRefreshed
)
return () =>
window.removeEventListener(
ReplaceableEventService.AUTHOR_REPLACEABLES_REFRESHED_EVENT,
onAuthorReplaceablesRefreshed
)
}, [pkLowerResolved, checkProfile])
return { isFetching, error, profile }
}

31
src/hooks/useRecipientAlternativePayments.ts

@ -7,23 +7,32 @@ import { @@ -7,23 +7,32 @@ import {
} from '@/lib/merge-payment-methods'
import { getPaymentInfoFromEvent, getProfileFromEvent } from '@/lib/event-metadata'
import client, { replaceableEventService } from '@/services/client.service'
import { kinds } from 'nostr-tools'
import { kinds, type Event } from 'nostr-tools'
import { useEffect, useMemo, useState } from 'react'
import type { TPaymentInfo } from '@/types'
import type { TProfile } from '@/types'
export type RecipientZapPaymentData = {
paymentInfo: TPaymentInfo | null
profile: TProfile | null
profileEvent: Event | null
alternativeGroups: PaymentMethodGroup[]
}
/** Kind 10133 + profile payto targets except the Lightning address used for zapping. */
export function useRecipientAlternativePayments(
export function useRecipientZapPaymentData(
recipientPubkey: string | undefined,
enabled: boolean
): PaymentMethodGroup[] {
): RecipientZapPaymentData {
const [paymentInfo, setPaymentInfo] = useState<TPaymentInfo | null>(null)
const [profile, setProfile] = useState<TProfile | null>(null)
const [profileEvent, setProfileEvent] = useState<Event | null>(null)
useEffect(() => {
if (!enabled || !recipientPubkey) {
setPaymentInfo(null)
setProfile(null)
setProfileEvent(null)
return
}
let cancelled = false
@ -35,11 +44,13 @@ export function useRecipientAlternativePayments( @@ -35,11 +44,13 @@ export function useRecipientAlternativePayments(
])
if (cancelled) return
setPaymentInfo(paymentEvent ? getPaymentInfoFromEvent(paymentEvent) : null)
setProfileEvent(metaEvent ?? null)
setProfile(metaEvent ? getProfileFromEvent(metaEvent) : null)
} catch {
if (!cancelled) {
setPaymentInfo(null)
setProfile(null)
setProfileEvent(null)
}
}
})()
@ -48,10 +59,20 @@ export function useRecipientAlternativePayments( @@ -48,10 +59,20 @@ export function useRecipientAlternativePayments(
}
}, [recipientPubkey, enabled])
return useMemo(() => {
const alternativeGroups = useMemo(() => {
if (!recipientPubkey) return []
const merged = sortMergedPaymentMethods(mergePaymentMethods(paymentInfo, profile))
const alts = getAlternativePaymentMethods(merged, profile?.lightningAddress)
const alts = getAlternativePaymentMethods(merged)
return groupPaymentMethodsByDisplayType(alts)
}, [recipientPubkey, paymentInfo, profile])
return { paymentInfo, profile, profileEvent, alternativeGroups }
}
/** @deprecated Use {@link useRecipientZapPaymentData} */
export function useRecipientAlternativePayments(
recipientPubkey: string | undefined,
enabled: boolean
): PaymentMethodGroup[] {
return useRecipientZapPaymentData(recipientPubkey, enabled).alternativeGroups
}

3
src/i18n/locales/de.ts

@ -154,6 +154,9 @@ export default { @@ -154,6 +154,9 @@ export default {
"Invalid tags JSON": "Invalid tags JSON",
"Payment methods": "Zahlungsmethoden",
"Other payment methods": "Weitere Zahlungsmethoden",
"Lightning address for zap": "Lightning-Adresse für Zap",
"Select lightning address": "Lightning-Adresse wählen",
"Tips above 10k sats can use Bitcoin on-chain.": "Tipps ab 10.000 Sats können On-Chain-Bitcoin nutzen.",
"Zap dialog other payment hint": "Link antippen für PayPal oder Adresse kopieren. Lightning-Tipps über den Button unten.",
"Tip notice prompt title": "Bescheid geben?",
"Tip notice success only note": "Nur wenn du bereits erfolgreich getippt hast (Lightning oder eine andere Zahlungsmethode).",

3
src/i18n/locales/en.ts

@ -159,6 +159,9 @@ export default { @@ -159,6 +159,9 @@ export default {
"Invalid tags JSON": "Invalid tags JSON",
"Payment methods": "Payment methods",
"Other payment methods": "Other payment methods",
"Lightning address for zap": "Lightning address for zap",
"Select lightning address": "Select lightning address",
"Tips above 10k sats can use Bitcoin on-chain.": "Tips above 10k sats can use Bitcoin on-chain.",
"Zap dialog other payment hint": "Tap a link to open PayPal or copy an address. Lightning tips use the button below.",
"Tip notice prompt title": "Let them know?",
"Tip notice success only note": "Only if you already sent a tip successfully (Lightning or another payment method).",

59
src/lib/merge-payment-methods.test.ts

@ -1,5 +1,10 @@ @@ -1,5 +1,10 @@
import { describe, expect, it } from 'vitest'
import { mergePaymentMethods, normalizeLightningAuthority } from './merge-payment-methods'
import { isLightningPaytoType } from '@/lib/payto-registry'
import {
prepareZapDialogAlternativePayments,
mergePaymentMethods,
normalizeLightningAuthority
} from './merge-payment-methods'
describe('normalizeLightningAuthority', () => {
it('maps dot variant to user@domain', () => {
@ -35,3 +40,55 @@ describe('mergePaymentMethods lightning dedup', () => { @@ -35,3 +40,55 @@ describe('mergePaymentMethods lightning dedup', () => {
expect(methods[0].payto).toBe('payto://lightning/user%40domain')
})
})
describe('isLightningPaytoType', () => {
it('includes BIP-353 and excludes BIP-352', () => {
expect(isLightningPaytoType('bip353')).toBe(true)
expect(isLightningPaytoType('bip352')).toBe(false)
expect(isLightningPaytoType('bitcoin')).toBe(false)
})
})
describe('prepareZapDialogAlternativePayments', () => {
const groups = [
{
displayType: 'Tether (USDT)',
methods: [{ type: 'usdt', authority: '0xusdt', displayType: 'Tether (USDT)' }]
},
{
displayType: 'Bitcoin',
methods: [{ type: 'bitcoin', authority: 'bc1qtest', displayType: 'Bitcoin' }]
},
{
displayType: 'Liquid Bitcoin (LBTC)',
methods: [{ type: 'lbtc', authority: 'lq1…', displayType: 'Liquid Bitcoin (LBTC)' }]
},
{
displayType: 'Monero',
methods: [{ type: 'monero', authority: '4…', displayType: 'Monero' }]
},
{
displayType: 'USD Coin',
methods: [{ type: 'usdc', authority: '0xusdc', displayType: 'USD Coin' }]
}
]
it('hides Bitcoin-category methods below 10k sats', () => {
const { groups: out, showBitcoinOnChainHint } = prepareZapDialogAlternativePayments(groups, 9999)
expect(showBitcoinOnChainHint).toBe(false)
expect(out.some((g) => g.methods.some((m) => m.type === 'bitcoin'))).toBe(false)
expect(out[0].displayType).toBe('Liquid Bitcoin (LBTC)')
expect(out[1].displayType).toBe('Monero')
})
it('puts Bitcoin first with hint at 10k sats and above', () => {
const { groups: out, showBitcoinOnChainHint } = prepareZapDialogAlternativePayments(groups, 10_000)
expect(showBitcoinOnChainHint).toBe(true)
expect(out[0].displayType).toBe('Bitcoin')
expect(out[0].highlighted).toBe(true)
expect(out[1].displayType).toBe('Liquid Bitcoin (LBTC)')
expect(out[2].displayType).toBe('Monero')
expect(out[3].displayType).toBe('Tether (USDT)')
expect(out[4].displayType).toBe('USD Coin')
})
})

146
src/lib/merge-payment-methods.ts

@ -1,7 +1,14 @@ @@ -1,7 +1,14 @@
import { getPaymentInfoFromEvent } from '@/lib/event-metadata'
import { buildPaytoUri, getCanonicalPaytoType, getPaytoEditorTypeLabel, getPaytoTypeInfo } from '@/lib/payto'
import {
buildPaytoUri,
getCanonicalPaytoType,
getPaytoEditorTypeLabel,
getPaytoTypeInfo,
isLightningPaytoType
} from '@/lib/payto'
import { normalizePaypalAuthority } from '@/lib/payto-paypal-url'
import type { TProfile } from '@/types'
import { kinds, type Event } from 'nostr-tools'
export type MergedPaymentMethod = {
type: string
@ -16,6 +23,14 @@ export type MergedPaymentMethod = { @@ -16,6 +23,14 @@ export type MergedPaymentMethod = {
export type PaymentMethodGroup = {
displayType: string
methods: MergedPaymentMethod[]
/** Zap dialog: emphasize on-chain Bitcoin when tip is ≥ 10k sats. */
highlighted?: boolean
}
export type ZapDialogAlternativePayments = {
groups: PaymentMethodGroup[]
/** Show banner when on-chain Bitcoin targets are listed (≥ 10k sats). */
showBitcoinOnChainHint: boolean
}
/** Normalize lightning/LUD-16 authority to a canonical form for deduplication. */
@ -47,6 +62,65 @@ function resolveLightningAuthority(a: string, b?: string): string { @@ -47,6 +62,65 @@ function resolveLightningAuthority(a: string, b?: string): string {
return normalizeLightningAuthority(preferred) || preferred.trim()
}
/** Below this zap size, on-chain Bitcoin payto targets are hidden in the zap dialog. */
export const ZAP_HIDE_BITCOIN_ALTS_MAX_SATS = 10_000
/** On-chain Bitcoin family (not Lightning / Liquid layer types). */
export function isBitcoinCategoryPaytoType(type: string): boolean {
return getPaytoTypeInfo(getCanonicalPaytoType(type))?.category === 'bitcoin'
}
/** Sort key for zap dialog “other payment” groups (lower = higher in list). */
function zapAlternativeGroupSortRank(group: PaymentMethodGroup): number {
const types = group.methods.map((m) => getCanonicalPaytoType(m.type))
if (types.some((t) => isBitcoinCategoryPaytoType(t))) return -1000
if (types.some((t) => getPaytoTypeInfo(t)?.category === 'bitcoin-layer' || t === 'liquid' || t === 'lbtc')) {
return 0
}
if (types.some((t) => t === 'monero')) return 1
if (types.some((t) => t === 'usdt')) return 2
if (types.some((t) => t === 'usdc')) return 3
return 10
}
/** Filter, order, and annotate payto groups for the zap dialog “other payment methods” block. */
export function prepareZapDialogAlternativePayments(
groups: PaymentMethodGroup[],
zapSats: number
): ZapDialogAlternativePayments {
const showBitcoin = zapSats >= ZAP_HIDE_BITCOIN_ALTS_MAX_SATS
const filtered = showBitcoin
? groups
: groups
.map((group) => ({
...group,
methods: group.methods.filter((m) => !isBitcoinCategoryPaytoType(m.type))
}))
.filter((group) => group.methods.length > 0)
const prepared = filtered
.map((group) => ({
...group,
highlighted:
showBitcoin && group.methods.some((m) => isBitcoinCategoryPaytoType(m.type))
}))
.sort((a, b) => zapAlternativeGroupSortRank(a) - zapAlternativeGroupSortRank(b))
return {
groups: prepared,
showBitcoinOnChainHint: showBitcoin && prepared.some((g) => g.highlighted)
}
}
/** @deprecated Use {@link prepareZapDialogAlternativePayments} */
export function filterPaymentMethodGroupsForZapAmount(
groups: PaymentMethodGroup[],
zapSats: number
): PaymentMethodGroup[] {
return prepareZapDialogAlternativePayments(groups, zapSats).groups
}
/** Bitcoin-layer first, then on-chain Bitcoin family, then everything else. */
export function paytoPaymentSortRank(type: string): number {
const category = getPaytoTypeInfo(type)?.category
@ -75,7 +149,7 @@ export function mergePaymentMethods( @@ -75,7 +149,7 @@ export function mergePaymentMethods(
const key = `${normType}:${normalizePaymentAuthority(normType, authority)}`
const existing = seen.get(key)
if (existing) {
if (normType === 'lightning') {
if (isLightningPaytoType(normType)) {
existing.authority = resolveLightningAuthority(existing.authority, authority.trim())
existing.payto = buildPaytoUri(normType, existing.authority)
}
@ -83,7 +157,7 @@ export function mergePaymentMethods( @@ -83,7 +157,7 @@ export function mergePaymentMethods(
}
const trimmedAuthority = authority.trim()
const resolvedAuthority =
normType === 'lightning'
isLightningPaytoType(normType)
? resolveLightningAuthority(trimmedAuthority)
: normType === 'paypal'
? normalizePaypalAuthority(trimmedAuthority)
@ -181,14 +255,60 @@ export function groupPaymentMethodsByDisplayType(methods: MergedPaymentMethod[]) @@ -181,14 +255,60 @@ export function groupPaymentMethodsByDisplayType(methods: MergedPaymentMethod[])
return order.map((key) => ({ displayType: key, methods: groups.get(key) ?? [] }))
}
/** Payment targets that differ from the lightning address used for zapping. */
export function getAlternativePaymentMethods(
methods: MergedPaymentMethod[],
zapLightningAddress: string | undefined
): MergedPaymentMethod[] {
const zapNorm = zapLightningAddress?.trim()
? normalizePaymentAuthority('lightning', zapLightningAddress)
: null
if (!zapNorm) return methods
return methods.filter((m) => normalizePaymentAuthority(m.type, m.authority) !== zapNorm)
/**
* Ordered Lightning targets for zaps: kind 0 `lud16` tags, then `w` (network lightning),
* then kind 10133 lightning methods. Optional `preferredAddress` is moved to the front.
*/
export function buildOrderedZapLightningAddresses(opts: {
profileEvent?: Event | null
paymentInfo: ReturnType<typeof getPaymentInfoFromEvent> | null
preferredAddress?: string | null
}): string[] {
const seen = new Set<string>()
const out: string[] = []
const add = (raw: string | undefined) => {
if (!raw?.trim()) return
const resolved = resolveLightningAuthority(raw.trim())
const key = normalizePaymentAuthority('lightning', resolved)
if (seen.has(key)) return
seen.add(key)
out.push(resolved)
}
const ev = opts.profileEvent
if (ev?.kind === kinds.Metadata) {
for (const tag of ev.tags) {
if (tag[0] === 'lud16' && tag[1]) add(tag[1])
}
for (const tag of ev.tags) {
if (tag[0] === 'w' && tag[1] && tag[2] && String(tag[3]).toLowerCase() === 'lightning') {
add(tag[2])
}
}
}
const paymentMethods = mergePaymentMethods(opts.paymentInfo, null)
for (const m of paymentMethods) {
if (isLightningPaytoType(m.type)) add(m.authority)
}
return prioritizeZapLightningAddress(out, opts.preferredAddress ?? undefined)
}
/** Move `preferred` to the front when present; append if not already listed. */
export function prioritizeZapLightningAddress(candidates: string[], preferred?: string): string[] {
if (!preferred?.trim()) return candidates
const norm = normalizePaymentAuthority('lightning', preferred)
const idx = candidates.findIndex((c) => normalizePaymentAuthority('lightning', c) === norm)
if (idx === -1) {
return [resolveLightningAuthority(preferred.trim()), ...candidates]
}
const rest = candidates.filter((_, i) => i !== idx)
return [candidates[idx], ...rest]
}
/** Non-Lightning payto targets for zap dialog “other payment methods” (Lightning has its own selector). */
export function getAlternativePaymentMethods(methods: MergedPaymentMethod[]): MergedPaymentMethod[] {
return methods.filter((m) => !isLightningPaytoType(m.type))
}

14
src/lib/payto-editor-hints.test.ts

@ -3,7 +3,9 @@ import { @@ -3,7 +3,9 @@ import {
getCanonicalPaytoType,
getPaytoAuthorityFieldHelp,
getPaytoLogoPath,
getPaytoEditorTypeLabel,
getPaytoTypeInfo,
isLightningPaytoType,
isPaytoEditorCustomType,
PAYTO_EDITOR_OTHER_OPTION,
paytoEditorSelectTypes
@ -44,11 +46,19 @@ describe('payto aliases', () => { @@ -44,11 +46,19 @@ describe('payto aliases', () => {
})
describe('bitcoin family types', () => {
it('uses bitcoin symbol and category for bolt12 and BIPs', () => {
it('uses bitcoin category for bolt12 and BIP-352 silent payments', () => {
const help = getPaytoAuthorityFieldHelp('bolt12')
expect(help.placeholder).toContain('lno1')
expect(getPaytoAuthorityFieldHelp('bip353').placeholder).toContain('@')
expect(getPaytoTypeInfo('bip352')?.category).toBe('bitcoin')
expect(getPaytoAuthorityFieldHelp('bip352').placeholder).toContain('sp1')
expect(getPaytoEditorTypeLabel('bip352')).toBe('Silent Payments (BIP-352)')
})
it('treats BIP-353 as lightning-layer, not on-chain bitcoin', () => {
expect(getPaytoTypeInfo('bip353')?.category).toBe('bitcoin-layer')
expect(isLightningPaytoType('bip353')).toBe(true)
expect(getPaytoEditorTypeLabel('bip353')).toBe('DNS Payment Instructions (BIP-353)')
expect(getPaytoAuthorityFieldHelp('bip353').placeholder).toContain('@')
})
})

4
src/lib/payto-registry.ts

@ -120,6 +120,8 @@ export function getPaytoIconChar(type: string): string | null { @@ -120,6 +120,8 @@ export function getPaytoIconChar(type: string): string | null {
return getPaytoTypeRecord(type)?.symbol ?? null
}
/** LUD-16 lightning and BIP-353 DNS payment instructions (not on-chain Bitcoin). */
export function isLightningPaytoType(type: string): boolean {
return getCanonicalPaytoType(type) === 'lightning'
const canonical = getCanonicalPaytoType(type)
return canonical === 'lightning' || canonical === 'bip353'
}

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

@ -153,15 +153,17 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { @@ -153,15 +153,17 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
const avatar = profileTags.find((t) => t[0] === 'picture')?.[1] ?? ''
const banner = profileTags.find((t) => t[0] === 'banner')?.[1] ?? ''
// Rebuild tag list whenever the stored profile event changes.
// Rebuild tag list when the stored profile event changes — not while the user is editing.
useEffect(() => {
if (hasChanged) return
setProfileTags(buildTagListFromEvent(profileEvent ?? null))
}, [profileEvent])
}, [profileEvent, hasChanged])
// Sync full-event JSON editor.
// Sync full-event JSON editor (same guard as tag list).
useEffect(() => {
if (hasChanged) return
setProfileEventJson(profileEvent ? JSON.stringify(profileEvent, null, 2) : '')
}, [profileEvent])
}, [profileEvent, hasChanged])
// Fetch payment info (kind 10133).
useEffect(() => {
@ -285,7 +287,12 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { @@ -285,7 +287,12 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
client.fetchProfileEvent(account.pubkey),
client.fetchPaymentInfoEvent(account.pubkey)
])
if (profileEvt) await updateProfileEvent(profileEvt)
if (profileEvt) {
await updateProfileEvent(profileEvt)
setProfileTags(buildTagListFromEvent(profileEvt))
setProfileEventJson(JSON.stringify(profileEvt, null, 2))
setHasChanged(false)
}
setPaymentInfoEvent(paymentEvt ?? null)
toast.success(t('Profile and payment cache refreshed'))
} catch {

19
src/services/lightning.service.ts

@ -14,6 +14,7 @@ import client from './client.service' @@ -14,6 +14,7 @@ import client from './client.service'
import storage from './local-storage.service'
import { queryService, replaceableEventService } from './client.service'
import { getProfileFromEvent } from '@/lib/event-metadata'
import { prioritizeZapLightningAddress } from '@/lib/merge-payment-methods'
import { fetchWithTimeout } from '@/lib/fetch-with-timeout'
import logger from '@/lib/logger'
import { runAfterReleasingRadixScrollLock } from '@/lib/react-remove-scroll-body-cleanup'
@ -44,7 +45,8 @@ class LightningService { @@ -44,7 +45,8 @@ class LightningService {
sats: number,
comment: string,
closeOuterModel?: () => void,
includePublicReceipt: boolean = storage.getIncludePublicZapReceipt()
includePublicReceipt: boolean = storage.getIncludePublicZapReceipt(),
zapLightning?: { address?: string; candidates?: string[] }
): Promise<{ preimage: string; invoice: string } | null> {
if (!client.signer) {
throw new Error('You need to be logged in to zap')
@ -67,7 +69,7 @@ class LightningService { @@ -67,7 +69,7 @@ class LightningService {
if (!profile) {
throw new Error('Recipient not found')
}
const zapEndpoint = await this.getZapEndpoint(profile)
const zapEndpoint = await this.getZapEndpoint(profile, zapLightning)
if (!zapEndpoint) {
throw new Error("Recipient's lightning address is invalid")
}
@ -216,11 +218,16 @@ class LightningService { @@ -216,11 +218,16 @@ class LightningService {
return this.recentSupportersCache
}
private async getZapEndpoint(profile: TProfile): Promise<null | {
private async getZapEndpoint(
profile: TProfile,
zapLightning?: { address?: string; candidates?: string[] }
): Promise<null | {
callback: string
lnurl: string
}> {
const candidates = this.lightningAddressCandidates(profile)
const candidates = zapLightning?.candidates?.length
? prioritizeZapLightningAddress(zapLightning.candidates, zapLightning.address)
: this.lightningAddressCandidates(profile, zapLightning?.address)
for (const addr of candidates) {
const resolved = await this.fetchLnurlPayZapEndpoint(addr)
if (resolved) return resolved
@ -229,7 +236,7 @@ class LightningService { @@ -229,7 +236,7 @@ class LightningService {
}
/** Ordered lightning identifiers from kind 0 (lud16/lud06 + `w` lightning rows); de-duplicated. */
private lightningAddressCandidates(profile: TProfile): string[] {
private lightningAddressCandidates(profile: TProfile, preferredFirst?: string): string[] {
const raw =
profile.lightningAddressList?.length && profile.lightningAddressList.length > 0
? profile.lightningAddressList
@ -246,7 +253,7 @@ class LightningService { @@ -246,7 +253,7 @@ class LightningService {
seen.add(k)
out.push(t)
}
return out
return prioritizeZapLightningAddress(out, preferredFirst)
}
private async fetchLnurlPayZapEndpoint(lightningAddress: string): Promise<null | {

Loading…
Cancel
Save