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 }