Browse Source

zap display as embedded events and replies and in feed. default threshold 210 sats

imwald
Silberengel 5 months ago
parent
commit
3eced2953b
  1. 54
      src/components/ContentPreview/ZapPreview.tsx
  2. 5
      src/components/ContentPreview/index.tsx
  3. 3
      src/components/KindFilter/index.tsx
  4. 80
      src/components/Note/Zap.tsx
  5. 7
      src/components/Note/index.tsx
  6. 26
      src/components/NoteList/index.tsx
  7. 128
      src/components/ReplyNoteList/index.tsx
  8. 8
      src/constants.ts
  9. 6
      src/i18n/locales/en.ts
  10. 26
      src/lib/event-metadata.ts
  11. 6
      src/lib/event.ts
  12. 46
      src/pages/secondary/WalletPage/ZapReplyThresholdInput.tsx
  13. 102
      src/pages/secondary/WalletPage/index.tsx
  14. 12
      src/providers/ZapProvider.tsx
  15. 27
      src/services/local-storage.service.ts

54
src/components/ContentPreview/ZapPreview.tsx

@ -0,0 +1,54 @@ @@ -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 (
<div className={cn('text-sm text-muted-foreground', className)}>
[{t('Invalid zap receipt')}]
</div>
)
}
const { senderPubkey, recipientPubkey, amount, comment } = zapInfo
return (
<div className={cn('flex items-start gap-3 p-3 rounded-lg border bg-card', className)}>
<Zap size={24} className="text-yellow-400 shrink-0 mt-0.5" fill="currentColor" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<Username userId={senderPubkey} className="font-semibold" />
<span className="text-muted-foreground text-sm">{t('zapped')}</span>
{recipientPubkey && recipientPubkey !== senderPubkey && (
<Username userId={recipientPubkey} className="font-semibold" />
)}
</div>
<div className="font-bold text-yellow-400 mt-1">
{formatAmount(amount)} {t('sats')}
</div>
{comment && (
<div className="text-sm text-muted-foreground mt-2 break-words">
{comment}
</div>
)}
{targetEvent && (
<div className="text-xs text-muted-foreground mt-2">
{t('on note')} {targetEvent.id.substring(0, 8)}...
</div>
)}
</div>
</div>
)
}

5
src/components/ContentPreview/index.tsx

