Browse Source

get rid of zap thresholds

reorg feed filter and add superchats
imwald
Silberengel 3 weeks ago
parent
commit
c67c74112b
  1. 101
      src/components/KindFilter/index.tsx
  2. 26
      src/components/NoteList/index.tsx
  3. 29
      src/components/ReplyNoteList/index.tsx
  4. 1
      src/constants.ts
  5. 73
      src/hooks/useFeedAttestedSuperchatIds.ts
  6. 11
      src/i18n/locales/de.ts
  7. 11
      src/i18n/locales/en.ts
  8. 6
      src/lib/btc-usd-rate.ts
  9. 13
      src/lib/event-metadata.ts
  10. 91
      src/lib/feed-kind-filter.test.ts
  11. 88
      src/lib/feed-kind-filter.ts
  12. 8
      src/lib/payment-attestation-cache.ts
  13. 61
      src/lib/superchat.test.ts
  14. 39
      src/lib/superchat.ts
  15. 6
      src/lib/xmr-usd-rate.ts
  16. 50
      src/pages/secondary/WalletPage/ZapReplyThresholdInput.tsx
  17. 2
      src/pages/secondary/WalletPage/index.tsx
  18. 10
      src/providers/ZapProvider.tsx
  19. 24
      src/services/local-storage.service.ts

101
src/components/KindFilter/index.tsx

