Browse Source

redo zaps to add payment target possibility, zap receipt deselection, and public message notice

imwald
Silberengel 4 weeks ago
parent
commit
47fe16f08e
  1. 11
      src/components/NoteStats/ZapButton.tsx
  2. 83
      src/components/PaymentMethodsSection/index.tsx
  3. 243
      src/components/Profile/index.tsx
  4. 27
      src/components/ZapDialog/index.tsx
  5. 1
      src/constants.ts
  6. 2
      src/i18n/locales/en.ts
  7. 183
      src/lib/merge-payment-methods.ts
  8. 25
      src/pages/secondary/WalletPage/IncludePublicZapReceiptSwitch.tsx
  9. 2
      src/pages/secondary/WalletPage/index.tsx
  10. 14
      src/providers/ZapProvider.tsx
  11. 11
      src/services/indexed-db.service.ts
  12. 18
      src/services/lightning.service.ts
  13. 37
      src/services/local-storage.service.ts

11
src/components/NoteStats/ZapButton.tsx

@ -26,7 +26,7 @@ type ZapButtonProps = { @@ -26,7 +26,7 @@ type ZapButtonProps = {
export function ZapButtonWithStats({ event, hideCount = false, noteStats }: ZapButtonProps) {
const { t } = useTranslation()
const { checkLogin, pubkey } = useNostr()
const { defaultZapSats, defaultZapComment, quickZap } = useZap()
const { defaultZapSats, defaultZapComment, quickZap, includePublicZapReceipt } = useZap()
const [touchStart, setTouchStart] = useState<{ x: number; y: number } | null>(null)
const [openZapDialog, setOpenZapDialog] = useState(false)
const [zapping, setZapping] = useState(false)
@ -66,7 +66,14 @@ export function ZapButtonWithStats({ event, hideCount = false, noteStats }: ZapB @@ -66,7 +66,14 @@ export function ZapButtonWithStats({ event, hideCount = false, noteStats }: ZapB
if (zapping) return
setZapping(true)
const zapResult = await lightning.zap(pubkey, event, defaultZapSats, defaultZapComment)
const zapResult = await lightning.zap(
pubkey,
event,
defaultZapSats,
defaultZapComment,
undefined,
includePublicZapReceipt
)
// user canceled
if (!zapResult) {
return

83
src/components/PaymentMethodsSection/index.tsx

@ -0,0 +1,83 @@ @@ -0,0 +1,83 @@
import PaytoLink from '@/components/PaytoLink'
import type { PaymentMethodGroup } from '@/lib/merge-payment-methods'
import { Copy } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
export default function PaymentMethodsSection({
groups,
recipientPubkey,
onOpenZap,
title,
className
}: {
groups: PaymentMethodGroup[]
recipientPubkey?: string
/** When set, lightning rows can open the zap flow for this profile. */
onOpenZap?: () => void
title?: string
className?: string
}) {
const { t } = useTranslation()
if (groups.length === 0) return null
return (
<div className={className}>
<div className="text-xs font-semibold text-muted-foreground mb-2">
{title ?? t('Payment Methods')}
</div>
<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 className="space-y-1.5 mt-1">
{group.methods.map((method, idx) => (
<div key={idx} className="min-w-0">
{method.authority && (
<div className="text-muted-foreground flex items-center gap-1 min-w-0">
<PaytoLink
type={method.type}
authority={method.authority}
paytoUri={method.payto}
pubkey={method.type === 'lightning' ? recipientPubkey : undefined}
onOpenZap={method.type === 'lightning' ? onOpenZap : undefined}
className="hover:underline break-all min-w-0 text-primary flex-1"
>
{method.authority}
</PaytoLink>
<button
type="button"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
navigator.clipboard.writeText(method.authority)
toast.success(t('Copied to clipboard'))
}}
className="shrink-0 p-1 rounded text-muted-foreground hover:text-foreground hover:bg-muted"
title={t('Copy address')}
>
<Copy className="size-3.5" />
</button>
</div>
)}
{(method.currency ||
(method.minAmount !== undefined && method.maxAmount !== undefined)) && (
<div className="text-muted-foreground text-xs mt-0.5">
{method.currency && <span>({method.currency})</span>}
{method.minAmount !== undefined && method.maxAmount !== undefined && (
<span className="ml-2">
{method.minAmount}-{method.maxAmount}
</span>
)}
</div>
)}
</div>
))}
</div>
</div>
))}
</div>
</div>
)
}

243
src/components/Profile/index.tsx

@ -33,7 +33,6 @@ import { @@ -33,7 +33,6 @@ import {
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import {
Copy,
Ellipsis,
ExternalLink,
Calendar,
@ -57,7 +56,6 @@ import { @@ -57,7 +56,6 @@ import {
type Ref
} from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import logger from '@/lib/logger'
import { AlexandriaEventsSearchEmptyCta } from '@/components/AlexandriaEventsSearchEmptyCta'
import NotFound from '../NotFound'
@ -74,7 +72,6 @@ import SmartFollowings from './SmartFollowings' @@ -74,7 +72,6 @@ import SmartFollowings from './SmartFollowings'
import SmartMuteLink from './SmartMuteLink'
import SmartRelays from './SmartRelays'
import ZapDialog from '@/components/ZapDialog'
import PaytoLink from '@/components/PaytoLink'
import PostEditor from '@/components/PostEditor'
import {
ScheduleVideoCallDialog,
@ -85,151 +82,12 @@ import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' @@ -85,151 +82,12 @@ import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useCurrentRelays } from '@/providers/CurrentRelaysProvider'
import { FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS } from '@/constants'
import { nip66Service } from '@/services/nip66.service'
import { buildPaytoUri, getCanonicalPaytoType, getPaytoEditorTypeLabel, getPaytoTypeInfo } from '@/lib/payto'
import type { TProfile } from '@/types'
/**
* Normalize lightning/LUD-16 authority to a canonical form for deduplication.
* Handles "user@domain" and "user.domain" (dot variant) as the same address.
*/
function normalizeLightningAuthority(authority: string): string {
const s = authority.trim().toLowerCase()
if (!s) return s
if (s.includes('@')) return s
const firstDot = s.indexOf('.')
if (firstDot > 0) return s.slice(0, firstDot) + '@' + s.slice(firstDot + 1)
return s
}
/** Normalize authority for deduplication (canonical key per type) */
function normalizePaymentAuthority(type: string, authority: string): string {
const t = type.toLowerCase()
if (t === 'lightning' && authority) return normalizeLightningAuthority(authority)
return authority.trim().toLowerCase()
}
/** Prefer displaying lightning address in canonical "user@domain" form when we have both variants */
function preferCanonicalLightningAuthority(a: string, b: string): string {
const hasAt = (s: string) => s.trim().includes('@')
if (hasAt(a) && !hasAt(b)) return a
if (hasAt(b) && !hasAt(a)) return b
return a
}
type MergedPaymentMethod = {
type: string
authority: string
payto?: string
displayType: string
currency?: string
minAmount?: number
maxAmount?: number
}
/** Bitcoin-layer first, then on-chain Bitcoin family, then everything else. */
function paytoPaymentSortRank(type: string): number {
const category = getPaytoTypeInfo(type)?.category
if (category === 'bitcoin-layer') return 0
if (category === 'bitcoin') return 1
return 2
}
/** Merge payment methods from kind 10133 and profile (kind 0: JSON + tags), normalized and deduplicated */
function mergePaymentMethods(
paymentInfo: ReturnType<typeof getPaymentInfoFromEvent> | null,
profile: TProfile | null
): MergedPaymentMethod[] {
const seen = new Map<string, MergedPaymentMethod>()
const out: MergedPaymentMethod[] = []
const add = (type: string, authority: string, payto?: string, displayType?: string, extra?: { currency?: string; minAmount?: number; maxAmount?: number }) => {
if (!authority?.trim()) return
const normType = getCanonicalPaytoType(type)
const key = `${normType}:${normalizePaymentAuthority(normType, authority)}`
const existing = seen.get(key)
if (existing) {
if (normType === 'lightning') {
existing.authority = preferCanonicalLightningAuthority(existing.authority, authority.trim())
existing.payto = existing.payto || payto || (normType && authority ? `payto://${normType}/${existing.authority}` : undefined)
}
return
}
const entry: MergedPaymentMethod = {
type: normType,
authority: authority.trim(),
payto: payto || (normType && authority ? `payto://${normType}/${authority.trim()}` : undefined),
displayType: displayType || getPaytoEditorTypeLabel(normType),
...extra
}
seen.set(key, entry)
out.push(entry)
}
// Aggregate: profile (kind 0) first – from lightningAddressList (tags + JSON) and single lightningAddress
const fromProfile = profile?.lightningAddressList?.length
? profile.lightningAddressList
: profile?.lightningAddress
? [profile.lightningAddress]
: []
fromProfile.forEach((addr) => {
if (addr) add('lightning', addr, `payto://lightning/${addr}`, 'Lightning Network')
})
// Kind-0 `w` tags: ["w", currency, address, network] — NIP-19-style multi-wallet (lightning via lud*/list above)
profile?.wWalletTags?.forEach((w) => {
const net = w.network.toLowerCase()
if (net === 'lightning') return
const addr = w.address?.trim()
if (!addr) return
const cur = (w.currency || '').trim().toLowerCase()
if (net === 'bitcoin') {
add('bitcoin', addr, buildPaytoUri('bitcoin', addr), 'Bitcoin', { currency: w.currency })
return
}
if (cur === 'usdt' || cur === 'usd₮' || cur === 'tether' || net === 'usdt') {
add('usdt', addr, buildPaytoUri('usdt', addr), 'Tether (USDT)', { currency: w.currency || 'USDT' })
return
}
if (net === 'liquid') {
if (cur === 'lbtc' || cur === 'l-btc' || cur === 'liquid btc') {
add('lbtc', addr, buildPaytoUri('lbtc', addr), 'Liquid Bitcoin (LBTC)', { currency: w.currency })
} else {
add('liquid', addr, buildPaytoUri('liquid', addr), cur ? `Liquid (${w.currency})` : 'Liquid', {
currency: w.currency
})
}
return
}
if (cur === 'lbtc' || cur === 'l-btc') {
add('lbtc', addr, buildPaytoUri('lbtc', addr), 'Liquid Bitcoin (LBTC)', { currency: w.currency })
return
}
})
// Then kind 10133 (payto tags and JSON content)
if (paymentInfo?.methods?.length) {
paymentInfo.methods.forEach((m) => {
const authority = m.authority || m.address || ''
add(
(m.type || 'lightning').toLowerCase(),
authority,
m.payto,
m.displayType,
{ currency: m.currency, minAmount: m.minAmount, maxAmount: m.maxAmount }
)
})
} else if (paymentInfo?.payto) {
const type = (paymentInfo.type || 'lightning').toLowerCase()
const authority = paymentInfo.authority || paymentInfo.payto.replace(/^payto:\/\/[^/]+\//, '') || ''
add(type, authority, paymentInfo.payto, type === 'lightning' ? 'Lightning Network' : paymentInfo.type || 'Payment')
}
return out
}
import PaymentMethodsSection from '@/components/PaymentMethodsSection'
import {
groupPaymentMethodsByDisplayType,
mergePaymentMethods,
sortMergedPaymentMethods
} from '@/lib/merge-payment-methods'
export default function Profile({
id,
@ -276,28 +134,15 @@ export default function Profile({ @@ -276,28 +134,15 @@ export default function Profile({
const { relayUrls: currentBrowsingRelayUrls } = useCurrentRelays()
const { relaySets, favoriteRelays } = useFavoriteRelays()
const mergedPaymentMethods = useMemo(() => {
const list = mergePaymentMethods(paymentInfo, profile ?? null)
return [...list].sort((a, b) => paytoPaymentSortRank(a.type) - paytoPaymentSortRank(b.type))
const paymentMethodsByType = useMemo(() => {
const list = sortMergedPaymentMethods(mergePaymentMethods(paymentInfo, profile ?? null))
return groupPaymentMethodsByDisplayType(list)
}, [paymentInfo, profile])
/** Group payment methods by displayType so same-type addresses render under one heading */
const paymentMethodsByType = useMemo(() => {
const groups = new Map<string, MergedPaymentMethod[]>()
for (const method of mergedPaymentMethods) {
const key = method.displayType || method.type
if (!groups.has(key)) groups.set(key, [])
groups.get(key)!.push(method)
}
const order = Array.from(groups.keys()).sort((a, b) => {
const arrA = groups.get(a)
const arrB = groups.get(b)
const typeA = arrA?.[0]?.type ?? ''
const typeB = arrB?.[0]?.type ?? ''
return paytoPaymentSortRank(typeA) - paytoPaymentSortRank(typeB)
})
return order.map((key) => ({ displayType: key, methods: groups.get(key) ?? [] }))
}, [mergedPaymentMethods])
const hasLightningForZap = useMemo(
() => paymentMethodsByType.some((g) => g.methods.some((m) => m.type === 'lightning')),
[paymentMethodsByType]
)
// Fetch payment info (kind 10133) for this profile; uses cached replaceable events and IndexedDB
useEffect(() => {
@ -686,7 +531,7 @@ export default function Profile({ @@ -686,7 +531,7 @@ export default function Profile({
)}
{!isSelf ? (
<>
{mergedPaymentMethods.some((m) => m.type === 'lightning') && (
{hasLightningForZap && (
<ProfileZapButton pubkey={pubkey} openZapDialog={openZapDialog} setOpenZapDialog={setOpenZapDialog} />
)}
<FollowButton pubkey={pubkey} />
@ -746,61 +591,13 @@ export default function Profile({ @@ -746,61 +591,13 @@ export default function Profile({
))}
</div>
)}
{/* Payment methods: merged from kind 10133 + profile lightning, deduplicated – use PaytoLink for consistent behavior */}
{paymentMethodsByType.length > 0 && (
<div className="mt-2 mb-4 p-3 pb-4 border rounded-lg bg-muted/50 min-w-0">
<div className="text-xs font-semibold text-muted-foreground mb-2">Payment Methods</div>
<div className="space-y-3 min-w-0">
{paymentMethodsByType.map((group, groupIdx) => (
<div key={groupIdx} className="text-sm min-w-0">
<div className="font-medium">{group.displayType}</div>
<div className="space-y-1.5 mt-1">
{group.methods.map((method, idx) => (
<div key={idx} className="min-w-0">
{method.authority && (
<div className="text-muted-foreground flex items-center gap-1 min-w-0">
<PaytoLink
type={method.type}
authority={method.authority}
paytoUri={method.payto}
pubkey={method.type === 'lightning' ? pubkey : undefined}
onOpenZap={method.type === 'lightning' ? () => setOpenZapDialog(true) : undefined}
className="hover:underline break-all min-w-0 text-primary flex-1"
>
{method.authority}
</PaytoLink>
<button
type="button"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
navigator.clipboard.writeText(method.authority)
toast.success(t('Copied to clipboard'))
}}
className="shrink-0 p-1 rounded text-muted-foreground hover:text-foreground hover:bg-muted"
title={t('Copy address')}
>
<Copy className="size-3.5" />
</button>
</div>
)}
{(method.currency || (method.minAmount !== undefined && method.maxAmount !== undefined)) && (
<div className="text-muted-foreground text-xs mt-0.5">
{method.currency && <span>({method.currency})</span>}
{method.minAmount !== undefined && method.maxAmount !== undefined && (
<span className="ml-2">
{method.minAmount}-{method.maxAmount}
</span>
)}
</div>
)}
</div>
))}
</div>
</div>
))}
</div>
</div>
<PaymentMethodsSection
groups={paymentMethodsByType}
recipientPubkey={pubkey}
onOpenZap={() => setOpenZapDialog(true)}
className="mt-2 mb-4 p-3 pb-4 border rounded-lg bg-muted/50 min-w-0"
/>
)}
<ZapDialog
open={openZapDialog}

27
src/components/ZapDialog/index.tsx

@ -16,6 +16,7 @@ import { @@ -16,6 +16,7 @@ import {
import { Input } from '@/components/ui/input'
import { Skeleton } from '@/components/ui/skeleton'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useZap } from '@/providers/ZapProvider'
@ -148,7 +149,8 @@ function ZapDialogContent({ @@ -148,7 +149,8 @@ function ZapDialogContent({
}) {
const { t, i18n } = useTranslation()
const { pubkey } = useNostr()
const { defaultZapSats, defaultZapComment } = useZap()
const { defaultZapSats, defaultZapComment, includePublicZapReceipt, updateIncludePublicZapReceipt } =
useZap()
const [sats, setSats] = useState(defaultAmount ?? defaultZapSats)
const [comment, setComment] = useState(defaultComment ?? defaultZapComment)
const [zapping, setZapping] = useState(false)
@ -192,8 +194,13 @@ function ZapDialogContent({ @@ -192,8 +194,13 @@ function ZapDialogContent({
throw new Error('You need to be logged in to zap')
}
setZapping(true)
const zapResult = await lightning.zap(pubkey, event ?? recipient, sats, comment, () =>
setOpen(false)
const zapResult = await lightning.zap(
pubkey,
event ?? recipient,
sats,
comment,
() => setOpen(false),
includePublicZapReceipt
)
// user canceled
if (!zapResult) {
@ -257,6 +264,20 @@ function ZapDialogContent({ @@ -257,6 +264,20 @@ function ZapDialogContent({
<Label htmlFor="comment">{t('zapComment')}</Label>
<Input id="comment" value={comment} onChange={(e) => setComment(e.target.value)} />
</div>
<div className="px-4 flex items-center justify-between gap-3">
<Label htmlFor="zap-include-receipt" className="flex-1 cursor-pointer">
<div className="text-sm font-medium">{t('Include public zap receipt')}</div>
<div className="text-xs text-muted-foreground font-normal">
{t('When off, your zap may still succeed but a public receipt may not be published to relays')}
</div>
</Label>
<Switch
id="zap-include-receipt"
checked={includePublicZapReceipt}
onCheckedChange={updateIncludePublicZapReceipt}
/>
</div>
</div>
{/* Zap button - fixed at bottom */}

1
src/constants.ts

@ -328,6 +328,7 @@ export const StorageKey = { @@ -328,6 +328,7 @@ export const StorageKey = {
DEFAULT_ZAP_SATS: 'defaultZapSats',
DEFAULT_ZAP_COMMENT: 'defaultZapComment',
QUICK_ZAP: 'quickZap',
INCLUDE_PUBLIC_ZAP_RECEIPT: 'includePublicZapReceipt',
ZAP_REPLY_THRESHOLD: 'zapReplyThreshold',
/** Per-pubkey ms timestamps: last full network hydrate (see ACCOUNT_SESSION_NETWORK_HYDRATE_MIN_INTERVAL_MS). */
ACCOUNT_NETWORK_HYDRATE_AT_MAP: 'accountNetworkHydrateAtMap',

2
src/i18n/locales/en.ts

@ -520,6 +520,8 @@ export default { @@ -520,6 +520,8 @@ export default {
"Lightning Address (or LNURL)": "Lightning Address (or LNURL)",
"Quick zap": "Quick zap",
"If enabled, you can zap with a single click. Click and hold for custom amounts": "If enabled, you can zap with a single click. Click and hold for custom amounts",
"Include public zap receipt": "Include public zap receipt",
"When off, your zap may still succeed but a public receipt may not be published to relays": "When off, your zap may still succeed but a public receipt may not be published to relays",
All: "All",
Reactions: "Reactions",
Zaps: "Zaps",

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

@ -0,0 +1,183 @@ @@ -0,0 +1,183 @@
import { getPaymentInfoFromEvent } from '@/lib/event-metadata'
import { buildPaytoUri, getCanonicalPaytoType, getPaytoEditorTypeLabel, getPaytoTypeInfo } from '@/lib/payto'
import type { TProfile } from '@/types'
export type MergedPaymentMethod = {
type: string
authority: string
payto?: string
displayType: string
currency?: string
minAmount?: number
maxAmount?: number
}
export type PaymentMethodGroup = {
displayType: string
methods: MergedPaymentMethod[]
}
/** Normalize lightning/LUD-16 authority to a canonical form for deduplication. */
export function normalizeLightningAuthority(authority: string): string {
const s = authority.trim().toLowerCase()
if (!s) return s
if (s.includes('@')) return s
const firstDot = s.indexOf('.')
if (firstDot > 0) return s.slice(0, firstDot) + '@' + s.slice(firstDot + 1)
return s
}
export function normalizePaymentAuthority(type: string, authority: string): string {
const t = type.toLowerCase()
if (t === 'lightning' && authority) return normalizeLightningAuthority(authority)
return authority.trim().toLowerCase()
}
function preferCanonicalLightningAuthority(a: string, b: string): string {
const hasAt = (s: string) => s.trim().includes('@')
if (hasAt(a) && !hasAt(b)) return a
if (hasAt(b) && !hasAt(a)) return b
return a
}
/** Bitcoin-layer first, then on-chain Bitcoin family, then everything else. */
export function paytoPaymentSortRank(type: string): number {
const category = getPaytoTypeInfo(type)?.category
if (category === 'bitcoin-layer') return 0
if (category === 'bitcoin') return 1
return 2
}
/** Merge payment methods from kind 10133 and profile (kind 0: JSON + tags), normalized and deduplicated. */
export function mergePaymentMethods(
paymentInfo: ReturnType<typeof getPaymentInfoFromEvent> | null,
profile: TProfile | null
): MergedPaymentMethod[] {
const seen = new Map<string, MergedPaymentMethod>()
const out: MergedPaymentMethod[] = []
const add = (
type: string,
authority: string,
payto?: string,
displayType?: string,
extra?: { currency?: string; minAmount?: number; maxAmount?: number }
) => {
if (!authority?.trim()) return
const normType = getCanonicalPaytoType(type)
const key = `${normType}:${normalizePaymentAuthority(normType, authority)}`
const existing = seen.get(key)
if (existing) {
if (normType === 'lightning') {
existing.authority = preferCanonicalLightningAuthority(existing.authority, authority.trim())
existing.payto =
existing.payto ||
payto ||
(normType && authority ? `payto://${normType}/${existing.authority}` : undefined)
}
return
}
const entry: MergedPaymentMethod = {
type: normType,
authority: authority.trim(),
payto: payto || (normType && authority ? `payto://${normType}/${authority.trim()}` : undefined),
displayType: displayType || getPaytoEditorTypeLabel(normType),
...extra
}
seen.set(key, entry)
out.push(entry)
}
const fromProfile = profile?.lightningAddressList?.length
? profile.lightningAddressList
: profile?.lightningAddress
? [profile.lightningAddress]
: []
fromProfile.forEach((addr) => {
if (addr) add('lightning', addr, `payto://lightning/${addr}`, 'Lightning Network')
})
profile?.wWalletTags?.forEach((w) => {
const net = w.network.toLowerCase()
if (net === 'lightning') return
const addr = w.address?.trim()
if (!addr) return
const cur = (w.currency || '').trim().toLowerCase()
if (net === 'bitcoin') {
add('bitcoin', addr, buildPaytoUri('bitcoin', addr), 'Bitcoin', { currency: w.currency })
return
}
if (cur === 'usdt' || cur === 'usd₮' || cur === 'tether' || net === 'usdt') {
add('usdt', addr, buildPaytoUri('usdt', addr), 'Tether (USDT)', { currency: w.currency || 'USDT' })
return
}
if (net === 'liquid') {
if (cur === 'lbtc' || cur === 'l-btc' || cur === 'liquid btc') {
add('lbtc', addr, buildPaytoUri('lbtc', addr), 'Liquid Bitcoin (LBTC)', { currency: w.currency })
} else {
add('liquid', addr, buildPaytoUri('liquid', addr), cur ? `Liquid (${w.currency})` : 'Liquid', {
currency: w.currency
})
}
return
}
if (cur === 'lbtc' || cur === 'l-btc') {
add('lbtc', addr, buildPaytoUri('lbtc', addr), 'Liquid Bitcoin (LBTC)', { currency: w.currency })
}
})
if (paymentInfo?.methods?.length) {
paymentInfo.methods.forEach((m) => {
const authority = m.authority || m.address || ''
add(
(m.type || 'lightning').toLowerCase(),
authority,
m.payto,
m.displayType,
{ currency: m.currency, minAmount: m.minAmount, maxAmount: m.maxAmount }
)
})
} else if (paymentInfo?.payto) {
const type = (paymentInfo.type || 'lightning').toLowerCase()
const authority = paymentInfo.authority || paymentInfo.payto.replace(/^payto:\/\/[^/]+\//, '') || ''
add(type, authority, paymentInfo.payto, type === 'lightning' ? 'Lightning Network' : paymentInfo.type || 'Payment')
}
return out
}
export function sortMergedPaymentMethods(methods: MergedPaymentMethod[]): MergedPaymentMethod[] {
return [...methods].sort((a, b) => paytoPaymentSortRank(a.type) - paytoPaymentSortRank(b.type))
}
/** Group payment methods by displayType (same headings as profile payment section). */
export function groupPaymentMethodsByDisplayType(methods: MergedPaymentMethod[]): PaymentMethodGroup[] {
const groups = new Map<string, MergedPaymentMethod[]>()
for (const method of methods) {
const key = method.displayType || method.type
if (!groups.has(key)) groups.set(key, [])
groups.get(key)!.push(method)
}
const order = Array.from(groups.keys()).sort((a, b) => {
const typeA = groups.get(a)?.[0]?.type ?? ''
const typeB = groups.get(b)?.[0]?.type ?? ''
return paytoPaymentSortRank(typeA) - paytoPaymentSortRank(typeB)
})
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)
}

25
src/pages/secondary/WalletPage/IncludePublicZapReceiptSwitch.tsx

@ -0,0 +1,25 @@ @@ -0,0 +1,25 @@
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { useZap } from '@/providers/ZapProvider'
import { useTranslation } from 'react-i18next'
export default function IncludePublicZapReceiptSwitch() {
const { t } = useTranslation()
const { includePublicZapReceipt, updateIncludePublicZapReceipt } = useZap()
return (
<div className="w-full flex justify-between items-center gap-3">
<Label htmlFor="include-public-zap-receipt-switch" className="flex-1">
<div className="text-base font-medium">{t('Include public zap receipt')}</div>
<div className="text-muted-foreground text-sm font-normal">
{t('When off, your zap may still succeed but a public receipt may not be published to relays')}
</div>
</Label>
<Switch
id="include-public-zap-receipt-switch"
checked={includePublicZapReceipt}
onCheckedChange={updateIncludePublicZapReceipt}
/>
</div>
)
}

2
src/pages/secondary/WalletPage/index.tsx

@ -21,6 +21,7 @@ import DefaultZapAmountInput from './DefaultZapAmountInput' @@ -21,6 +21,7 @@ import DefaultZapAmountInput from './DefaultZapAmountInput'
import DefaultZapCommentInput from './DefaultZapCommentInput'
import LightningAddressInput from './LightningAddressInput'
import QuickZapSwitch from './QuickZapSwitch'
import IncludePublicZapReceiptSwitch from './IncludePublicZapReceiptSwitch'
import ZapReplyThresholdInput from './ZapReplyThresholdInput'
const WalletPage = forwardRef(({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => {
@ -78,6 +79,7 @@ const WalletPage = forwardRef(({ index, hideTitlebar = false }: { index?: number @@ -78,6 +79,7 @@ const WalletPage = forwardRef(({ index, hideTitlebar = false }: { index?: number
<DefaultZapAmountInput />
<DefaultZapCommentInput />
<QuickZapSwitch />
<IncludePublicZapReceiptSwitch />
<LightningAddressInput />
</>
) : (

14
src/providers/ZapProvider.tsx

@ -16,6 +16,8 @@ type TZapContext = { @@ -16,6 +16,8 @@ type TZapContext = {
updateQuickZap: (quickZap: boolean) => void
zapReplyThreshold: number
updateZapReplyThreshold: (sats: number) => void
includePublicZapReceipt: boolean
updateIncludePublicZapReceipt: (include: boolean) => void
}
const ZapContext = createContext<TZapContext | undefined>(undefined)
@ -33,6 +35,9 @@ export function ZapProvider({ children }: { children: React.ReactNode }) { @@ -33,6 +35,9 @@ export function ZapProvider({ children }: { children: React.ReactNode }) {
const [defaultZapComment, setDefaultZapComment] = useState<string>(storage.getDefaultZapComment())
const [quickZap, setQuickZap] = useState<boolean>(storage.getQuickZap())
const [zapReplyThreshold, setZapReplyThreshold] = useState<number>(storage.getZapReplyThreshold())
const [includePublicZapReceipt, setIncludePublicZapReceipt] = useState<boolean>(
storage.getIncludePublicZapReceipt()
)
const [isWalletConnected, setIsWalletConnected] = useState(false)
const [provider, setProvider] = useState<WebLNProvider | null>(null)
const [walletInfo, setWalletInfo] = useState<GetInfoResponse | null>(null)
@ -77,6 +82,11 @@ export function ZapProvider({ children }: { children: React.ReactNode }) { @@ -77,6 +82,11 @@ export function ZapProvider({ children }: { children: React.ReactNode }) {
setZapReplyThreshold(sats)
}
const updateIncludePublicZapReceipt = (include: boolean) => {
setIncludePublicZapReceipt(include)
void storage.setIncludePublicZapReceiptAsync(include)
}
return (
<ZapContext.Provider
value={{
@ -90,7 +100,9 @@ export function ZapProvider({ children }: { children: React.ReactNode }) { @@ -90,7 +100,9 @@ export function ZapProvider({ children }: { children: React.ReactNode }) {
quickZap,
updateQuickZap,
zapReplyThreshold,
updateZapReplyThreshold
updateZapReplyThreshold,
includePublicZapReceipt,
updateIncludePublicZapReceipt
}}
>
{children}

11
src/services/indexed-db.service.ts

@ -216,8 +216,11 @@ const ARCHIVE_CALENDAR_PURGE_SETTING_KEY = 'archiveCalendarPurgedV37' @@ -216,8 +216,11 @@ const ARCHIVE_CALENDAR_PURGE_SETTING_KEY = 'archiveCalendarPurgedV37'
/** Schema version we expect. When adding stores or migrations, bump this. */
const DB_VERSION = 38
/** Max age for profile and payment info cache before we refetch (5 min). */
const PROFILE_AND_PAYMENT_CACHE_MAX_AGE_MS = 5 * 60 * 1000
/** Hint age for profile/payment reads (stale rows still returned; background refresh). */
const PROFILE_AND_PAYMENT_STALE_READ_MS = 5 * 60 * 1000
/** IndexedDB TTL for kind 10133 payment info (matches profile replaceable cache). */
const PAYMENT_INFO_CACHE_MAX_AGE_MS = 1000 * 60 * 60 * 24
/** Convert IDB request.onerror Event to a proper Error for logging and UI */
function idbEventToError(ev: Parameters<NonNullable<IDBRequest['onerror']>>[0]): Error {
@ -626,7 +629,7 @@ class IndexedDbService { @@ -626,7 +629,7 @@ class IndexedDbService {
// BUT: Always return cached profiles even if stale - we'll refresh in background
// This ensures profiles are always visible, even if slightly outdated
const isProfileOrPayment = kind === kinds.Metadata || kind === ExtendedKind.PAYMENT_INFO
if (isProfileOrPayment && row.addedAt && Date.now() - row.addedAt > PROFILE_AND_PAYMENT_CACHE_MAX_AGE_MS) {
if (isProfileOrPayment && row.addedAt && Date.now() - row.addedAt > PROFILE_AND_PAYMENT_STALE_READ_MS) {
// Profile is stale, but return it anyway - refresh will happen in background
// This prevents the "no profile" state when cache exists but is just old
}
@ -2411,7 +2414,7 @@ class IndexedDbService { @@ -2411,7 +2414,7 @@ class IndexedDbService {
try {
const stores = [
{ name: StoreNames.PROFILE_EVENTS, expirationTimestamp: Date.now() - 1000 * 60 * 60 * 24 }, // 1 day
{ name: StoreNames.PAYMENT_INFO_EVENTS, expirationTimestamp: Date.now() - PROFILE_AND_PAYMENT_CACHE_MAX_AGE_MS }, // 5 min
{ name: StoreNames.PAYMENT_INFO_EVENTS, expirationTimestamp: Date.now() - PAYMENT_INFO_CACHE_MAX_AGE_MS }, // 1 day
{ name: StoreNames.RELAY_LIST_EVENTS, expirationTimestamp: Date.now() - 1000 * 60 * 60 * 24 }, // 1 day
{
name: StoreNames.FOLLOW_LIST_EVENTS,

18
src/services/lightning.service.ts

@ -16,6 +16,7 @@ import { SubCloser } from 'nostr-tools/abstract-pool' @@ -16,6 +16,7 @@ import { SubCloser } from 'nostr-tools/abstract-pool'
import { makeZapRequest } from 'nostr-tools/nip57'
import { utf8Decoder } from 'nostr-tools/utils'
import client from './client.service'
import storage from './local-storage.service'
import { queryService, replaceableEventService } from './client.service'
import { getProfileFromEvent } from '@/lib/event-metadata'
import { fetchWithTimeout } from '@/lib/fetch-with-timeout'
@ -47,7 +48,8 @@ class LightningService { @@ -47,7 +48,8 @@ class LightningService {
recipientOrEvent: string | NostrEvent,
sats: number,
comment: string,
closeOuterModel?: () => void
closeOuterModel?: () => void,
includePublicReceipt: boolean = storage.getIncludePublicZapReceipt()
): Promise<{ preimage: string; invoice: string } | null> {
if (!client.signer) {
throw new Error('You need to be logged in to zap')
@ -76,11 +78,13 @@ class LightningService { @@ -76,11 +78,13 @@ class LightningService {
}
const { callback, lnurl } = zapEndpoint
const amount = sats * 1000
const zapRelays = includePublicReceipt
? senderRelayList.write.slice(0, 4).concat(FAST_READ_RELAY_URLS)
: []
const zapRequestDraft = makeZapRequest({
...(event ? { event } : { pubkey: recipient }),
amount,
// Privacy: Only use sender's relays + defaults, not recipient's relays
relays: senderRelayList.write.slice(0, 4).concat(FAST_READ_RELAY_URLS),
relays: zapRelays,
comment
})
const zapRequest = await client.signer.signEvent(zapRequestDraft)
@ -169,7 +173,8 @@ class LightningService { @@ -169,7 +173,8 @@ class LightningService {
optionIndex: number,
sats: number,
comment: string,
closeOuterModel?: () => void
closeOuterModel?: () => void,
includePublicReceipt: boolean = storage.getIncludePublicZapReceipt()
): Promise<{ preimage: string; invoice: string } | null> {
if (!client.signer) {
throw new Error('You need to be logged in to zap')
@ -199,13 +204,16 @@ class LightningService { @@ -199,13 +204,16 @@ class LightningService {
}
const { callback, lnurl } = zapEndpoint
const amount = sats * 1000
const zapRelays = includePublicReceipt
? senderRelayList.write.slice(0, 4).concat(FAST_READ_RELAY_URLS)
: []
const zapRequestDraft = buildZapPollVoteRequestTemplate({
poll: pollEvent,
meta,
recipientPubkey: rec,
optionIndex,
amountMillisats: amount,
relays: senderRelayList.write.slice(0, 4).concat(FAST_READ_RELAY_URLS),
relays: zapRelays,
comment
})
const zapRequest = await client.signer.signEvent(zapRequestDraft)

37
src/services/local-storage.service.ts

@ -52,6 +52,7 @@ const SETTINGS_KEYS = [ @@ -52,6 +52,7 @@ const SETTINGS_KEYS = [
StorageKey.DEFAULT_ZAP_SATS,
StorageKey.DEFAULT_ZAP_COMMENT,
StorageKey.QUICK_ZAP,
StorageKey.INCLUDE_PUBLIC_ZAP_RECEIPT,
StorageKey.ZAP_REPLY_THRESHOLD,
StorageKey.AUTOPLAY,
StorageKey.HIDE_UNTRUSTED_INTERACTIONS,
@ -98,6 +99,7 @@ class LocalStorageService { @@ -98,6 +99,7 @@ class LocalStorageService {
private defaultZapSats: number = 21
private defaultZapComment: string = 'Zap!'
private quickZap: boolean = false
private includePublicZapReceipt: boolean = true
private zapReplyThreshold: number = 1
private mediaUploadService: string = DEFAULT_NIP_96_SERVICE
private autoplay: boolean = true
@ -195,6 +197,10 @@ class LocalStorageService { @@ -195,6 +197,10 @@ class LocalStorageService {
}
this.defaultZapComment = window.localStorage.getItem(StorageKey.DEFAULT_ZAP_COMMENT) ?? 'Zap!'
this.quickZap = window.localStorage.getItem(StorageKey.QUICK_ZAP) === 'true'
const includeReceiptStr = window.localStorage.getItem(StorageKey.INCLUDE_PUBLIC_ZAP_RECEIPT)
if (includeReceiptStr != null) {
this.includePublicZapReceipt = includeReceiptStr !== 'false'
}
const zapReplyThresholdStr = window.localStorage.getItem(StorageKey.ZAP_REPLY_THRESHOLD)
if (zapReplyThresholdStr) {
@ -472,14 +478,22 @@ class LocalStorageService { @@ -472,14 +478,22 @@ class LocalStorageService {
/** Persist a setting. Keys in SETTINGS_KEYS go only to IndexedDB; others use localStorage. */
private persistSetting(key: string, value: string): void {
if ((SETTINGS_KEYS as readonly string[]).includes(key)) {
void loadIndexedDb()
.then((idb) => idb.setSetting(key, value))
.catch(() => {})
void this.persistSettingToIndexedDb(key, value)
return
}
window.localStorage.setItem(key, value)
}
/** Awaited write to the IndexedDB `settings` store (source of truth for {@link SETTINGS_KEYS}). */
private async persistSettingToIndexedDb(key: string, value: string): Promise<void> {
try {
const idb = await loadIndexedDb()
await idb.setSetting(key, value)
} catch {
// IndexedDB unavailable; in-memory value still updated for this session
}
}
private initPromise: Promise<void> | null = null
/**
@ -589,6 +603,8 @@ class LocalStorageService { @@ -589,6 +603,8 @@ class LocalStorageService {
if (defaultZapCommentStr != null) this.defaultZapComment = defaultZapCommentStr
const quickZapStr = get(StorageKey.QUICK_ZAP)
if (quickZapStr != null) this.quickZap = quickZapStr === 'true'
const includeReceiptStr = get(StorageKey.INCLUDE_PUBLIC_ZAP_RECEIPT)
if (includeReceiptStr != null) this.includePublicZapReceipt = includeReceiptStr !== 'false'
const zapReplyStr = get(StorageKey.ZAP_REPLY_THRESHOLD)
if (zapReplyStr != null) {
const num = parseInt(zapReplyStr)
@ -801,6 +817,21 @@ class LocalStorageService { @@ -801,6 +817,21 @@ class LocalStorageService {
this.persistSetting(StorageKey.QUICK_ZAP, quickZap.toString())
}
getIncludePublicZapReceipt() {
return this.includePublicZapReceipt
}
setIncludePublicZapReceipt(include: boolean) {
this.includePublicZapReceipt = include
void this.persistSettingToIndexedDb(StorageKey.INCLUDE_PUBLIC_ZAP_RECEIPT, include.toString())
}
/** Persist include-public-zap-receipt to IndexedDB settings (await for callers that need flush). */
async setIncludePublicZapReceiptAsync(include: boolean): Promise<void> {
this.includePublicZapReceipt = include
await this.persistSettingToIndexedDb(StorageKey.INCLUDE_PUBLIC_ZAP_RECEIPT, include.toString())
}
getZapReplyThreshold() {
return this.zapReplyThreshold
}

Loading…
Cancel
Save