@ -15,6 +15,7 @@ import NormalContentPreview from './NormalContentPreview' @@ -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({ @@ -106,5 +107,9 @@ export default function ContentPreview({
return <LiveEventPreview event={event} className={className} />
}
if (event.kind === ExtendedKind.ZAP_REQUEST || event.kind === ExtendedKind.ZAP_RECEIPT) {
return <ZapPreview event={event} className={className} />
}
return <div className={className}>[{t('Cannot handle event of kind k', { k: event.kind })}]</div>
}

3
src/components/KindFilter/index.tsx

@ -21,7 +21,8 @@ const KIND_FILTER_OPTIONS = [ @@ -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({

80
src/components/Note/Zap.tsx

@ -0,0 +1,80 @@ @@ -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 (
<div className={cn('text-sm text-muted-foreground p-4 border rounded-lg', className)}>
[{t('Invalid zap receipt')}]
</div>
)
}
const { senderPubkey, recipientPubkey, amount, comment } = zapInfo
return (
<div className={cn('relative border rounded-lg p-4 bg-gradient-to-br from-yellow-50/50 to-amber-50/50 dark:from-yellow-950/20 dark:to-amber-950/20', className)}>
{/* Zapped note/profile link in top-right corner */}
{(targetEvent || recipientPubkey) && (
<button
onClick={() => {
if (targetEvent) {
push(toNote(targetEvent.id))
} else if (recipientPubkey) {
push(toProfile(recipientPubkey))
}
}}
className="absolute top-2 right-2 text-xs text-muted-foreground hover:text-foreground hover:underline"
>
{targetEvent ? t('Zapped note') : t('Zapped profile')}
</button>
)}
<div className="flex items-start gap-3">
<ZapIcon size={28} className="text-yellow-500 shrink-0 mt-1" fill="currentColor" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap mb-2">
<UserAvatar userId={senderPubkey} size="small" />
<Username userId={senderPubkey} className="font-semibold" />
<span className="text-muted-foreground text-sm">{t('zapped')}</span>
{recipientPubkey && recipientPubkey !== senderPubkey && (
<>
<UserAvatar userId={recipientPubkey} size="small" />
<Username userId={recipientPubkey} className="font-semibold" />
</>
)}
</div>
<div className="flex items-baseline gap-2">
<span className="text-3xl font-bold text-yellow-600 dark:text-yellow-400">
{formatAmount(amount)}
</span>
<span className="text-lg font-semibold text-yellow-600/70 dark:text-yellow-400/70">
{t('sats')}
</span>
</div>
{comment && (
<div className="mt-3 text-sm bg-white/50 dark:bg-black/20 rounded-lg p-3 break-words">
{comment}
</div>
)}
</div>
</div>
</div>
)
}

7
src/components/Note/index.tsx

@ -32,6 +32,7 @@ import Poll from './Poll' @@ -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({ @@ -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 = <UnknownNote className="mt-2" event={event} />
@ -115,6 +118,8 @@ export default function Note({ @@ -115,6 +118,8 @@ export default function Note({
content = <RelayReview className="mt-2" event={event} />
} else if (event.kind === ExtendedKind.PUBLIC_MESSAGE) {
content = <Content className="mt-2" event={event} />
} else if (event.kind === ExtendedKind.ZAP_REQUEST || event.kind === ExtendedKind.ZAP_RECEIPT) {
content = <Zap className="mt-2" event={event} />
} else {
content = <Content className="mt-2" event={event} />
}

26
src/components/NoteList/index.tsx

@ -6,16 +6,18 @@ import { @@ -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( @@ -61,6 +63,7 @@ const NoteList = forwardRef(
const { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy()
const { isEventDeleted } = useDeletedEvent()
const { zapReplyThreshold } = useZap()
const [events, setEvents] = useState<Event[]>([])
const [newEvents, setNewEvents] = useState<Event[]>([])
const [hasMore, setHasMore] = useState<boolean>(true)
@ -75,7 +78,24 @@ const NoteList = forwardRef( @@ -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( @@ -88,7 +108,7 @@ const NoteList = forwardRef(
return false
},
[hideReplies, hideUntrustedNotes, mutePubkeySet, isEventDeleted]
[hideReplies, hideUntrustedNotes, mutePubkeySet, isEventDeleted, zapReplyThreshold]
)
const filteredEvents = useMemo(() => {

128
src/components/ReplyNoteList/index.tsx

@ -9,6 +9,7 @@ import { @@ -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' @@ -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 = @@ -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 @@ -42,6 +44,8 @@ export default function ReplyNoteList({ index, event, sort = 'oldest' }: { index
const { relayList: userRelayList } = useNostr()
const [rootInfo, setRootInfo] = useState<TRootInfo | undefined>(undefined)
const { repliesMap, addReplies } = useReply()
const [zapEvents, setZapEvents] = useState<NEvent[]>([])
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 @@ -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<string>()
const replyEvents: NEvent[] = []
const currentEventKey = isReplaceableEvent(event.kind)
@ -101,6 +109,46 @@ export default function ReplyNoteList({ index, event, sort = 'oldest' }: { index @@ -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 @@ -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<string | undefined>(undefined)
const [until, setUntil] = useState<number | undefined>(undefined)
const [loading, setLoading] = useState<boolean>(false)
@ -215,16 +263,18 @@ export default function ReplyNoteList({ index, event, sort = 'oldest' }: { index @@ -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<Filter, 'since' | 'until'> & {
limit: number
@ -273,6 +323,29 @@ export default function ReplyNoteList({ index, event, sort = 'oldest' }: { index @@ -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 @@ -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 @@ -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 @@ -426,3 +524,5 @@ export default function ReplyNoteList({ index, event, sort = 'oldest' }: { index
</div>
)
}
export default ReplyNoteList

8
src/constants.ts

@ -28,6 +28,7 @@ export const StorageKey = { @@ -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 = { @@ -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 = [ @@ -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 =

6
src/i18n/locales/en.ts

@ -390,6 +390,12 @@ export default { @@ -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',

26
src/lib/event-metadata.ts

@ -5,7 +5,7 @@ import { buildATag } from './draft-event' @@ -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) { @@ -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) { @@ -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) { @@ -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
}

6
src/lib/event.ts

@ -26,6 +26,12 @@ export function isReplyNoteEvent(event: Event) { @@ -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)

46
src/pages/secondary/WalletPage/ZapReplyThresholdInput.tsx

@ -0,0 +1,46 @@ @@ -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 (
<div className="w-full space-y-1">
<Label htmlFor="zap-reply-threshold-input">
<div className="text-base font-medium">{t('Zap reply threshold')}</div>
<div className="text-muted-foreground text-sm">
{t('Zaps above this amount will appear as replies in threads')}
</div>
</Label>
<div className="flex items-center gap-2">
<Input
id="zap-reply-threshold-input"
className="w-20"
value={zapReplyThresholdInput}
onChange={(e) => {
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)
}}
/>
<span className="text-sm text-muted-foreground shrink-0">{t('sats')}</span>
</div>
</div>
)
}

102
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 ZapReplyThresholdInput from './ZapReplyThresholdInput'
const WalletPage = forwardRef(({ index }: { index?: number }, ref) => {
const { t } = useTranslation()
@ -29,55 +30,60 @@ const WalletPage = forwardRef(({ index }: { index?: number }, ref) => { @@ -29,55 +30,60 @@ const WalletPage = forwardRef(({ index }: { index?: number }, ref) => {
return (
<SecondaryPageLayout ref={ref} index={index} title={t('Wallet')}>
{isWalletConnected ? (
<div className="px-4 pt-3 space-y-4">
<div>
{walletInfo?.node.alias && (
<div className="mb-2">
{t('Connected to')} <strong>{walletInfo.node.alias}</strong>
</div>
)}
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive">{t('Disconnect Wallet')}</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('Are you absolutely sure?')}</AlertDialogTitle>
<AlertDialogDescription>
{t('You will not be able to send zaps to others.')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('Cancel')}</AlertDialogCancel>
<AlertDialogAction variant="destructive" onClick={() => disconnect()}>
{t('Disconnect')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<div className="px-4 pt-3 space-y-4">
{isWalletConnected ? (
<>
<div>
{walletInfo?.node.alias && (
<div className="mb-2">
{t('Connected to')} <strong>{walletInfo.node.alias}</strong>
</div>
)}
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive">{t('Disconnect Wallet')}</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('Are you absolutely sure?')}</AlertDialogTitle>
<AlertDialogDescription>
{t('You will not be able to send zaps to others.')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('Cancel')}</AlertDialogCancel>
<AlertDialogAction variant="destructive" onClick={() => disconnect()}>
{t('Disconnect')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
<DefaultZapAmountInput />
<DefaultZapCommentInput />
<QuickZapSwitch />
<LightningAddressInput />
</>
) : (
<div className="flex items-center gap-2">
<Button className="bg-foreground hover:bg-foreground/90" onClick={() => push(toRizful())}>
{t('Start with a Rizful Vault')}
</Button>
<Button
variant="link"
className="text-muted-foreground hover:text-foreground px-0"
onClick={() => {
launchModal()
}}
>
{t('or other wallets')}
</Button>
</div>
<DefaultZapAmountInput />
<DefaultZapCommentInput />
<QuickZapSwitch />
<LightningAddressInput />
</div>
) : (
<div className="px-4 pt-3 flex items-center gap-2">
<Button className="bg-foreground hover:bg-foreground/90" onClick={() => push(toRizful())}>
{t('Start with a Rizful Vault')}
</Button>
<Button
variant="link"
className="text-muted-foreground hover:text-foreground px-0"
onClick={() => {
launchModal()
}}
>
{t('or other wallets')}
</Button>
</div>
)}
)}
{/* Zap Reply Threshold - always visible as it's just a display setting */}
<ZapReplyThresholdInput />
</div>
</SecondaryPageLayout>
)
})

12
src/providers/ZapProvider.tsx

@ -14,6 +14,8 @@ type TZapContext = { @@ -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<TZapContext | undefined>(undefined)
@ -30,6 +32,7 @@ export function ZapProvider({ children }: { children: React.ReactNode }) { @@ -30,6 +32,7 @@ export function ZapProvider({ children }: { children: React.ReactNode }) {
const [defaultZapSats, setDefaultZapSats] = useState<number>(storage.getDefaultZapSats())
const [defaultZapComment, setDefaultZapComment] = useState<string>(storage.getDefaultZapComment())
const [quickZap, setQuickZap] = useState<boolean>(storage.getQuickZap())
const [zapReplyThreshold, setZapReplyThreshold] = useState<number>(storage.getZapReplyThreshold())
const [isWalletConnected, setIsWalletConnected] = useState(false)
const [provider, setProvider] = useState<WebLNProvider | null>(null)
const [walletInfo, setWalletInfo] = useState<GetInfoResponse | null>(null)
@ -69,6 +72,11 @@ export function ZapProvider({ children }: { children: React.ReactNode }) { @@ -69,6 +72,11 @@ export function ZapProvider({ children }: { children: React.ReactNode }) {
setQuickZap(quickZap)
}
const updateZapReplyThreshold = (sats: number) => {
storage.setZapReplyThreshold(sats)
setZapReplyThreshold(sats)
}
return (
<ZapContext.Provider
value={{
@ -80,7 +88,9 @@ export function ZapProvider({ children }: { children: React.ReactNode }) { @@ -80,7 +88,9 @@ export function ZapProvider({ children }: { children: React.ReactNode }) {
defaultZapComment,
updateDefaultComment,
quickZap,
updateQuickZap
updateQuickZap,
zapReplyThreshold,
updateZapReplyThreshold
}}
>
{children}

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

@ -6,6 +6,7 @@ import { @@ -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 { @@ -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<string, TFeedInfo | undefined> = {}
private mediaUploadService: string = DEFAULT_NIP_96_SERVICE
private autoplay: boolean = true
@ -106,6 +108,14 @@ class LocalStorageService { @@ -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 { @@ -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 { @@ -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 { @@ -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
}

Loading…
Cancel
Save