@ -5,6 +5,17 @@ import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerTrigger } from
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { ExtendedKind, NIP71_VIDEO_KINDS, PROFILE_FEED_KINDS } from '@/constants' import { ExtendedKind, NIP71_VIDEO_KINDS, PROFILE_FEED_KINDS } from '@/constants'
import {
applyFeedGitGroupToggle,
applyFeedPostsGroupToggle,
applyFeedRepliesGroupToggle,
FEED_GIT_GROUP_KINDS,
FEED_POSTS_GROUP_KINDS,
FEED_REPLIES_GROUP_KINDS,
isFeedGitGroupEnabled,
isFeedPostsGroupEnabled,
isFeedRepliesGroupEnabled
} from '@/lib/feed-kind-filter'
import { LIVE_ACTIVITY_KINDS } from '@/lib/live-activities' import { LIVE_ACTIVITY_KINDS } from '@/lib/live-activities'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useKindFilterOrDefaults } from '@/providers/KindFilterProvider' import { useKindFilterOrDefaults } from '@/providers/KindFilterProvider'
@ -19,26 +30,11 @@ const KIND_1111 = ExtendedKind.COMMENT
const KIND_FILTER_OPTIONS = [ const KIND_FILTER_OPTIONS = [
{ kindGroup: [kinds.LongFormArticle, ExtendedKind.WIKI_ARTICLE, ExtendedKind.NOSTR_SPECIFICATION], label: 'Articles' }, { kindGroup: [kinds.LongFormArticle, ExtendedKind.WIKI_ARTICLE, ExtendedKind.NOSTR_SPECIFICATION], label: 'Articles' },
{ kindGroup: [kinds.Highlights], label: 'Highlights' },
{ kindGroup: [ExtendedKind.POLL], label: 'Polls' }, { kindGroup: [ExtendedKind.POLL], label: 'Polls' },
{ kindGroup: [ExtendedKind.VOICE, ExtendedKind.VOICE_COMMENT], label: 'Voice Posts' },
{ kindGroup: [ExtendedKind.PICTURE], label: 'Photo Posts' },
{ kindGroup: [...NIP71_VIDEO_KINDS], label: 'Video Posts' }, { kindGroup: [...NIP71_VIDEO_KINDS], label: 'Video Posts' },
{ kindGroup: [ExtendedKind.DISCUSSION], label: 'Discussions' },
{ kindGroup: [ExtendedKind.CALENDAR_EVENT_DATE, ExtendedKind.CALENDAR_EVENT_TIME], label: 'Calendar Events' }, { kindGroup: [ExtendedKind.CALENDAR_EVENT_DATE, ExtendedKind.CALENDAR_EVENT_TIME], label: 'Calendar Events' },
{ kindGroup: [...LIVE_ACTIVITY_KINDS], label: 'Live streams' }, { kindGroup: [...LIVE_ACTIVITY_KINDS], label: 'Live streams' },
{ { kindGroup: [kinds.Repost, ExtendedKind.GENERIC_REPOST], label: 'Boosts' }
kindGroup: [
ExtendedKind.ZAP_RECEIPT,
ExtendedKind.MONERO_TIP_DISCLOSURE,
ExtendedKind.MONERO_TIP_RECEIPT
],
label: 'Zaps'
},
{ kindGroup: [kinds.Repost, ExtendedKind.GENERIC_REPOST], label: 'Boosts' },
{ kindGroup: [ExtendedKind.GIT_REPO_ANNOUNCEMENT], label: 'Git repositories' },
{ kindGroup: [ExtendedKind.GIT_ISSUE], label: 'Git issues' },
{ kindGroup: [ExtendedKind.GIT_RELEASE], label: 'Git releases' }
] ]
function buildShowKindsFromOptions( function buildShowKindsFromOptions(
@ -134,6 +130,14 @@ export default function KindFilter({
) )
const canApply = temporarySeeAllEvents || appliedShowKinds.length > 0 const canApply = temporarySeeAllEvents || appliedShowKinds.length > 0
const postsGroupEnabled = isFeedPostsGroupEnabled(temporaryShowKind1OPs, temporaryShowKinds)
const repliesGroupEnabled = isFeedRepliesGroupEnabled(
temporaryShowKind1Replies,
temporaryShowKind1111,
temporaryShowKinds
)
const gitGroupEnabled = isFeedGitGroupEnabled(temporaryShowKinds)
const handleApply = () => { const handleApply = () => {
if (!canApply) return if (!canApply) return
@ -196,38 +200,64 @@ export default function KindFilter({
{temporarySeeAllEvents ? t('See all events hint') : t('Use filter hint')} {temporarySeeAllEvents ? t('See all events hint') : t('Use filter hint')}
</p> </p>
<div className={cn('grid grid-cols-2 gap-2', temporarySeeAllEvents && 'opacity-50')}> <div className={cn('grid grid-cols-2 gap-2', temporarySeeAllEvents && 'opacity-50')}>
{/* Posts (OPs) - kind 1 top-level only */}
<div <div
className={cn( className={cn(
'cursor-pointer grid gap-1.5 rounded-lg border px-4 py-3', 'cursor-pointer grid gap-1.5 rounded-lg border px-4 py-3',
temporaryShowKind1OPs ? 'border-primary/60 bg-primary/5' : 'clickable' postsGroupEnabled ? 'border-primary/60 bg-primary/5' : 'clickable'
)} )}
onClick={() => setTemporaryShowKind1OPs((prev) => !prev)} onClick={() => {
const next = !postsGroupEnabled
const { showKinds: nextKinds, showKind1OPs } = applyFeedPostsGroupToggle(
temporaryShowKinds,
next
)
setTemporaryShowKinds(nextKinds)
setTemporaryShowKind1OPs(showKind1OPs)
}}
> >
<p className="leading-none font-medium">{t('Posts (OPs)')}</p> <p className="leading-none font-medium">{t('Posts')}</p>
<p className="text-muted-foreground text-xs">kind {KIND_1}</p> <p className="text-muted-foreground text-xs">
{t('Feed filter posts group kinds', {
kinds: [KIND_1, ...FEED_POSTS_GROUP_KINDS].join(', ')
})}
</p>
</div> </div>
{/* Kind 1 replies - kind 1 that are replies */}
<div <div
className={cn( className={cn(
'cursor-pointer grid gap-1.5 rounded-lg border px-4 py-3', 'cursor-pointer grid gap-1.5 rounded-lg border px-4 py-3',
temporaryShowKind1Replies ? 'border-primary/60 bg-primary/5' : 'clickable' repliesGroupEnabled ? 'border-primary/60 bg-primary/5' : 'clickable'
)} )}
onClick={() => setTemporaryShowKind1Replies((prev) => !prev)} onClick={() => {
const next = !repliesGroupEnabled
const { showKinds: nextKinds, showKind1Replies, showKind1111 } = applyFeedRepliesGroupToggle(
temporaryShowKinds,
next
)
setTemporaryShowKinds(nextKinds)
setTemporaryShowKind1Replies(showKind1Replies)
setTemporaryShowKind1111(showKind1111)
}}
> >
<p className="leading-none font-medium">{t('Kind 1 replies')}</p> <p className="leading-none font-medium">{t('Replies')}</p>
<p className="text-muted-foreground text-xs">kind {KIND_1}</p> <p className="text-muted-foreground text-xs">
{t('Feed filter replies group kinds', {
kinds: [KIND_1, KIND_1111, ...FEED_REPLIES_GROUP_KINDS].join(', ')
})}
</p>
</div> </div>
{/* Comments - kind 1111 */}
<div <div
className={cn( className={cn(
'cursor-pointer grid gap-1.5 rounded-lg border px-4 py-3', 'cursor-pointer grid gap-1.5 rounded-lg border px-4 py-3',
temporaryShowKind1111 ? 'border-primary/60 bg-primary/5' : 'clickable' gitGroupEnabled ? 'border-primary/60 bg-primary/5' : 'clickable'
)} )}
onClick={() => setTemporaryShowKind1111((prev) => !prev)} onClick={() => {
setTemporaryShowKinds(applyFeedGitGroupToggle(temporaryShowKinds, !gitGroupEnabled))
}}
> >
<p className="leading-none font-medium">{t('Comments')}</p> <p className="leading-none font-medium">{t('Git')}</p>
<p className="text-muted-foreground text-xs">kind {KIND_1111}</p> <p className="text-muted-foreground text-xs">
{t('Feed filter git group kinds', { kinds: FEED_GIT_GROUP_KINDS.join(', ') })}
</p>
</div> </div>
{KIND_FILTER_OPTIONS.map(({ kindGroup, label }) => { {KIND_FILTER_OPTIONS.map(({ kindGroup, label }) => {
/** `some` not `every`: saved kinds may include e.g. only 30311 while the row lists 30311–30313; `every` made the box look off while 30311 still matched the feed. */ /** `some` not `every`: saved kinds may include e.g. only 30311 while the row lists 30311–30313; `every` made the box look off while 30311 still matched the feed. */
@ -259,7 +289,14 @@ export default function KindFilter({
variant="secondary" variant="secondary"
onClick={() => { onClick={() => {
setTemporaryShowKinds( setTemporaryShowKinds(
PROFILE_FEED_KINDS.filter((k) => k !== KIND_1 && k !== KIND_1111) Array.from(
new Set([
...PROFILE_FEED_KINDS.filter((k) => k !== KIND_1 && k !== KIND_1111),
...FEED_POSTS_GROUP_KINDS,
...FEED_REPLIES_GROUP_KINDS,
...FEED_GIT_GROUP_KINDS
])
)
) )
setTemporaryShowKind1OPs(true) setTemporaryShowKind1OPs(true)
setTemporaryShowKind1Replies(true) setTemporaryShowKind1Replies(true)

26
src/components/NoteList/index.tsx

@ -30,7 +30,8 @@ import { isLocalNetworkUrl, normalizeUrl } from '@/lib/url'
import { eventPassesNoteListKindPicker } from '@/lib/feed-kind-filter' import { eventPassesNoteListKindPicker } from '@/lib/feed-kind-filter'
import { collectLocalEventsForTextSearch } from '@/lib/local-nip50-search-merge' import { collectLocalEventsForTextSearch } from '@/lib/local-nip50-search-merge'
import { eventMatchesNip50LocalFullTextQuery } from '@/lib/nip50-local-text-match' import { eventMatchesNip50LocalFullTextQuery } from '@/lib/nip50-local-text-match'
import { shouldIncludeZapReceiptAtReplyThreshold } from '@/lib/event-metadata' import { useFeedAttestedSuperchatIds } from '@/hooks/useFeedAttestedSuperchatIds'
import { shouldIncludePaymentInFeed } from '@/lib/superchat'
import { isTouchDevice } from '@/lib/utils' import { isTouchDevice } from '@/lib/utils'
import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider' import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
import { useDeletedEvent } from '@/providers/DeletedEventProvider' import { useDeletedEvent } from '@/providers/DeletedEventProvider'
@ -38,7 +39,6 @@ import { useMuteList } from '@/contexts/mute-list-context'
import { muteSetHas } from '@/lib/mute-set' import { muteSetHas } from '@/lib/mute-set'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useUserTrust } from '@/contexts/user-trust-context' import { useUserTrust } from '@/contexts/user-trust-context'
import { useZap } from '@/providers/ZapProvider'
import client from '@/services/client.service' import client from '@/services/client.service'
import noteStatsService from '@/services/note-stats.service' import noteStatsService from '@/services/note-stats.service'
import indexedDb from '@/services/indexed-db.service' import indexedDb from '@/services/indexed-db.service'
@ -859,7 +859,6 @@ const NoteList = forwardRef(
contentPolicy?.isOffline ?? contentPolicy?.isOffline ??
(!navigator.onLine || (navigator as Navigator & { connection?: { type?: string } }).connection?.type === 'none') (!navigator.onLine || (navigator as Navigator & { connection?: { type?: string } }).connection?.type === 'none')
const { isEventDeleted } = useDeletedEvent() const { isEventDeleted } = useDeletedEvent()
const { zapReplyThreshold } = useZap()
const { favoriteRelays, blockedRelays } = useFavoriteRelays() const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const [events, setEvents] = useState<Event[]>([]) const [events, setEvents] = useState<Event[]>([])
const eventsRef = useRef<Event[]>([]) const eventsRef = useRef<Event[]>([])
@ -996,6 +995,19 @@ const NoteList = forwardRef(
// Memoize subRequests serialization to avoid expensive JSON.stringify on every render // Memoize subRequests serialization to avoid expensive JSON.stringify on every render
const subRequestsKey = useMemo(() => legacyFeedSubscriptionKey(subRequests), [subRequests]) const subRequestsKey = useMemo(() => legacyFeedSubscriptionKey(subRequests), [subRequests])
const feedRelayUrls = useMemo(() => {
const urls = new Set<string>()
for (const req of subRequests) {
for (const url of req.urls ?? []) {
const trimmed = url.trim()
if (trimmed) urls.add(trimmed)
}
}
return [...urls]
}, [subRequestsKey])
const feedAttestedSuperchatIds = useFeedAttestedSuperchatIds(feedRelayUrls)
const followingFeedDeltaSubRequestsKey = useMemo( const followingFeedDeltaSubRequestsKey = useMemo(
() => () =>
legacyFeedSubscriptionKey(followingFeedDeltaSubRequests ?? []), legacyFeedSubscriptionKey(followingFeedDeltaSubRequests ?? []),
@ -1310,8 +1322,8 @@ const NoteList = forwardRef(
// Filter out expired events // Filter out expired events
if (shouldFilterEvent(evt)) return true if (shouldFilterEvent(evt)) return true
// Filter out zap receipts below the zap-reply threshold (same rule as thread replies) // Attested superchats only (9741), same as threads / profile walls.
if (evt.kind === ExtendedKind.ZAP_RECEIPT && !shouldIncludeZapReceiptAtReplyThreshold(evt, zapReplyThreshold)) { if (!shouldIncludePaymentInFeed(evt, feedAttestedSuperchatIds)) {
return true return true
} }
@ -1338,7 +1350,7 @@ const NoteList = forwardRef(
mutePubkeySet, mutePubkeySet,
pinnedEventIds, pinnedEventIds,
isEventDeleted, isEventDeleted,
zapReplyThreshold, feedAttestedSuperchatIds,
extraShouldHideEvent, extraShouldHideEvent,
homeFeedActiveSeenOnAllowlist, homeFeedActiveSeenOnAllowlist,
homeFeedListMode homeFeedListMode
@ -3327,7 +3339,6 @@ const NoteList = forwardRef(
if (!isReply && !showKind1OPsRef.current) return if (!isReply && !showKind1OPsRef.current) return
} }
if (event.kind === ExtendedKind.COMMENT && !showKind1111Ref.current) return if (event.kind === ExtendedKind.COMMENT && !showKind1111Ref.current) return
if (event.kind === ExtendedKind.GIT_RELEASE && !showKind1OPsRef.current) return
} }
} }
} }
@ -3640,7 +3651,6 @@ const NoteList = forwardRef(
if (!isReply && !showKind1OPsRef.current) return if (!isReply && !showKind1OPsRef.current) return
} }
if (event.kind === ExtendedKind.COMMENT && !showKind1111Ref.current) return if (event.kind === ExtendedKind.COMMENT && !showKind1111Ref.current) return
if (event.kind === ExtendedKind.GIT_RELEASE && !showKind1OPsRef.current) return
} }
} }
} }

29
src/components/ReplyNoteList/index.tsx

@ -29,7 +29,6 @@ import { useSmartNoteNavigation } from '@/PageManager'
import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useMuteList } from '@/contexts/mute-list-context' import { useMuteList } from '@/contexts/mute-list-context'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useZap } from '@/providers/ZapProvider'
import { useReplyIngress } from '@/hooks/useReplyIngress' import { useReplyIngress } from '@/hooks/useReplyIngress'
import { useUserTrust } from '@/contexts/user-trust-context' import { useUserTrust } from '@/contexts/user-trust-context'
import { useCurrentRelays } from '@/providers/CurrentRelaysProvider' import { useCurrentRelays } from '@/providers/CurrentRelaysProvider'
@ -115,7 +114,6 @@ function ReplyNoteList({
const { mutePubkeySet } = useMuteList() const { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy() const { hideContentMentioningMutedUsers } = useContentPolicy()
const { pubkey: userPubkey } = useNostr() const { pubkey: userPubkey } = useNostr()
const { zapReplyThreshold } = useZap()
const { blockedRelays, favoriteRelays } = useFavoriteRelays() const { blockedRelays, favoriteRelays } = useFavoriteRelays()
const { relayUrls: browsingRelayUrls } = useCurrentRelays() const { relayUrls: browsingRelayUrls } = useCurrentRelays()
const relayAuthoritativeRead = const relayAuthoritativeRead =
@ -241,11 +239,7 @@ function ReplyNoteList({
threadWalkFromRepliesMap.set(evt.id.toLowerCase(), evt) threadWalkFromRepliesMap.set(evt.id.toLowerCase(), evt)
} }
const { superchats, rest: nonZaps } = partitionAttestedSuperchats( const { superchats, rest: nonZaps } = partitionAttestedSuperchats(replyEvents, attestedPaymentIds)
replyEvents,
attestedPaymentIds,
zapReplyThreshold
)
const zaps = superchats const zaps = superchats
const replyScoreById = const replyScoreById =
sort === 'top' || sort === 'controversial' || sort === 'most-zapped' sort === 'top' || sort === 'controversial' || sort === 'most-zapped'
@ -336,7 +330,6 @@ function ReplyNoteList({
mutePubkeySet, mutePubkeySet,
hideContentMentioningMutedUsers, hideContentMentioningMutedUsers,
sort, sort,
zapReplyThreshold,
attestedPaymentIds, attestedPaymentIds,
isDiscussionRoot, isDiscussionRoot,
event.kind event.kind
@ -361,11 +354,7 @@ function ReplyNoteList({
const mergedFeed = useMemo(() => { const mergedFeed = useMemo(() => {
/** Quotes + time-sorted feeds must not interleave zap receipts chronologically */ /** Quotes + time-sorted feeds must not interleave zap receipts chronologically */
const zapsThenTimeSorted = (merged: NEvent[], direction: 'asc' | 'desc') => { const zapsThenTimeSorted = (merged: NEvent[], direction: 'asc' | 'desc') => {
const { superchats, rest: nonZaps } = partitionAttestedSuperchats( const { superchats, rest: nonZaps } = partitionAttestedSuperchats(merged, attestedPaymentIds)
merged,
attestedPaymentIds,
zapReplyThreshold
)
const sortedNon = [...nonZaps].sort((a, b) => const sortedNon = [...nonZaps].sort((a, b) =>
direction === 'asc' ? a.created_at - b.created_at : b.created_at - a.created_at direction === 'asc' ? a.created_at - b.created_at : b.created_at - a.created_at
) )
@ -376,11 +365,7 @@ function ReplyNoteList({
// E/A: zaps (sats desc) → thread replies (1 / 1111 / 1244, excluding #q-only) → tail (quotes, highlights, long-form refs) // E/A: zaps (sats desc) → thread replies (1 / 1111 / 1244, excluding #q-only) → tail (quotes, highlights, long-form refs)
if (rootInfo?.type === 'E' || rootInfo?.type === 'A') { if (rootInfo?.type === 'E' || rootInfo?.type === 'A') {
const { superchats, rest: nonZaps } = partitionAttestedSuperchats( const { superchats, rest: nonZaps } = partitionAttestedSuperchats(replies, attestedPaymentIds)
replies,
attestedPaymentIds,
zapReplyThreshold
)
const middle = nonZaps.filter((e) => !isEaThreadTailBacklinkCandidate(e, rootInfo)) const middle = nonZaps.filter((e) => !isEaThreadTailBacklinkCandidate(e, rootInfo))
const tailFromReplies = nonZaps.filter((e) => isEaThreadTailBacklinkCandidate(e, rootInfo)) const tailFromReplies = nonZaps.filter((e) => isEaThreadTailBacklinkCandidate(e, rootInfo))
const tailSeen = new Set<string>() const tailSeen = new Set<string>()
@ -397,11 +382,7 @@ function ReplyNoteList({
// Web article / URL thread (NIP-22): same zaps → middle → tail layout as E/A // Web article / URL thread (NIP-22): same zaps → middle → tail layout as E/A
if (rootInfo?.type === 'I') { if (rootInfo?.type === 'I') {
const { superchats, rest: nonZaps } = partitionAttestedSuperchats( const { superchats, rest: nonZaps } = partitionAttestedSuperchats(replies, attestedPaymentIds)
replies,
attestedPaymentIds,
zapReplyThreshold
)
const middle = nonZaps.filter((e) => !isWebThreadTailKind(e.kind)) const middle = nonZaps.filter((e) => !isWebThreadTailKind(e.kind))
const tailFromReplies = nonZaps.filter((e) => isWebThreadTailKind(e.kind)) const tailFromReplies = nonZaps.filter((e) => isWebThreadTailKind(e.kind))
const tailSeen = new Set<string>() const tailSeen = new Set<string>()
@ -423,7 +404,7 @@ function ReplyNoteList({
return [...replies] return [...replies]
} }
return zapsThenTimeSorted(merged, 'desc') return zapsThenTimeSorted(merged, 'desc')
}, [replies, showQuotes, sort, replyIdSet, rootInfo, event.kind, attestedPaymentIds, zapReplyThreshold]) }, [replies, showQuotes, sort, replyIdSet, rootInfo, event.kind, attestedPaymentIds])
const parentNoteFeed = useNoteFeedProfileContext() const parentNoteFeed = useNoteFeedProfileContext()
const threadProfileLoadedRef = useRef<Set<string>>(new Set()) const threadProfileLoadedRef = useRef<Set<string>>(new Set())

1
src/constants.ts

@ -336,7 +336,6 @@ export const StorageKey = {
DEFAULT_ZAP_COMMENT: 'defaultZapComment', DEFAULT_ZAP_COMMENT: 'defaultZapComment',
QUICK_ZAP: 'quickZap', QUICK_ZAP: 'quickZap',
INCLUDE_PUBLIC_ZAP_RECEIPT: 'includePublicZapReceipt', 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). */ /** Per-pubkey ms timestamps: last full network hydrate (see ACCOUNT_SESSION_NETWORK_HYDRATE_MIN_INTERVAL_MS). */
ACCOUNT_NETWORK_HYDRATE_AT_MAP: 'accountNetworkHydrateAtMap', ACCOUNT_NETWORK_HYDRATE_AT_MAP: 'accountNetworkHydrateAtMap',
AUTOPLAY: 'autoplay', AUTOPLAY: 'autoplay',

73
src/hooks/useFeedAttestedSuperchatIds.ts

@ -0,0 +1,73 @@
import { ExtendedKind } from '@/constants'
import {
collectPaymentAttestationsFromSession,
mergeAttestedPaymentIdSets
} from '@/lib/payment-attestation-cache'
import { buildGlobalAttestedSuperchatIdSet } from '@/lib/superchat'
import client from '@/services/client.service'
import type { Event as NostrEvent } from 'nostr-tools'
import { useCallback, useEffect, useMemo, useState } from 'react'
function idsFromAttestations(attestations: NostrEvent[]): Set<string> {
return buildGlobalAttestedSuperchatIdSet(attestations)
}
/** Attested superchat target ids (9735 / 9740 / 9736 / 1814) for feed filtering. */
export function useFeedAttestedSuperchatIds(relayUrls: string[]): Set<string> {
const [attestedIds, setAttestedIds] = useState<Set<string>>(() =>
idsFromAttestations(collectPaymentAttestationsFromSession())
)
const mergeAttestations = useCallback((incoming: NostrEvent[]) => {
if (incoming.length === 0) return
const next = idsFromAttestations(incoming)
setAttestedIds((prev) => {
const merged = mergeAttestedPaymentIdSets(prev, next)
return merged.size === prev.size ? prev : merged
})
}, [])
const relayUrlsKey = useMemo(
() =>
[...relayUrls]
.map((u) => u.trim())
.filter(Boolean)
.sort()
.join('|'),
[relayUrls]
)
useEffect(() => {
mergeAttestations(collectPaymentAttestationsFromSession())
}, [mergeAttestations])
useEffect(() => {
const handleNewEvent = (data: Event) => {
const evt = (data as CustomEvent<NostrEvent>).detail
if (!evt || evt.kind !== ExtendedKind.PAYMENT_ATTESTATION) return
mergeAttestations([evt])
}
client.addEventListener('newEvent', handleNewEvent)
return () => client.removeEventListener('newEvent', handleNewEvent)
}, [mergeAttestations])
useEffect(() => {
if (!relayUrlsKey) return
const urls = relayUrlsKey.split('|').filter(Boolean)
if (urls.length === 0) return
let cancelled = false
void client
.fetchEvents(urls, { kinds: [ExtendedKind.PAYMENT_ATTESTATION], limit: 500 }, { cache: true })
.then((events) => {
if (!cancelled) mergeAttestations(events)
})
.catch(() => {
/* optional */
})
return () => {
cancelled = true
}
}, [relayUrlsKey, mergeAttestations])
return attestedIds
}

11
src/i18n/locales/de.ts

@ -1212,6 +1212,10 @@ export default {
'Posts (OPs)': 'Beiträge (OPs)', 'Posts (OPs)': 'Beiträge (OPs)',
'Kind 1 replies': 'Kind-1-Antworten', 'Kind 1 replies': 'Kind-1-Antworten',
Comments: 'Kommentare', Comments: 'Kommentare',
'Feed filter posts group kinds': 'Kind {{kinds}}',
'Feed filter replies group kinds': 'Kind {{kinds}}',
'Feed filter git group kinds': 'Kind {{kinds}}',
Git: 'Git',
'Replies & comments': 'Antworten & Kommentare', 'Replies & comments': 'Antworten & Kommentare',
Articles: 'Artikel', Articles: 'Artikel',
Highlights: 'Highlights', Highlights: 'Highlights',
@ -1533,9 +1537,12 @@ export default {
'Invalid zap receipt': 'Invalid zap receipt', 'Invalid zap receipt': 'Invalid zap receipt',
'Zapped note': 'Zapped note', 'Zapped note': 'Zapped note',
'Zapped profile': 'Zapped profile', 'Zapped profile': 'Zapped profile',
'Zap reply threshold': 'Zap reply threshold', 'Zap reply threshold': 'Zap-Feed-Schwelle',
'Zap feed threshold': 'Zap-Feed-Schwelle',
'Zaps above this amount will appear in feeds':
'Lightning-Zaps, Zahlungsbenachrichtigungen und Monero-Tips ab diesem Betrag (in Sats, umgerechnet wenn Kurse verfügbar) erscheinen in Home- und Relay-Feeds, wenn diese Kinds aktiv sind. Threads zeigen nur bestätigte Superchats.',
'Zaps above this amount will appear as replies in threads': 'Zaps above this amount will appear as replies in threads':
'Zaps above this amount will appear as replies in threads', 'Lightning-Zaps, Zahlungsbenachrichtigungen und Monero-Tips ab diesem Betrag (in Sats, umgerechnet wenn Kurse verfügbar) erscheinen in Home- und Relay-Feeds, wenn diese Kinds aktiv sind. Threads zeigen nur bestätigte Superchats.',
'Mark as read': 'Als gelesen markieren', 'Mark as read': 'Als gelesen markieren',
Report: 'Melden', Report: 'Melden',
'Successfully report': 'Erfolgreich gemeldet', 'Successfully report': 'Erfolgreich gemeldet',

11
src/i18n/locales/en.ts

@ -1191,6 +1191,10 @@ export default {
Posts: 'Posts', Posts: 'Posts',
'Posts (OPs)': 'Posts (OPs)', 'Posts (OPs)': 'Posts (OPs)',
'Kind 1 replies': 'Kind 1 replies', 'Kind 1 replies': 'Kind 1 replies',
'Feed filter posts group kinds': 'kind {{kinds}}',
'Feed filter replies group kinds': 'kind {{kinds}}',
'Feed filter git group kinds': 'kind {{kinds}}',
Git: 'Git',
Comments: 'Comments', Comments: 'Comments',
'Replies & comments': 'Replies & comments', 'Replies & comments': 'Replies & comments',
Articles: 'Articles', Articles: 'Articles',
@ -1506,9 +1510,12 @@ export default {
'Invalid zap receipt': 'Invalid zap receipt', 'Invalid zap receipt': 'Invalid zap receipt',
'Zapped note': 'Zapped note', 'Zapped note': 'Zapped note',
'Zapped profile': 'Zapped profile', 'Zapped profile': 'Zapped profile',
'Zap reply threshold': 'Zap reply threshold', 'Zap reply threshold': 'Zap feed threshold',
'Zap feed threshold': 'Zap feed threshold',
'Zaps above this amount will appear in feeds':
'Lightning zaps, payment notifications, and Monero tips at or above this amount (in sats, or converted when rates are available) appear in home and relay feeds when those kinds are enabled. Threads only show attested superchats.',
'Zaps above this amount will appear as replies in threads': 'Zaps above this amount will appear as replies in threads':
'Only zap receipts (kind 9735) with at least this many sats are shown in home and relay feeds (with “Zaps” enabled in the kind filter) and listed under notes as zap replies.', 'Lightning zaps, payment notifications, and Monero tips at or above this amount (in sats, or converted when rates are available) appear in home and relay feeds when those kinds are enabled. Threads only show attested superchats.',
'Mark as read': 'Mark as read', 'Mark as read': 'Mark as read',
Report: 'Report', Report: 'Report',
'Successfully report': 'Successfully reported', 'Successfully report': 'Successfully reported',

6
src/lib/btc-usd-rate.ts

@ -2,6 +2,12 @@ const CACHE_MS = 5 * 60 * 1000
let cache: { usd: number; at: number } | null = null let cache: { usd: number; at: number } | null = null
/** Cached BTC/USD if {@link fetchBtcUsdRate} has run recently (sync feed filters). */
export function getCachedBtcUsdRate(): number | null {
if (cache && Date.now() - cache.at < CACHE_MS) return cache.usd
return null
}
/** Latest BTC/USD spot price (cached ~5 min). */ /** Latest BTC/USD spot price (cached ~5 min). */
export async function fetchBtcUsdRate(): Promise<number | null> { export async function fetchBtcUsdRate(): Promise<number | null> {
if (cache && Date.now() - cache.at < CACHE_MS) { if (cache && Date.now() - cache.at < CACHE_MS) {

13
src/lib/event-metadata.ts

@ -618,19 +618,6 @@ export function getZapInfoFromEvent(receiptEvent: Event) {
} }
} }
/**
* Kind 9735: include in timelines and reply lists only when amount (sats) is known and at least `thresholdSats`.
* Matches {@link NoteList} zap filtering.
*/
export function shouldIncludeZapReceiptAtReplyThreshold(receipt: Event, thresholdSats: number): boolean {
if (receipt.kind !== kinds.Zap) return true
const zapInfo = getZapInfoFromEvent(receipt)
if (!zapInfo || zapInfo.amount === undefined || zapInfo.amount === 0 || zapInfo.amount < thresholdSats) {
return false
}
return true
}
// Helper function to convert d-tag to title case // Helper function to convert d-tag to title case
export function dTagToTitleCase(dTag: string): string { export function dTagToTitleCase(dTag: string): string {
return dTag return dTag

91
src/lib/feed-kind-filter.test.ts

@ -0,0 +1,91 @@
import { ExtendedKind } from '@/constants'
import {
applyFeedGitGroupToggle,
applyFeedPostsGroupToggle,
applyFeedRepliesGroupToggle,
eventPassesNoteListKindPicker,
FEED_GIT_GROUP_KINDS,
FEED_REPLIES_GROUP_KINDS,
isFeedGitGroupEnabled,
isFeedPostsGroupEnabled,
isFeedRepliesGroupEnabled
} from '@/lib/feed-kind-filter'
import { describe, expect, it } from 'vitest'
import { kinds, type Event } from 'nostr-tools'
function fakeEvent(partial: Partial<Event> & Pick<Event, 'kind' | 'tags'>): Event {
return {
id: '0'.repeat(64),
pubkey: 'b'.repeat(64),
created_at: 1,
content: '',
sig: 'sig',
...partial
}
}
describe('feed kind groups', () => {
it('posts group toggles kind 1 OPs, highlights, discussions, photos, and voice posts together', () => {
const off = applyFeedPostsGroupToggle([], false)
expect(off.showKind1OPs).toBe(false)
expect(isFeedPostsGroupEnabled(off.showKind1OPs, off.showKinds)).toBe(false)
const on = applyFeedPostsGroupToggle(off.showKinds, true)
expect(on.showKind1OPs).toBe(true)
expect(on.showKinds).toContain(kinds.Highlights)
expect(on.showKinds).toContain(ExtendedKind.DISCUSSION)
expect(on.showKinds).toContain(ExtendedKind.PICTURE)
expect(on.showKinds).toContain(ExtendedKind.VOICE)
expect(isFeedPostsGroupEnabled(on.showKind1OPs, on.showKinds)).toBe(true)
})
it('git group toggles all git kinds together', () => {
const off = applyFeedGitGroupToggle([], false)
expect(isFeedGitGroupEnabled(off)).toBe(false)
const on = applyFeedGitGroupToggle(off, true)
for (const k of FEED_GIT_GROUP_KINDS) {
expect(on).toContain(k)
}
expect(isFeedGitGroupEnabled(on)).toBe(true)
})
it('replies group toggles kind 1 replies, comments, voice comments, and superchat kinds together', () => {
const off = applyFeedRepliesGroupToggle([], false)
expect(off.showKind1Replies).toBe(false)
expect(off.showKind1111).toBe(false)
expect(isFeedRepliesGroupEnabled(off.showKind1Replies, off.showKind1111, off.showKinds)).toBe(
false
)
const on = applyFeedRepliesGroupToggle(off.showKinds, true)
expect(on.showKind1Replies).toBe(true)
expect(on.showKind1111).toBe(true)
expect(on.showKinds).toContain(ExtendedKind.VOICE_COMMENT)
for (const k of FEED_REPLIES_GROUP_KINDS) {
expect(on.showKinds).toContain(k)
}
expect(
isFeedRepliesGroupEnabled(on.showKind1Replies, on.showKind1111, on.showKinds)
).toBe(true)
})
it('hides payment notifications when replies group flags are off', () => {
const payment = fakeEvent({
kind: ExtendedKind.PAYMENT_NOTIFICATION,
tags: [['p', 'a'.repeat(64)], ['amount', '21000']]
})
const showKinds = [...FEED_REPLIES_GROUP_KINDS]
expect(eventPassesNoteListKindPicker(payment, showKinds, true, false, false)).toBe(false)
expect(eventPassesNoteListKindPicker(payment, showKinds, true, true, true)).toBe(true)
expect(
eventPassesNoteListKindPicker(
fakeEvent({ id: 'z'.repeat(64), kind: ExtendedKind.ZAP_RECEIPT, tags: [] }),
showKinds,
true,
true,
true
)
).toBe(true)
})
})

88
src/lib/feed-kind-filter.ts

@ -3,6 +3,86 @@ import { isReplyNoteEvent } from '@/lib/event'
import type { Event } from 'nostr-tools' import type { Event } from 'nostr-tools'
import { kinds } from 'nostr-tools' import { kinds } from 'nostr-tools'
/** Kind 1 OPs, highlights, discussions, photos, voice posts — feed filter “Posts” group. */
export const FEED_POSTS_GROUP_KINDS: readonly number[] = [
kinds.Highlights,
ExtendedKind.DISCUSSION,
ExtendedKind.PICTURE,
ExtendedKind.VOICE
]
/** Kind 1 replies, comments, voice comments, superchats — feed filter “Replies” group. */
export const FEED_REPLIES_GROUP_KINDS: readonly number[] = [
ExtendedKind.VOICE_COMMENT,
ExtendedKind.ZAP_RECEIPT,
ExtendedKind.PAYMENT_NOTIFICATION,
ExtendedKind.MONERO_TIP_DISCLOSURE,
ExtendedKind.MONERO_TIP_RECEIPT
]
export const FEED_GIT_GROUP_KINDS: readonly number[] = [
ExtendedKind.GIT_REPO_ANNOUNCEMENT,
ExtendedKind.GIT_ISSUE,
ExtendedKind.GIT_RELEASE
]
const FEED_POSTS_GROUP_KIND_SET = new Set(FEED_POSTS_GROUP_KINDS)
const FEED_REPLIES_GROUP_KIND_SET = new Set(FEED_REPLIES_GROUP_KINDS)
const FEED_GIT_GROUP_KIND_SET = new Set(FEED_GIT_GROUP_KINDS)
export function isFeedPostsGroupEnabled(showKind1OPs: boolean, showKinds: readonly number[]): boolean {
return showKind1OPs && FEED_POSTS_GROUP_KINDS.every((k) => showKinds.includes(k))
}
export function isFeedRepliesGroupEnabled(
showKind1Replies: boolean,
showKind1111: boolean,
showKinds: readonly number[]
): boolean {
return (
showKind1Replies &&
showKind1111 &&
FEED_REPLIES_GROUP_KINDS.every((k) => showKinds.includes(k))
)
}
export function isFeedGitGroupEnabled(showKinds: readonly number[]): boolean {
return FEED_GIT_GROUP_KINDS.every((k) => showKinds.includes(k))
}
export function applyFeedPostsGroupToggle(
showKinds: number[],
enabled: boolean
): { showKinds: number[]; showKind1OPs: boolean } {
const rest = showKinds.filter((k) => !FEED_POSTS_GROUP_KINDS.includes(k))
if (!enabled) return { showKind1OPs: false, showKinds: rest }
return {
showKind1OPs: true,
showKinds: Array.from(new Set([...rest, ...FEED_POSTS_GROUP_KINDS]))
}
}
export function applyFeedRepliesGroupToggle(
showKinds: number[],
enabled: boolean
): { showKinds: number[]; showKind1Replies: boolean; showKind1111: boolean } {
const rest = showKinds.filter((k) => !FEED_REPLIES_GROUP_KINDS.includes(k))
if (!enabled) {
return { showKind1Replies: false, showKind1111: false, showKinds: rest }
}
return {
showKind1Replies: true,
showKind1111: true,
showKinds: Array.from(new Set([...rest, ...FEED_REPLIES_GROUP_KINDS]))
}
}
export function applyFeedGitGroupToggle(showKinds: number[], enabled: boolean): number[] {
const rest = showKinds.filter((k) => !FEED_GIT_GROUP_KINDS.includes(k))
if (!enabled) return rest
return Array.from(new Set([...rest, ...FEED_GIT_GROUP_KINDS]))
}
/** /**
* Same rules as visible-row filtering when the home kind picker applies * Same rules as visible-row filtering when the home kind picker applies
* (not {@link shouldHideEvent} / mute / trust layers). * (not {@link shouldHideEvent} / mute / trust layers).
@ -15,12 +95,18 @@ export function eventPassesNoteListKindPicker(
showKind1111: boolean showKind1111: boolean
): boolean { ): boolean {
if (!effectiveShowKinds.includes(event.kind)) return false if (!effectiveShowKinds.includes(event.kind)) return false
if (FEED_POSTS_GROUP_KIND_SET.has(event.kind) && !showKind1OPs) return false
if (FEED_REPLIES_GROUP_KIND_SET.has(event.kind) && (!showKind1Replies || !showKind1111)) {
return false
}
if (FEED_GIT_GROUP_KIND_SET.has(event.kind) && !isFeedGitGroupEnabled(effectiveShowKinds)) {
return false
}
if (event.kind === kinds.ShortTextNote) { if (event.kind === kinds.ShortTextNote) {
const isReply = isReplyNoteEvent(event) const isReply = isReplyNoteEvent(event)
if (isReply && !showKind1Replies) return false if (isReply && !showKind1Replies) return false
if (!isReply && !showKind1OPs) return false if (!isReply && !showKind1OPs) return false
} }
if (event.kind === ExtendedKind.COMMENT && !showKind1111) return false if (event.kind === ExtendedKind.COMMENT && !showKind1111) return false
if (event.kind === ExtendedKind.GIT_RELEASE && !showKind1OPs) return false
return true return true
} }

8
src/lib/payment-attestation-cache.ts

@ -11,6 +11,14 @@ const authorHydrateByPubkey = new Map<string, Promise<void>>()
const LOCAL_ATTESTED_KEY_PREFIX = 'jumble:attested-payment-ids:' const LOCAL_ATTESTED_KEY_PREFIX = 'jumble:attested-payment-ids:'
/** Kind 9741 events already in the session LRU (for feed attestation index). */
export function collectPaymentAttestationsFromSession(limit = 2000): NostrEvent[] {
return client.eventService.getSessionEventsMatchingFilters(
[{ kinds: [ExtendedKind.PAYMENT_ATTESTATION], limit }],
limit
)
}
export function paymentAttestationCacheKey(targetEventId: string, recipientPubkey: string): string { export function paymentAttestationCacheKey(targetEventId: string, recipientPubkey: string): string {
return `${targetEventId.trim().toLowerCase()}:${recipientPubkey.trim().toLowerCase()}` return `${targetEventId.trim().toLowerCase()}:${recipientPubkey.trim().toLowerCase()}`
} }

61
src/lib/superchat.test.ts

@ -12,7 +12,9 @@ import {
isProfileWallPaymentNotification, isProfileWallPaymentNotification,
isProfileWallZapReceipt, isProfileWallZapReceipt,
isNestedThreadReplyParentKind, isNestedThreadReplyParentKind,
partitionAttestedSuperchats buildGlobalAttestedSuperchatIdSet,
partitionAttestedSuperchats,
shouldIncludePaymentInFeed
} from '@/lib/superchat' } from '@/lib/superchat'
import { parsePaytoTagType } from '@/lib/payto' import { parsePaytoTagType } from '@/lib/payto'
import { kinds, type Event } from 'nostr-tools' import { kinds, type Event } from 'nostr-tools'
@ -202,15 +204,14 @@ describe('partitionAttestedSuperchats', () => {
const { superchats, rest } = partitionAttestedSuperchats( const { superchats, rest } = partitionAttestedSuperchats(
[zapAttested, zapUnattested, payment, comment], [zapAttested, zapUnattested, payment, comment],
attested, attested
1
) )
expect(superchats.map((e) => e.id)).toEqual([payment.id, zapAttested.id]) expect(superchats.map((e) => e.id)).toEqual([payment.id, zapAttested.id])
expect(rest).toEqual([comment]) expect(rest).toEqual([comment])
}) })
it('includes attested zaps below the reply threshold at the top', () => { it('includes attested micro zaps in threads (feed threshold does not apply)', () => {
const attested = new Set([ZAP_ID]) const attested = new Set([ZAP_ID])
const microZap = fakeEvent({ const microZap = fakeEvent({
id: ZAP_ID, id: ZAP_ID,
@ -234,12 +235,62 @@ describe('partitionAttestedSuperchats', () => {
kind: ExtendedKind.COMMENT, kind: ExtendedKind.COMMENT,
tags: [['e', '2'.repeat(64)]] tags: [['e', '2'.repeat(64)]]
}) })
const { superchats, rest } = partitionAttestedSuperchats([microZap, comment], attested, 21) const { superchats, rest } = partitionAttestedSuperchats([microZap, comment], attested)
expect(superchats.map((e) => e.id)).toEqual([ZAP_ID]) expect(superchats.map((e) => e.id)).toEqual([ZAP_ID])
expect(rest).toEqual([comment]) expect(rest).toEqual([comment])
}) })
}) })
describe('buildGlobalAttestedSuperchatIdSet', () => {
it('collects attested target ids from valid kind 9741 events', () => {
const paymentId = PAYMENT_ID
const attestation = fakeEvent({
id: 'a'.repeat(64),
kind: ExtendedKind.PAYMENT_ATTESTATION,
pubkey: RECIPIENT,
tags: [
['e', paymentId],
['k', String(ExtendedKind.PAYMENT_NOTIFICATION)]
]
})
expect(buildGlobalAttestedSuperchatIdSet([attestation]).has(paymentId)).toBe(true)
})
})
describe('shouldIncludePaymentInFeed', () => {
it('requires attestation for superchat kinds only', () => {
const zap = fakeEvent({
id: ZAP_ID,
kind: kinds.Zap,
tags: [
['P', SENDER],
['p', RECIPIENT],
['bolt11', 'lnbc210n1p0fake'],
[
'description',
JSON.stringify({
pubkey: SENDER,
content: 'Zap!',
tags: [['p', RECIPIENT], ['amount', '21000']]
})
]
]
})
const payment = fakeEvent({
id: PAYMENT_ID,
kind: ExtendedKind.PAYMENT_NOTIFICATION,
tags: [['p', RECIPIENT], ['amount', '100000']]
})
const note = fakeEvent({ id: '1'.repeat(64), kind: kinds.ShortTextNote, content: 'hi', tags: [] })
const attested = new Set([ZAP_ID, PAYMENT_ID])
expect(shouldIncludePaymentInFeed(zap, attested)).toBe(true)
expect(shouldIncludePaymentInFeed(payment, attested)).toBe(true)
expect(shouldIncludePaymentInFeed(zap, new Set())).toBe(false)
expect(shouldIncludePaymentInFeed(note, attested)).toBe(true)
})
})
describe('getPaymentNotificationInfo', () => { describe('getPaymentNotificationInfo', () => {
it('uses only the first p, e, and a tags', () => { it('uses only the first p, e, and a tags', () => {
const evt = fakeEvent({ const evt = fakeEvent({

39
src/lib/superchat.ts

@ -16,6 +16,19 @@ import { Event, kinds } from 'nostr-tools'
export const PAYMENT_ATTESTATION_TARGET_KINDS = new Set(['9735', '9740', '9736', '1814']) export const PAYMENT_ATTESTATION_TARGET_KINDS = new Set(['9735', '9740', '9736', '1814'])
/** Payment kinds shown in feeds only when attested (kind 9741); excludes lightning zap receipts. */
export const FEED_SUPERCHAT_KINDS: readonly number[] = [
ExtendedKind.PAYMENT_NOTIFICATION,
ExtendedKind.MONERO_TIP_DISCLOSURE,
ExtendedKind.MONERO_TIP_RECEIPT
]
const FEED_SUPERCHAT_KIND_SET = new Set(FEED_SUPERCHAT_KINDS)
export function isFeedSuperchatKind(kind: number): boolean {
return FEED_SUPERCHAT_KIND_SET.has(kind)
}
export type PaymentNotificationInfo = { export type PaymentNotificationInfo = {
senderPubkey: string senderPubkey: string
recipientPubkey: string recipientPubkey: string
@ -267,8 +280,7 @@ export function collectAttestedSuperchatsFromRepliesMap(
export function partitionAttestedSuperchats( export function partitionAttestedSuperchats(
items: Event[], items: Event[],
attestedIds: Set<string>, attestedIds: Set<string>
_zapReplyThreshold: number
): { superchats: Event[]; rest: Event[] } { ): { superchats: Event[]; rest: Event[] } {
const superchats: Event[] = [] const superchats: Event[] = []
const rest: Event[] = [] const rest: Event[] = []
@ -298,6 +310,29 @@ export function partitionAttestedSuperchats(
return { superchats: sortSuperchatsByAmountDesc(superchats), rest } return { superchats: sortSuperchatsByAmountDesc(superchats), rest }
} }
/** Target payment ids from any valid kind 9741 (feeds are not scoped to one recipient). */
export function buildGlobalAttestedSuperchatIdSet(attestations: Event[]): Set<string> {
const out = new Set<string>()
for (const attestation of attestations) {
if (!isValidPaymentAttestation(attestation, attestation.pubkey)) continue
const targetId = getPaymentAttestationTargetId(attestation)
if (targetId) out.add(targetId.toLowerCase())
}
return out
}
/**
* Feeds: kind 9735 / 9740 / 9736 / 1814 only when attested (9741).
* Same attestation rule as threads and profile walls.
*/
export function shouldIncludePaymentInFeed(
event: Event,
attestedIds: ReadonlySet<string>
): boolean {
if (!isSuperchatKind(event.kind)) return true
return isAttestedSuperchat(event, attestedIds)
}
export function replyFeedSuperchatsFirst(sortedNonSuperchatReplies: Event[], superchats: Event[]) { export function replyFeedSuperchatsFirst(sortedNonSuperchatReplies: Event[], superchats: Event[]) {
return [...superchats, ...sortedNonSuperchatReplies] return [...superchats, ...sortedNonSuperchatReplies]
} }

6
src/lib/xmr-usd-rate.ts

@ -2,6 +2,12 @@ const CACHE_MS = 5 * 60 * 1000
let cache: { usd: number; at: number } | null = null let cache: { usd: number; at: number } | null = null
/** Cached XMR/USD if {@link fetchXmrUsdRate} has run recently (sync feed filters). */
export function getCachedXmrUsdRate(): number | null {
if (cache && Date.now() - cache.at < CACHE_MS) return cache.usd
return null
}
/** Latest XMR/USD spot price (cached ~5 min). */ /** Latest XMR/USD spot price (cached ~5 min). */
export async function fetchXmrUsdRate(): Promise<number | null> { export async function fetchXmrUsdRate(): Promise<number | null> {
if (cache && Date.now() - cache.at < CACHE_MS) { if (cache && Date.now() - cache.at < CACHE_MS) {

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

@ -1,50 +0,0 @@
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useZap } from '@/providers/ZapProvider'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
export default function ZapReplyThresholdInput() {
const { t } = useTranslation()
const { zapReplyThreshold, updateZapReplyThreshold } = useZap()
const [zapReplyThresholdInput, setZapReplyThresholdInput] = useState(zapReplyThreshold)
useEffect(() => {
setZapReplyThresholdInput(zapReplyThreshold)
}, [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>
)
}

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

@ -5,7 +5,6 @@ import { usePrimaryNoteView } from '@/contexts/primary-note-view-context'
import { forwardRef, useCallback, useEffect, useState } from 'react' import { forwardRef, useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import LightningAddressInput from './LightningAddressInput' import LightningAddressInput from './LightningAddressInput'
import ZapReplyThresholdInput from './ZapReplyThresholdInput'
import WalletZapSendingSettings from './WalletZapSendingSettings' import WalletZapSendingSettings from './WalletZapSendingSettings'
const WalletPage = forwardRef(({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => { const WalletPage = forwardRef(({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => {
@ -33,7 +32,6 @@ const WalletPage = forwardRef(({ index, hideTitlebar = false }: { index?: number
<div key={contentKey} className="px-4 pt-3 space-y-4"> <div key={contentKey} className="px-4 pt-3 space-y-4">
{LIGHTNING_WALLET_PAY_ENABLED ? <WalletZapSendingSettings /> : null} {LIGHTNING_WALLET_PAY_ENABLED ? <WalletZapSendingSettings /> : null}
<LightningAddressInput /> <LightningAddressInput />
<ZapReplyThresholdInput />
</div> </div>
</SecondaryPageLayout> </SecondaryPageLayout>
) )

10
src/providers/ZapProvider.tsx

@ -18,8 +18,6 @@ type TZapContext = {
updateDefaultComment: (comment: string) => void updateDefaultComment: (comment: string) => void
quickZap: boolean quickZap: boolean
updateQuickZap: (quickZap: boolean) => void updateQuickZap: (quickZap: boolean) => void
zapReplyThreshold: number
updateZapReplyThreshold: (sats: number) => void
includePublicZapReceipt: boolean includePublicZapReceipt: boolean
updateIncludePublicZapReceipt: (include: boolean) => void updateIncludePublicZapReceipt: (include: boolean) => void
} }
@ -39,7 +37,6 @@ export function ZapProvider({ children }: { children: React.ReactNode }) {
const [defaultZapSats, setDefaultZapSats] = useState<number>(storage.getDefaultZapSats()) const [defaultZapSats, setDefaultZapSats] = useState<number>(storage.getDefaultZapSats())
const [defaultZapComment, setDefaultZapComment] = useState<string>(storage.getDefaultZapComment()) const [defaultZapComment, setDefaultZapComment] = useState<string>(storage.getDefaultZapComment())
const [quickZap, setQuickZap] = useState<boolean>(storage.getQuickZap()) const [quickZap, setQuickZap] = useState<boolean>(storage.getQuickZap())
const [zapReplyThreshold, setZapReplyThreshold] = useState<number>(storage.getZapReplyThreshold())
const [includePublicZapReceipt, setIncludePublicZapReceipt] = useState<boolean>( const [includePublicZapReceipt, setIncludePublicZapReceipt] = useState<boolean>(
storage.getIncludePublicZapReceipt() storage.getIncludePublicZapReceipt()
) )
@ -95,11 +92,6 @@ export function ZapProvider({ children }: { children: React.ReactNode }) {
setQuickZap(quickZap) setQuickZap(quickZap)
} }
const updateZapReplyThreshold = (sats: number) => {
storage.setZapReplyThreshold(sats)
setZapReplyThreshold(sats)
}
const updateIncludePublicZapReceipt = (include: boolean) => { const updateIncludePublicZapReceipt = (include: boolean) => {
setIncludePublicZapReceipt(include) setIncludePublicZapReceipt(include)
void storage.setIncludePublicZapReceiptAsync(include) void storage.setIncludePublicZapReceiptAsync(include)
@ -117,8 +109,6 @@ export function ZapProvider({ children }: { children: React.ReactNode }) {
updateDefaultComment, updateDefaultComment,
quickZap, quickZap,
updateQuickZap, updateQuickZap,
zapReplyThreshold,
updateZapReplyThreshold,
includePublicZapReceipt, includePublicZapReceipt,
updateIncludePublicZapReceipt updateIncludePublicZapReceipt
}} }}

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

@ -54,7 +54,6 @@ const SETTINGS_KEYS = [
StorageKey.DEFAULT_ZAP_COMMENT, StorageKey.DEFAULT_ZAP_COMMENT,
StorageKey.QUICK_ZAP, StorageKey.QUICK_ZAP,
StorageKey.INCLUDE_PUBLIC_ZAP_RECEIPT, StorageKey.INCLUDE_PUBLIC_ZAP_RECEIPT,
StorageKey.ZAP_REPLY_THRESHOLD,
StorageKey.AUTOPLAY, StorageKey.AUTOPLAY,
StorageKey.HIDE_UNTRUSTED_INTERACTIONS, StorageKey.HIDE_UNTRUSTED_INTERACTIONS,
StorageKey.HIDE_UNTRUSTED_NOTIFICATIONS, StorageKey.HIDE_UNTRUSTED_NOTIFICATIONS,
@ -102,7 +101,6 @@ class LocalStorageService {
private defaultZapComment: string = 'Zap!' private defaultZapComment: string = 'Zap!'
private quickZap: boolean = false private quickZap: boolean = false
private includePublicZapReceipt: boolean = true private includePublicZapReceipt: boolean = true
private zapReplyThreshold: number = 1
private mediaUploadService: string = DEFAULT_NIP_96_SERVICE private mediaUploadService: string = DEFAULT_NIP_96_SERVICE
private autoplay: boolean = true private autoplay: boolean = true
private hideUntrustedInteractions: boolean = false private hideUntrustedInteractions: boolean = false
@ -205,14 +203,6 @@ class LocalStorageService {
this.includePublicZapReceipt = includeReceiptStr !== 'false' this.includePublicZapReceipt = includeReceiptStr !== 'false'
} }
const zapReplyThresholdStr = window.localStorage.getItem(StorageKey.ZAP_REPLY_THRESHOLD)
if (zapReplyThresholdStr) {
const num = parseInt(zapReplyThresholdStr)
if (!isNaN(num)) {
this.zapReplyThreshold = num
}
}
// deprecated // deprecated
this.mediaUploadService = this.mediaUploadService =
window.localStorage.getItem(StorageKey.MEDIA_UPLOAD_SERVICE) ?? DEFAULT_NIP_96_SERVICE window.localStorage.getItem(StorageKey.MEDIA_UPLOAD_SERVICE) ?? DEFAULT_NIP_96_SERVICE
@ -609,11 +599,6 @@ class LocalStorageService {
if (quickZapStr != null) this.quickZap = quickZapStr === 'true' if (quickZapStr != null) this.quickZap = quickZapStr === 'true'
const includeReceiptStr = get(StorageKey.INCLUDE_PUBLIC_ZAP_RECEIPT) const includeReceiptStr = get(StorageKey.INCLUDE_PUBLIC_ZAP_RECEIPT)
if (includeReceiptStr != null) this.includePublicZapReceipt = includeReceiptStr !== 'false' if (includeReceiptStr != null) this.includePublicZapReceipt = includeReceiptStr !== 'false'
const zapReplyStr = get(StorageKey.ZAP_REPLY_THRESHOLD)
if (zapReplyStr != null) {
const num = parseInt(zapReplyStr)
if (!isNaN(num)) this.zapReplyThreshold = num
}
this.autoplay = get(StorageKey.AUTOPLAY) !== 'false' this.autoplay = get(StorageKey.AUTOPLAY) !== 'false'
const hideInteractions = get(StorageKey.HIDE_UNTRUSTED_INTERACTIONS) const hideInteractions = get(StorageKey.HIDE_UNTRUSTED_INTERACTIONS)
if (hideInteractions != null) this.hideUntrustedInteractions = hideInteractions === 'true' if (hideInteractions != null) this.hideUntrustedInteractions = hideInteractions === 'true'
@ -841,15 +826,6 @@ class LocalStorageService {
await this.persistSettingToIndexedDb(StorageKey.INCLUDE_PUBLIC_ZAP_RECEIPT, include.toString()) await this.persistSettingToIndexedDb(StorageKey.INCLUDE_PUBLIC_ZAP_RECEIPT, include.toString())
} }
getZapReplyThreshold() {
return this.zapReplyThreshold
}
setZapReplyThreshold(sats: number) {
this.zapReplyThreshold = sats
this.persistSetting(StorageKey.ZAP_REPLY_THRESHOLD, sats.toString())
}
getAutoplay() { getAutoplay() {
return this.autoplay return this.autoplay
} }

Loading…
Cancel
Save