diff --git a/src/components/NoteStats/ZapButton.tsx b/src/components/NoteStats/ZapButton.tsx
index edd12e07..1ed40cb0 100644
--- a/src/components/NoteStats/ZapButton.tsx
+++ b/src/components/NoteStats/ZapButton.tsx
@@ -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
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
diff --git a/src/components/PaymentMethodsSection/index.tsx b/src/components/PaymentMethodsSection/index.tsx
new file mode 100644
index 00000000..1e9222c7
--- /dev/null
+++ b/src/components/PaymentMethodsSection/index.tsx
@@ -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 (
+
+
+ {title ?? t('Payment Methods')}
+
+
+ {groups.map((group, groupIdx) => (
+
+
{group.displayType}
+
+ {group.methods.map((method, idx) => (
+
+ {method.authority && (
+
+
+ {method.authority}
+
+
+
+ )}
+ {(method.currency ||
+ (method.minAmount !== undefined && method.maxAmount !== undefined)) && (
+
+ {method.currency && ({method.currency})}
+ {method.minAmount !== undefined && method.maxAmount !== undefined && (
+
+ {method.minAmount}-{method.maxAmount}
+
+ )}
+
+ )}
+
+ ))}
+
+
+ ))}
+
+
+ )
+}
diff --git a/src/components/Profile/index.tsx b/src/components/Profile/index.tsx
index c834369a..1fb06b96 100644
--- a/src/components/Profile/index.tsx
+++ b/src/components/Profile/index.tsx
@@ -33,7 +33,6 @@ import {
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import {
- Copy,
Ellipsis,
ExternalLink,
Calendar,
@@ -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'
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'
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 | null,
- profile: TProfile | null
-): MergedPaymentMethod[] {
- const seen = new Map()
- 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({
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()
- 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({
)}
{!isSelf ? (
<>
- {mergedPaymentMethods.some((m) => m.type === 'lightning') && (
+ {hasLightningForZap && (
)}
@@ -746,61 +591,13 @@ export default function Profile({
))}
)}
- {/* Payment methods: merged from kind 10133 + profile lightning, deduplicated – use PaytoLink for consistent behavior */}
{paymentMethodsByType.length > 0 && (
-
-
Payment Methods
-
- {paymentMethodsByType.map((group, groupIdx) => (
-
-
{group.displayType}
-
- {group.methods.map((method, idx) => (
-
- {method.authority && (
-
-
setOpenZapDialog(true) : undefined}
- className="hover:underline break-all min-w-0 text-primary flex-1"
- >
- {method.authority}
-
-
-
- )}
- {(method.currency || (method.minAmount !== undefined && method.maxAmount !== undefined)) && (
-
- {method.currency && ({method.currency})}
- {method.minAmount !== undefined && method.maxAmount !== undefined && (
-
- {method.minAmount}-{method.maxAmount}
-
- )}
-
- )}
-
- ))}
-
-
- ))}
-
-
+ setOpenZapDialog(true)}
+ className="mt-2 mb-4 p-3 pb-4 border rounded-lg bg-muted/50 min-w-0"
+ />
)}
- 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({
setComment(e.target.value)} />
+
+
+
+
+
{/* Zap button - fixed at bottom */}
diff --git a/src/constants.ts b/src/constants.ts
index 46b22d96..74b49ae4 100644
--- a/src/constants.ts
+++ b/src/constants.ts
@@ -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',
diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts
index 7f287149..4537b671 100644
--- a/src/i18n/locales/en.ts
+++ b/src/i18n/locales/en.ts
@@ -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",
diff --git a/src/lib/merge-payment-methods.ts b/src/lib/merge-payment-methods.ts
new file mode 100644
index 00000000..c314463c
--- /dev/null
+++ b/src/lib/merge-payment-methods.ts
@@ -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 | null,
+ profile: TProfile | null
+): MergedPaymentMethod[] {
+ const seen = new Map()
+ 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()
+ 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)
+}
diff --git a/src/pages/secondary/WalletPage/IncludePublicZapReceiptSwitch.tsx b/src/pages/secondary/WalletPage/IncludePublicZapReceiptSwitch.tsx
new file mode 100644
index 00000000..88a02a65
--- /dev/null
+++ b/src/pages/secondary/WalletPage/IncludePublicZapReceiptSwitch.tsx
@@ -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 (
+
+
+
+
+ )
+}
diff --git a/src/pages/secondary/WalletPage/index.tsx b/src/pages/secondary/WalletPage/index.tsx
index 04dafb47..65cb711b 100644
--- a/src/pages/secondary/WalletPage/index.tsx
+++ b/src/pages/secondary/WalletPage/index.tsx
@@ -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
+
>
) : (
diff --git a/src/providers/ZapProvider.tsx b/src/providers/ZapProvider.tsx
index da0b9e95..f87eae76 100644
--- a/src/providers/ZapProvider.tsx
+++ b/src/providers/ZapProvider.tsx
@@ -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(undefined)
@@ -33,6 +35,9 @@ export function ZapProvider({ children }: { children: React.ReactNode }) {
const [defaultZapComment, setDefaultZapComment] = useState(storage.getDefaultZapComment())
const [quickZap, setQuickZap] = useState(storage.getQuickZap())
const [zapReplyThreshold, setZapReplyThreshold] = useState(storage.getZapReplyThreshold())
+ const [includePublicZapReceipt, setIncludePublicZapReceipt] = useState(
+ storage.getIncludePublicZapReceipt()
+ )
const [isWalletConnected, setIsWalletConnected] = useState(false)
const [provider, setProvider] = useState(null)
const [walletInfo, setWalletInfo] = useState(null)
@@ -77,6 +82,11 @@ export function ZapProvider({ children }: { children: React.ReactNode }) {
setZapReplyThreshold(sats)
}
+ const updateIncludePublicZapReceipt = (include: boolean) => {
+ setIncludePublicZapReceipt(include)
+ void storage.setIncludePublicZapReceiptAsync(include)
+ }
+
return (
{children}
diff --git a/src/services/indexed-db.service.ts b/src/services/indexed-db.service.ts
index 4a3fbd05..84caa52a 100644
--- a/src/services/indexed-db.service.ts
+++ b/src/services/indexed-db.service.ts
@@ -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>[0]): Error {
@@ -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 {
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,
diff --git a/src/services/lightning.service.ts b/src/services/lightning.service.ts
index 733cbb36..e1c058d9 100644
--- a/src/services/lightning.service.ts
+++ b/src/services/lightning.service.ts
@@ -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 {
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 {
}
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 {
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 {
}
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)
diff --git a/src/services/local-storage.service.ts b/src/services/local-storage.service.ts
index 8e8fb500..ffc664f9 100644
--- a/src/services/local-storage.service.ts
+++ b/src/services/local-storage.service.ts
@@ -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 {
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 {
}
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 {
/** 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 {
+ try {
+ const idb = await loadIndexedDb()
+ await idb.setSetting(key, value)
+ } catch {
+ // IndexedDB unavailable; in-memory value still updated for this session
+ }
+ }
+
private initPromise: Promise | null = null
/**
@@ -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 {
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 {
+ this.includePublicZapReceipt = include
+ await this.persistSettingToIndexedDb(StorageKey.INCLUDE_PUBLIC_ZAP_RECEIPT, include.toString())
+ }
+
getZapReplyThreshold() {
return this.zapReplyThreshold
}