From 3eced2953be038711e95af35ee1383256baa407d Mon Sep 17 00:00:00 2001 From: Silberengel Date: Fri, 10 Oct 2025 20:27:51 +0200 Subject: [PATCH] zap display as embedded events and replies and in feed. default threshold 210 sats --- src/components/ContentPreview/ZapPreview.tsx | 54 ++++++++ src/components/ContentPreview/index.tsx | 5 + src/components/KindFilter/index.tsx | 3 +- src/components/Note/Zap.tsx | 80 +++++++++++ src/components/Note/index.tsx | 7 +- src/components/NoteList/index.tsx | 26 +++- src/components/ReplyNoteList/index.tsx | 128 ++++++++++++++++-- src/constants.ts | 8 +- src/i18n/locales/en.ts | 6 + src/lib/event-metadata.ts | 26 +++- src/lib/event.ts | 6 + .../WalletPage/ZapReplyThresholdInput.tsx | 46 +++++++ src/pages/secondary/WalletPage/index.tsx | 102 +++++++------- src/providers/ZapProvider.tsx | 12 +- src/services/local-storage.service.ts | 27 +++- 15 files changed, 462 insertions(+), 74 deletions(-) create mode 100644 src/components/ContentPreview/ZapPreview.tsx create mode 100644 src/components/Note/Zap.tsx create mode 100644 src/pages/secondary/WalletPage/ZapReplyThresholdInput.tsx diff --git a/src/components/ContentPreview/ZapPreview.tsx b/src/components/ContentPreview/ZapPreview.tsx new file mode 100644 index 0000000..329b649 --- /dev/null +++ b/src/components/ContentPreview/ZapPreview.tsx @@ -0,0 +1,54 @@ +import { useFetchEvent } from '@/hooks' +import { getZapInfoFromEvent } from '@/lib/event-metadata' +import { formatAmount } from '@/lib/lightning' +import { cn } from '@/lib/utils' +import { Zap } from 'lucide-react' +import { Event } from 'nostr-tools' +import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import Username from '../Username' + +export default function ZapPreview({ event, className }: { event: Event; className?: string }) { + const { t } = useTranslation() + const zapInfo = useMemo(() => getZapInfoFromEvent(event), [event]) + const { event: targetEvent } = useFetchEvent(zapInfo?.eventId) + + if (!zapInfo || !zapInfo.senderPubkey || !zapInfo.amount) { + return ( +
+ [{t('Invalid zap receipt')}] +
+ ) + } + + const { senderPubkey, recipientPubkey, amount, comment } = zapInfo + + return ( +
+ +
+
+ + {t('zapped')} + {recipientPubkey && recipientPubkey !== senderPubkey && ( + + )} +
+
+ {formatAmount(amount)} {t('sats')} +
+ {comment && ( +
+ {comment} +
+ )} + {targetEvent && ( +
+ {t('on note')} {targetEvent.id.substring(0, 8)}... +
+ )} +
+
+ ) +} + diff --git a/src/components/ContentPreview/index.tsx b/src/components/ContentPreview/index.tsx index 2c90fac..8b4b8bd 100644 --- a/src/components/ContentPreview/index.tsx +++ b/src/components/ContentPreview/index.tsx @@ -15,6 +15,7 @@ import NormalContentPreview from './NormalContentPreview' import PictureNotePreview from './PictureNotePreview' import PollPreview from './PollPreview' import VideoNotePreview from './VideoNotePreview' +import ZapPreview from './ZapPreview' import DiscussionNote from '../DiscussionNote' export default function ContentPreview({ @@ -106,5 +107,9 @@ export default function ContentPreview({ return } + if (event.kind === ExtendedKind.ZAP_REQUEST || event.kind === ExtendedKind.ZAP_RECEIPT) { + return + } + return
[{t('Cannot handle event of kind k', { k: event.kind })}]
} diff --git a/src/components/KindFilter/index.tsx b/src/components/KindFilter/index.tsx index 041af8a..78d146c 100644 --- a/src/components/KindFilter/index.tsx +++ b/src/components/KindFilter/index.tsx @@ -21,7 +21,8 @@ const KIND_FILTER_OPTIONS = [ { kindGroup: [ExtendedKind.VOICE, ExtendedKind.VOICE_COMMENT], label: 'Voice Posts' }, { kindGroup: [ExtendedKind.PICTURE], label: 'Photo Posts' }, { kindGroup: [ExtendedKind.VIDEO, ExtendedKind.SHORT_VIDEO], label: 'Video Posts' }, - { kindGroup: [ExtendedKind.DISCUSSION], label: 'Discussions' } + { kindGroup: [ExtendedKind.DISCUSSION], label: 'Discussions' }, + { kindGroup: [ExtendedKind.ZAP_RECEIPT], label: 'Zaps' } ] export default function KindFilter({ diff --git a/src/components/Note/Zap.tsx b/src/components/Note/Zap.tsx new file mode 100644 index 0000000..a238935 --- /dev/null +++ b/src/components/Note/Zap.tsx @@ -0,0 +1,80 @@ +import { useFetchEvent } from '@/hooks' +import { getZapInfoFromEvent } from '@/lib/event-metadata' +import { formatAmount } from '@/lib/lightning' +import { toNote, toProfile } from '@/lib/link' +import { cn } from '@/lib/utils' +import { Zap as ZapIcon } from 'lucide-react' +import { Event } from 'nostr-tools' +import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { useSecondaryPage } from '@/PageManager' +import Username from '../Username' +import UserAvatar from '../UserAvatar' + +export default function Zap({ event, className }: { event: Event; className?: string }) { + const { t } = useTranslation() + const { push } = useSecondaryPage() + const zapInfo = useMemo(() => getZapInfoFromEvent(event), [event]) + const { event: targetEvent } = useFetchEvent(zapInfo?.eventId) + + if (!zapInfo || !zapInfo.senderPubkey || !zapInfo.amount) { + return ( +
+ [{t('Invalid zap receipt')}] +
+ ) + } + + const { senderPubkey, recipientPubkey, amount, comment } = zapInfo + + return ( +
+ {/* Zapped note/profile link in top-right corner */} + {(targetEvent || recipientPubkey) && ( + + )} + +
+ +
+
+ + + {t('zapped')} + {recipientPubkey && recipientPubkey !== senderPubkey && ( + <> + + + + )} +
+
+ + {formatAmount(amount)} + + + {t('sats')} + +
+ {comment && ( +
+ {comment} +
+ )} +
+
+
+ ) +} + diff --git a/src/components/Note/index.tsx b/src/components/Note/index.tsx index a169818..f5dfd47 100644 --- a/src/components/Note/index.tsx +++ b/src/components/Note/index.tsx @@ -32,6 +32,7 @@ import Poll from './Poll' import UnknownNote from './UnknownNote' import VideoNote from './VideoNote' import RelayReview from './RelayReview' +import Zap from './Zap' export default function Note({ event, @@ -67,7 +68,9 @@ export default function Note({ kinds.CommunityDefinition, kinds.LiveEvent, ExtendedKind.GROUP_METADATA, - ExtendedKind.PUBLIC_MESSAGE + ExtendedKind.PUBLIC_MESSAGE, + ExtendedKind.ZAP_REQUEST, + ExtendedKind.ZAP_RECEIPT ].includes(event.kind) ) { content = @@ -115,6 +118,8 @@ export default function Note({ content = } else if (event.kind === ExtendedKind.PUBLIC_MESSAGE) { content = + } else if (event.kind === ExtendedKind.ZAP_REQUEST || event.kind === ExtendedKind.ZAP_RECEIPT) { + content = } else { content = } diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index 5e79512..0cd8c8c 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -6,16 +6,18 @@ import { isReplaceableEvent, isReplyNoteEvent } from '@/lib/event' +import { getZapInfoFromEvent } from '@/lib/event-metadata' import { isTouchDevice } from '@/lib/utils' import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useDeletedEvent } from '@/providers/DeletedEventProvider' import { useMuteList } from '@/providers/MuteListProvider' import { useNostr } from '@/providers/NostrProvider' import { useUserTrust } from '@/providers/UserTrustProvider' +import { useZap } from '@/providers/ZapProvider' import client from '@/services/client.service' import { TFeedSubRequest } from '@/types' import dayjs from 'dayjs' -import { Event } from 'nostr-tools' +import { Event, kinds } from 'nostr-tools' import { forwardRef, useCallback, @@ -61,6 +63,7 @@ const NoteList = forwardRef( const { mutePubkeySet } = useMuteList() const { hideContentMentioningMutedUsers } = useContentPolicy() const { isEventDeleted } = useDeletedEvent() + const { zapReplyThreshold } = useZap() const [events, setEvents] = useState([]) const [newEvents, setNewEvents] = useState([]) const [hasMore, setHasMore] = useState(true) @@ -75,7 +78,24 @@ const NoteList = forwardRef( const shouldHideEvent = useCallback( (evt: Event) => { if (isEventDeleted(evt)) return true - if (hideReplies && isReplyNoteEvent(evt)) return true + + // Special handling for zaps - always check threshold, then check hideReplies for non-zap replies + if (evt.kind === kinds.Zap) { + console.log(`[NoteList] Processing zap ${evt.id.slice(0, 8)}: isReply=${isReplyNoteEvent(evt)}, hideReplies=${hideReplies}`) + const zapInfo = getZapInfoFromEvent(evt) + console.log(`[NoteList] Zap ${evt.id.slice(0, 8)}: amount=${zapInfo?.amount} sats, threshold=${zapReplyThreshold}`) + + // Always filter zaps by threshold regardless of hideReplies setting + if (zapInfo && zapInfo.amount < zapReplyThreshold) { + console.log(`[NoteList] HIDING zap ${evt.id.slice(0, 8)}: ${zapInfo.amount} < ${zapReplyThreshold} (threshold filter)`) + return true + } else { + console.log(`[NoteList] SHOWING zap ${evt.id.slice(0, 8)}: ${zapInfo?.amount} >= ${zapReplyThreshold}`) + } + } else if (hideReplies && isReplyNoteEvent(evt)) { + return true + } + if (hideUntrustedNotes && !isUserTrusted(evt.pubkey)) return true if (filterMutedNotes && mutePubkeySet.has(evt.pubkey)) return true if ( @@ -88,7 +108,7 @@ const NoteList = forwardRef( return false }, - [hideReplies, hideUntrustedNotes, mutePubkeySet, isEventDeleted] + [hideReplies, hideUntrustedNotes, mutePubkeySet, isEventDeleted, zapReplyThreshold] ) const filteredEvents = useMemo(() => { diff --git a/src/components/ReplyNoteList/index.tsx b/src/components/ReplyNoteList/index.tsx index e587d83..e187948 100644 --- a/src/components/ReplyNoteList/index.tsx +++ b/src/components/ReplyNoteList/index.tsx @@ -9,6 +9,7 @@ import { isReplaceableEvent, isReplyNoteEvent } from '@/lib/event' +import { getZapInfoFromEvent } from '@/lib/event-metadata' import { toNote } from '@/lib/link' import { generateBech32IdFromETag, tagNameEquals } from '@/lib/tag' import { useSecondaryPage } from '@/PageManager' @@ -17,6 +18,7 @@ import { useMuteList } from '@/providers/MuteListProvider' import { useNostr } from '@/providers/NostrProvider' import { useReply } from '@/providers/ReplyProvider' import { useUserTrust } from '@/providers/UserTrustProvider' +import { useZap } from '@/providers/ZapProvider' import client from '@/services/client.service' import noteStatsService from '@/services/note-stats.service' import { Filter, Event as NEvent, kinds } from 'nostr-tools' @@ -33,7 +35,7 @@ type TRootInfo = const LIMIT = 100 const SHOW_COUNT = 10 -export default function ReplyNoteList({ index, event, sort = 'oldest' }: { index?: number; event: NEvent; sort?: 'newest' | 'oldest' | 'top' | 'controversial' | 'most-zapped' }) { +function ReplyNoteList({ index, event, sort = 'oldest' }: { index?: number; event: NEvent; sort?: 'newest' | 'oldest' | 'top' | 'controversial' | 'most-zapped' }) { const { t } = useTranslation() const { push, currentIndex } = useSecondaryPage() const { hideUntrustedInteractions, isUserTrusted } = useUserTrust() @@ -42,6 +44,8 @@ export default function ReplyNoteList({ index, event, sort = 'oldest' }: { index const { relayList: userRelayList } = useNostr() const [rootInfo, setRootInfo] = useState(undefined) const { repliesMap, addReplies } = useReply() + const [zapEvents, setZapEvents] = useState([]) + const { zapReplyThreshold } = useZap() // Helper function to get vote score for a reply const getReplyVoteScore = (reply: NEvent) => { @@ -83,6 +87,10 @@ export default function ReplyNoteList({ index, event, sort = 'oldest' }: { index return totalAmount } const replies = useMemo(() => { + console.log(`[ReplyNoteList] Processing replies for event ${event.id.slice(0, 8)}...`) + console.log(`[ReplyNoteList] zapEvents.length: ${zapEvents.length}`) + console.log(`[ReplyNoteList] zapReplyThreshold: ${zapReplyThreshold}`) + const replyIdSet = new Set() const replyEvents: NEvent[] = [] const currentEventKey = isReplaceableEvent(event.kind) @@ -101,6 +109,46 @@ export default function ReplyNoteList({ index, event, sort = 'oldest' }: { index }) parentEventKeys = events.map((evt) => evt.id) } + + // Add zap receipts that are above the threshold + console.log(`========== ZAP FILTERING START ==========`) + console.log(`Processing ${zapEvents.length} zap events with threshold ${zapReplyThreshold} sats`) + zapEvents.forEach((zapEvt) => { + console.log(`\n--- Processing zap: ${zapEvt.id.slice(0, 8)}... ---`) + console.log(`Created: ${new Date(zapEvt.created_at * 1000).toISOString()}`) + + if (replyIdSet.has(zapEvt.id)) { + console.log(`❌ Already in set, skipping`) + return + } + if (mutePubkeySet.has(zapEvt.pubkey)) { + console.log(`❌ From muted user, skipping`) + return + } + + const zapInfo = getZapInfoFromEvent(zapEvt) + + if (!zapInfo) { + console.log(`❌ No valid zapInfo`) + return + } + + console.log(`💰 Zap amount: ${zapInfo.amount} sats`) + console.log(`🎯 Threshold: ${zapReplyThreshold} sats`) + console.log(`🔢 Comparison: ${zapInfo.amount} >= ${zapReplyThreshold} = ${zapInfo.amount >= zapReplyThreshold}`) + + if (zapInfo.amount >= zapReplyThreshold) { + console.log(`✅ PASSED - Adding to replies`) + replyIdSet.add(zapEvt.id) + replyEvents.push(zapEvt) + } else { + console.log(`❌ FILTERED OUT - ${zapInfo.amount} < ${zapReplyThreshold}`) + } + }) + console.log(`\n========== ZAP FILTERING END ==========`) + console.log(`Total zaps that passed: ${replyEvents.filter(e => e.kind === kinds.Zap).length}`) + console.log(`Total reply events: ${replyEvents.length}`) + // Apply sorting based on the sort parameter switch (sort) { case 'oldest': @@ -140,7 +188,7 @@ export default function ReplyNoteList({ index, event, sort = 'oldest' }: { index default: return replyEvents.sort((a, b) => b.created_at - a.created_at) } - }, [event.id, repliesMap, sort]) + }, [event.id, repliesMap, zapEvents, zapReplyThreshold, mutePubkeySet, hideContentMentioningMutedUsers, sort]) const [timelineKey, setTimelineKey] = useState(undefined) const [until, setUntil] = useState(undefined) const [loading, setLoading] = useState(false) @@ -215,16 +263,18 @@ export default function ReplyNoteList({ index, event, sort = 'oldest' }: { index useEffect(() => { if (loading || !rootInfo || currentIndex !== index) return - const init = async () => { - setLoading(true) + const init = async () => { + setLoading(true) - try { - // Privacy: Only use user's own relays + defaults, never connect to other users' relays - const userRelays = userRelayList?.read || [] - const finalRelayUrls = Array.from(new Set([ - ...FAST_READ_RELAY_URLS, // Fast, well-connected relays - ...userRelays // User's mailbox relays - ])) + try { + console.log(`[ReplyNoteList] Starting init with rootInfo:`, rootInfo) + + // Privacy: Only use user's own relays + defaults, never connect to other users' relays + const userRelays = userRelayList?.read || [] + const finalRelayUrls = Array.from(new Set([ + ...FAST_READ_RELAY_URLS, // Fast, well-connected relays + ...userRelays // User's mailbox relays + ])) const filters: (Omit & { limit: number @@ -273,6 +323,29 @@ export default function ReplyNoteList({ index, event, sort = 'oldest' }: { index limit: LIMIT }) } + + // Fetch zap receipts for the event + if (rootInfo.type === 'E') { + console.log(`[ReplyNoteList] Adding zap filter for E type: #e=[${rootInfo.id}], kinds=[${kinds.Zap}]`) + filters.push({ + '#e': [rootInfo.id], + kinds: [kinds.Zap], + limit: LIMIT + }) + } else if (rootInfo.type === 'A') { + console.log(`[ReplyNoteList] Adding zap filter for A type: #a=[${rootInfo.id}], kinds=[${kinds.Zap}]`) + filters.push({ + '#a': [rootInfo.id], + kinds: [kinds.Zap], + limit: LIMIT + }) + } + + console.log(`[ReplyNoteList] Total filters: ${filters.length}`) + filters.forEach((filter, i) => { + console.log(`[ReplyNoteList] Filter ${i}:`, filter) + }) + const { closer, timelineKey } = await client.subscribeTimeline( filters.map((filter) => ({ urls: finalRelayUrls.slice(0, 8), // Increased from 5 to 8 for better coverage @@ -281,7 +354,21 @@ export default function ReplyNoteList({ index, event, sort = 'oldest' }: { index { onEvents: (evts, eosed) => { if (evts.length > 0) { - addReplies(evts.filter((evt) => isReplyNoteEvent(evt))) + const regularReplies = evts.filter((evt) => isReplyNoteEvent(evt)) + const zaps = evts.filter((evt) => evt.kind === kinds.Zap) + + console.log(`[ReplyNoteList] Received ${evts.length} events: ${regularReplies.length} regular replies, ${zaps.length} zaps`) + + addReplies(regularReplies) + if (zaps.length > 0) { + console.log(`[ReplyNoteList] Adding ${zaps.length} new zap events`) + setZapEvents(prev => { + const zapIdSet = new Set(prev.map(z => z.id)) + const newZaps = zaps.filter(z => !zapIdSet.has(z.id)) + console.log(`[ReplyNoteList] ${newZaps.length} are actually new (not duplicates)`) + return [...prev, ...newZaps] + }) + } } if (eosed) { setUntil(evts.length >= LIMIT ? evts[evts.length - 1].created_at - 1 : undefined) @@ -289,8 +376,19 @@ export default function ReplyNoteList({ index, event, sort = 'oldest' }: { index } }, onNew: (evt) => { - if (!isReplyNoteEvent(evt)) return - addReplies([evt]) + if (evt.kind === kinds.Zap) { + console.log(`[ReplyNoteList] New zap event received: ${evt.id.slice(0, 8)}...`) + setZapEvents(prev => { + if (prev.some(z => z.id === evt.id)) { + console.log(`[ReplyNoteList] Zap ${evt.id.slice(0, 8)} already exists, skipping`) + return prev + } + console.log(`[ReplyNoteList] Adding new zap: ${evt.id.slice(0, 8)}...`) + return [...prev, evt] + }) + } else if (isReplyNoteEvent(evt)) { + addReplies([evt]) + } } } ) @@ -426,3 +524,5 @@ export default function ReplyNoteList({ index, event, sort = 'oldest' }: { index ) } + +export default ReplyNoteList diff --git a/src/constants.ts b/src/constants.ts index 47c87a6..ae14853 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -28,6 +28,7 @@ export const StorageKey = { DEFAULT_ZAP_SATS: 'defaultZapSats', DEFAULT_ZAP_COMMENT: 'defaultZapComment', QUICK_ZAP: 'quickZap', + ZAP_REPLY_THRESHOLD: 'zapReplyThreshold', LAST_READ_NOTIFICATION_TIME_MAP: 'lastReadNotificationTimeMap', ACCOUNT_FEED_INFO_MAP: 'accountFeedInfoMap', AUTOPLAY: 'autoplay', @@ -125,7 +126,9 @@ export const ExtendedKind = { FAVORITE_RELAYS: 10012, BLOSSOM_SERVER_LIST: 10063, RELAY_REVIEW: 31987, - GROUP_METADATA: 39000 + GROUP_METADATA: 39000, + ZAP_REQUEST: 9734, + ZAP_RECEIPT: 9735 } export const SUPPORTED_KINDS = [ @@ -142,7 +145,8 @@ export const SUPPORTED_KINDS = [ kinds.Highlights, kinds.LongFormArticle, ExtendedKind.RELAY_REVIEW, - ExtendedKind.DISCUSSION + ExtendedKind.DISCUSSION, + ExtendedKind.ZAP_RECEIPT ] export const URL_REGEX = diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 89d28f4..968c95e 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -390,6 +390,12 @@ export default { 'reposted your note': 'reposted your note', 'zapped your note': 'zapped your note', 'zapped you': 'zapped you', + zapped: 'zapped', + 'Invalid zap receipt': 'Invalid zap receipt', + 'Zapped note': 'Zapped note', + 'Zapped profile': 'Zapped profile', + 'Zap reply threshold': 'Zap reply threshold', + 'Zaps above this amount will appear as replies in threads': 'Zaps above this amount will appear as replies in threads', 'Mark as read': 'Mark as read', Report: 'Report', 'Successfully report': 'Successfully reported', diff --git a/src/lib/event-metadata.ts b/src/lib/event-metadata.ts index f417f7e..8ef8174 100644 --- a/src/lib/event-metadata.ts +++ b/src/lib/event-metadata.ts @@ -5,7 +5,7 @@ import { buildATag } from './draft-event' import { getReplaceableEventIdentifier } from './event' import { getAmountFromInvoice, getLightningAddressFromProfile } from './lightning' import { formatPubkey, pubkeyToNpub } from './pubkey' -import { generateBech32IdFromETag, tagNameEquals } from './tag' +import { generateBech32IdFromATag, generateBech32IdFromETag, tagNameEquals } from './tag' import { isWebsocketUrl, normalizeHttpUrl, normalizeUrl } from './url' import { isTorBrowser } from './utils' @@ -121,6 +121,10 @@ export function getZapInfoFromEvent(receiptEvent: Event) { originalEventId = tag[1] eventId = generateBech32IdFromETag(tag) break + case 'a': + originalEventId = tag[1] + eventId = generateBech32IdFromATag(tag) + break case 'bolt11': invoice = tagValue break @@ -133,7 +137,14 @@ export function getZapInfoFromEvent(receiptEvent: Event) { } }) if (!recipientPubkey || !invoice) return null - amount = invoice ? getAmountFromInvoice(invoice) : 0 + + // Try to parse amount from invoice, fallback to description if invoice is invalid + try { + amount = getAmountFromInvoice(invoice) + } catch { + amount = 0 + } + if (description) { try { const zapRequest = JSON.parse(description) @@ -141,6 +152,17 @@ export function getZapInfoFromEvent(receiptEvent: Event) { if (!senderPubkey) { senderPubkey = zapRequest.pubkey } + // If invoice parsing failed, try to get amount from zap request tags + if (amount === 0 && zapRequest.tags) { + const amountTag = zapRequest.tags.find((tag: string[]) => tag[0] === 'amount') + if (amountTag && amountTag[1]) { + const millisats = parseInt(amountTag[1]) + amount = millisats / 1000 // Convert millisats to sats + console.log(`📝 Parsed amount from description tag: ${amountTag[1]} millisats = ${amount} sats`) + } + } else if (amount > 0) { + console.log(`📝 Parsed amount from invoice: ${amount} sats`) + } } catch { // ignore } diff --git a/src/lib/event.ts b/src/lib/event.ts index 858867f..efa7821 100644 --- a/src/lib/event.ts +++ b/src/lib/event.ts @@ -26,6 +26,12 @@ export function isReplyNoteEvent(event: Event) { if ([ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT].includes(event.kind)) { return true } + + // Zap receipts are considered replies if they have an 'e' tag (zapping a note) or 'a' tag (zapping an addressable event) + if (event.kind === kinds.Zap) { + return event.tags.some(tag => tag[0] === 'e' || tag[0] === 'a') + } + if (event.kind !== kinds.ShortTextNote) return false const cache = EVENT_IS_REPLY_NOTE_CACHE.get(event.id) diff --git a/src/pages/secondary/WalletPage/ZapReplyThresholdInput.tsx b/src/pages/secondary/WalletPage/ZapReplyThresholdInput.tsx new file mode 100644 index 0000000..6f13860 --- /dev/null +++ b/src/pages/secondary/WalletPage/ZapReplyThresholdInput.tsx @@ -0,0 +1,46 @@ +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { useZap } from '@/providers/ZapProvider' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' + +export default function ZapReplyThresholdInput() { + const { t } = useTranslation() + const { zapReplyThreshold, updateZapReplyThreshold } = useZap() + const [zapReplyThresholdInput, setZapReplyThresholdInput] = useState(zapReplyThreshold) + + return ( +
+ +
+ { + setZapReplyThresholdInput((pre) => { + if (e.target.value === '') { + return 0 + } + let num = parseInt(e.target.value, 10) + if (isNaN(num) || num < 0) { + num = pre + } + return num + }) + }} + onBlur={() => { + updateZapReplyThreshold(zapReplyThresholdInput) + }} + /> + {t('sats')} +
+
+ ) +} + diff --git a/src/pages/secondary/WalletPage/index.tsx b/src/pages/secondary/WalletPage/index.tsx index 154e88e..0f824b4 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 ZapReplyThresholdInput from './ZapReplyThresholdInput' const WalletPage = forwardRef(({ index }: { index?: number }, ref) => { const { t } = useTranslation() @@ -29,55 +30,60 @@ const WalletPage = forwardRef(({ index }: { index?: number }, ref) => { return ( - {isWalletConnected ? ( -
-
- {walletInfo?.node.alias && ( -
- {t('Connected to')} {walletInfo.node.alias} -
- )} - - - - - - - {t('Are you absolutely sure?')} - - {t('You will not be able to send zaps to others.')} - - - - {t('Cancel')} - disconnect()}> - {t('Disconnect')} - - - - +
+ {isWalletConnected ? ( + <> +
+ {walletInfo?.node.alias && ( +
+ {t('Connected to')} {walletInfo.node.alias} +
+ )} + + + + + + + {t('Are you absolutely sure?')} + + {t('You will not be able to send zaps to others.')} + + + + {t('Cancel')} + disconnect()}> + {t('Disconnect')} + + + + +
+ + + + + + ) : ( +
+ +
- - - - -
- ) : ( -
- - -
- )} + )} + + {/* Zap Reply Threshold - always visible as it's just a display setting */} + +
) }) diff --git a/src/providers/ZapProvider.tsx b/src/providers/ZapProvider.tsx index 51fa519..da0b9e9 100644 --- a/src/providers/ZapProvider.tsx +++ b/src/providers/ZapProvider.tsx @@ -14,6 +14,8 @@ type TZapContext = { updateDefaultComment: (comment: string) => void quickZap: boolean updateQuickZap: (quickZap: boolean) => void + zapReplyThreshold: number + updateZapReplyThreshold: (sats: number) => void } const ZapContext = createContext(undefined) @@ -30,6 +32,7 @@ export function ZapProvider({ children }: { children: React.ReactNode }) { const [defaultZapSats, setDefaultZapSats] = useState(storage.getDefaultZapSats()) const [defaultZapComment, setDefaultZapComment] = useState(storage.getDefaultZapComment()) const [quickZap, setQuickZap] = useState(storage.getQuickZap()) + const [zapReplyThreshold, setZapReplyThreshold] = useState(storage.getZapReplyThreshold()) const [isWalletConnected, setIsWalletConnected] = useState(false) const [provider, setProvider] = useState(null) const [walletInfo, setWalletInfo] = useState(null) @@ -69,6 +72,11 @@ export function ZapProvider({ children }: { children: React.ReactNode }) { setQuickZap(quickZap) } + const updateZapReplyThreshold = (sats: number) => { + storage.setZapReplyThreshold(sats) + setZapReplyThreshold(sats) + } + return ( {children} diff --git a/src/services/local-storage.service.ts b/src/services/local-storage.service.ts index c04f395..ea128bc 100644 --- a/src/services/local-storage.service.ts +++ b/src/services/local-storage.service.ts @@ -6,6 +6,7 @@ import { SUPPORTED_KINDS, StorageKey } from '@/constants' +import { kinds } from 'nostr-tools' import { isSameAccount } from '@/lib/account' import { randomString } from '@/lib/random' import { @@ -33,6 +34,7 @@ class LocalStorageService { private defaultZapSats: number = 21 private defaultZapComment: string = 'Zap!' private quickZap: boolean = false + private zapReplyThreshold: number = 210 private accountFeedInfoMap: Record = {} private mediaUploadService: string = DEFAULT_NIP_96_SERVICE private autoplay: boolean = true @@ -106,6 +108,14 @@ class LocalStorageService { this.defaultZapComment = window.localStorage.getItem(StorageKey.DEFAULT_ZAP_COMMENT) ?? 'Zap!' this.quickZap = window.localStorage.getItem(StorageKey.QUICK_ZAP) === 'true' + const zapReplyThresholdStr = window.localStorage.getItem(StorageKey.ZAP_REPLY_THRESHOLD) + if (zapReplyThresholdStr) { + const num = parseInt(zapReplyThresholdStr) + if (!isNaN(num)) { + this.zapReplyThreshold = num + } + } + const accountFeedInfoMapStr = window.localStorage.getItem(StorageKey.ACCOUNT_FEED_INFO_MAP) ?? '{}' this.accountFeedInfoMap = JSON.parse(accountFeedInfoMapStr) @@ -156,7 +166,8 @@ class LocalStorageService { const showKindsStr = window.localStorage.getItem(StorageKey.SHOW_KINDS) if (!showKindsStr) { - this.showKinds = SUPPORTED_KINDS + // Default: show all supported kinds except reposts + this.showKinds = SUPPORTED_KINDS.filter(kind => kind !== kinds.Repost) } else { const showKindsVersionStr = window.localStorage.getItem(StorageKey.SHOW_KINDS_VERSION) const showKindsVersion = showKindsVersionStr ? parseInt(showKindsVersionStr) : 0 @@ -164,10 +175,13 @@ class LocalStorageService { if (showKindsVersion < 1) { showKinds.push(ExtendedKind.VIDEO, ExtendedKind.SHORT_VIDEO) } + if (showKindsVersion < 2) { + showKinds.push(ExtendedKind.ZAP_RECEIPT) + } this.showKinds = showKinds } window.localStorage.setItem(StorageKey.SHOW_KINDS, JSON.stringify(this.showKinds)) - window.localStorage.setItem(StorageKey.SHOW_KINDS_VERSION, '1') + window.localStorage.setItem(StorageKey.SHOW_KINDS_VERSION, '2') this.hideContentMentioningMutedUsers = window.localStorage.getItem(StorageKey.HIDE_CONTENT_MENTIONING_MUTED_USERS) === 'true' @@ -310,6 +324,15 @@ class LocalStorageService { window.localStorage.setItem(StorageKey.QUICK_ZAP, quickZap.toString()) } + getZapReplyThreshold() { + return this.zapReplyThreshold + } + + setZapReplyThreshold(sats: number) { + this.zapReplyThreshold = sats + window.localStorage.setItem(StorageKey.ZAP_REPLY_THRESHOLD, sats.toString()) + } + getLastReadNotificationTime(pubkey: string) { return this.lastReadNotificationTimeMap[pubkey] ?? 0 }