diff --git a/src/components/KindFilter/index.tsx b/src/components/KindFilter/index.tsx index c2245d67..743bad80 100644 --- a/src/components/KindFilter/index.tsx +++ b/src/components/KindFilter/index.tsx @@ -5,6 +5,17 @@ import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerTrigger } from import { Label } from '@/components/ui/label' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' 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 { cn } from '@/lib/utils' import { useKindFilterOrDefaults } from '@/providers/KindFilterProvider' @@ -19,26 +30,11 @@ const KIND_1111 = ExtendedKind.COMMENT const KIND_FILTER_OPTIONS = [ { kindGroup: [kinds.LongFormArticle, ExtendedKind.WIKI_ARTICLE, ExtendedKind.NOSTR_SPECIFICATION], label: 'Articles' }, - { kindGroup: [kinds.Highlights], label: 'Highlights' }, { 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: [ExtendedKind.DISCUSSION], label: 'Discussions' }, { kindGroup: [ExtendedKind.CALENDAR_EVENT_DATE, ExtendedKind.CALENDAR_EVENT_TIME], label: 'Calendar Events' }, { kindGroup: [...LIVE_ACTIVITY_KINDS], label: 'Live streams' }, - { - 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' } + { kindGroup: [kinds.Repost, ExtendedKind.GENERIC_REPOST], label: 'Boosts' } ] function buildShowKindsFromOptions( @@ -134,6 +130,14 @@ export default function KindFilter({ ) const canApply = temporarySeeAllEvents || appliedShowKinds.length > 0 + const postsGroupEnabled = isFeedPostsGroupEnabled(temporaryShowKind1OPs, temporaryShowKinds) + const repliesGroupEnabled = isFeedRepliesGroupEnabled( + temporaryShowKind1Replies, + temporaryShowKind1111, + temporaryShowKinds + ) + const gitGroupEnabled = isFeedGitGroupEnabled(temporaryShowKinds) + const handleApply = () => { if (!canApply) return @@ -196,38 +200,64 @@ export default function KindFilter({ {temporarySeeAllEvents ? t('See all events hint') : t('Use filter hint')}

- {/* Posts (OPs) - kind 1 top-level only */}
setTemporaryShowKind1OPs((prev) => !prev)} + onClick={() => { + const next = !postsGroupEnabled + const { showKinds: nextKinds, showKind1OPs } = applyFeedPostsGroupToggle( + temporaryShowKinds, + next + ) + setTemporaryShowKinds(nextKinds) + setTemporaryShowKind1OPs(showKind1OPs) + }} > -

{t('Posts (OPs)')}

-

kind {KIND_1}

+

{t('Posts')}

+

+ {t('Feed filter posts group kinds', { + kinds: [KIND_1, ...FEED_POSTS_GROUP_KINDS].join(', ') + })} +

- {/* Kind 1 replies - kind 1 that are replies */}
setTemporaryShowKind1Replies((prev) => !prev)} + onClick={() => { + const next = !repliesGroupEnabled + const { showKinds: nextKinds, showKind1Replies, showKind1111 } = applyFeedRepliesGroupToggle( + temporaryShowKinds, + next + ) + setTemporaryShowKinds(nextKinds) + setTemporaryShowKind1Replies(showKind1Replies) + setTemporaryShowKind1111(showKind1111) + }} > -

{t('Kind 1 replies')}

-

kind {KIND_1}

+

{t('Replies')}

+

+ {t('Feed filter replies group kinds', { + kinds: [KIND_1, KIND_1111, ...FEED_REPLIES_GROUP_KINDS].join(', ') + })} +

- {/* Comments - kind 1111 */}
setTemporaryShowKind1111((prev) => !prev)} + onClick={() => { + setTemporaryShowKinds(applyFeedGitGroupToggle(temporaryShowKinds, !gitGroupEnabled)) + }} > -

{t('Comments')}

-

kind {KIND_1111}

+

{t('Git')}

+

+ {t('Feed filter git group kinds', { kinds: FEED_GIT_GROUP_KINDS.join(', ') })} +

{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. */ @@ -259,7 +289,14 @@ export default function KindFilter({ variant="secondary" onClick={() => { 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) setTemporaryShowKind1Replies(true) diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index 897968d7..4e0918be 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -30,7 +30,8 @@ import { isLocalNetworkUrl, normalizeUrl } from '@/lib/url' import { eventPassesNoteListKindPicker } from '@/lib/feed-kind-filter' import { collectLocalEventsForTextSearch } from '@/lib/local-nip50-search-merge' 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 { useContentPolicyOptional } from '@/providers/ContentPolicyProvider' import { useDeletedEvent } from '@/providers/DeletedEventProvider' @@ -38,7 +39,6 @@ import { useMuteList } from '@/contexts/mute-list-context' import { muteSetHas } from '@/lib/mute-set' import { useNostr } from '@/providers/NostrProvider' import { useUserTrust } from '@/contexts/user-trust-context' -import { useZap } from '@/providers/ZapProvider' import client from '@/services/client.service' import noteStatsService from '@/services/note-stats.service' import indexedDb from '@/services/indexed-db.service' @@ -859,7 +859,6 @@ const NoteList = forwardRef( contentPolicy?.isOffline ?? (!navigator.onLine || (navigator as Navigator & { connection?: { type?: string } }).connection?.type === 'none') const { isEventDeleted } = useDeletedEvent() - const { zapReplyThreshold } = useZap() const { favoriteRelays, blockedRelays } = useFavoriteRelays() const [events, setEvents] = useState([]) const eventsRef = useRef([]) @@ -996,6 +995,19 @@ const NoteList = forwardRef( // Memoize subRequests serialization to avoid expensive JSON.stringify on every render const subRequestsKey = useMemo(() => legacyFeedSubscriptionKey(subRequests), [subRequests]) + const feedRelayUrls = useMemo(() => { + const urls = new Set() + 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( () => legacyFeedSubscriptionKey(followingFeedDeltaSubRequests ?? []), @@ -1310,8 +1322,8 @@ const NoteList = forwardRef( // Filter out expired events if (shouldFilterEvent(evt)) return true - // Filter out zap receipts below the zap-reply threshold (same rule as thread replies) - if (evt.kind === ExtendedKind.ZAP_RECEIPT && !shouldIncludeZapReceiptAtReplyThreshold(evt, zapReplyThreshold)) { + // Attested superchats only (9741), same as threads / profile walls. + if (!shouldIncludePaymentInFeed(evt, feedAttestedSuperchatIds)) { return true } @@ -1338,7 +1350,7 @@ const NoteList = forwardRef( mutePubkeySet, pinnedEventIds, isEventDeleted, - zapReplyThreshold, + feedAttestedSuperchatIds, extraShouldHideEvent, homeFeedActiveSeenOnAllowlist, homeFeedListMode @@ -3327,7 +3339,6 @@ const NoteList = forwardRef( if (!isReply && !showKind1OPsRef.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 (event.kind === ExtendedKind.COMMENT && !showKind1111Ref.current) return - if (event.kind === ExtendedKind.GIT_RELEASE && !showKind1OPsRef.current) return } } } diff --git a/src/components/ReplyNoteList/index.tsx b/src/components/ReplyNoteList/index.tsx index 0f13c564..3f6e2731 100644 --- a/src/components/ReplyNoteList/index.tsx +++ b/src/components/ReplyNoteList/index.tsx @@ -29,7 +29,6 @@ import { useSmartNoteNavigation } from '@/PageManager' import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useMuteList } from '@/contexts/mute-list-context' import { useNostr } from '@/providers/NostrProvider' -import { useZap } from '@/providers/ZapProvider' import { useReplyIngress } from '@/hooks/useReplyIngress' import { useUserTrust } from '@/contexts/user-trust-context' import { useCurrentRelays } from '@/providers/CurrentRelaysProvider' @@ -115,7 +114,6 @@ function ReplyNoteList({ const { mutePubkeySet } = useMuteList() const { hideContentMentioningMutedUsers } = useContentPolicy() const { pubkey: userPubkey } = useNostr() - const { zapReplyThreshold } = useZap() const { blockedRelays, favoriteRelays } = useFavoriteRelays() const { relayUrls: browsingRelayUrls } = useCurrentRelays() const relayAuthoritativeRead = @@ -241,11 +239,7 @@ function ReplyNoteList({ threadWalkFromRepliesMap.set(evt.id.toLowerCase(), evt) } - const { superchats, rest: nonZaps } = partitionAttestedSuperchats( - replyEvents, - attestedPaymentIds, - zapReplyThreshold - ) + const { superchats, rest: nonZaps } = partitionAttestedSuperchats(replyEvents, attestedPaymentIds) const zaps = superchats const replyScoreById = sort === 'top' || sort === 'controversial' || sort === 'most-zapped' @@ -336,7 +330,6 @@ function ReplyNoteList({ mutePubkeySet, hideContentMentioningMutedUsers, sort, - zapReplyThreshold, attestedPaymentIds, isDiscussionRoot, event.kind @@ -361,11 +354,7 @@ function ReplyNoteList({ const mergedFeed = useMemo(() => { /** Quotes + time-sorted feeds must not interleave zap receipts chronologically */ const zapsThenTimeSorted = (merged: NEvent[], direction: 'asc' | 'desc') => { - const { superchats, rest: nonZaps } = partitionAttestedSuperchats( - merged, - attestedPaymentIds, - zapReplyThreshold - ) + const { superchats, rest: nonZaps } = partitionAttestedSuperchats(merged, attestedPaymentIds) const sortedNon = [...nonZaps].sort((a, b) => 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) if (rootInfo?.type === 'E' || rootInfo?.type === 'A') { - const { superchats, rest: nonZaps } = partitionAttestedSuperchats( - replies, - attestedPaymentIds, - zapReplyThreshold - ) + const { superchats, rest: nonZaps } = partitionAttestedSuperchats(replies, attestedPaymentIds) const middle = nonZaps.filter((e) => !isEaThreadTailBacklinkCandidate(e, rootInfo)) const tailFromReplies = nonZaps.filter((e) => isEaThreadTailBacklinkCandidate(e, rootInfo)) const tailSeen = new Set() @@ -397,11 +382,7 @@ function ReplyNoteList({ // Web article / URL thread (NIP-22): same zaps → middle → tail layout as E/A if (rootInfo?.type === 'I') { - const { superchats, rest: nonZaps } = partitionAttestedSuperchats( - replies, - attestedPaymentIds, - zapReplyThreshold - ) + const { superchats, rest: nonZaps } = partitionAttestedSuperchats(replies, attestedPaymentIds) const middle = nonZaps.filter((e) => !isWebThreadTailKind(e.kind)) const tailFromReplies = nonZaps.filter((e) => isWebThreadTailKind(e.kind)) const tailSeen = new Set() @@ -423,7 +404,7 @@ function ReplyNoteList({ return [...replies] } 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 threadProfileLoadedRef = useRef>(new Set()) diff --git a/src/constants.ts b/src/constants.ts index 62b8d60f..de9803a5 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -336,7 +336,6 @@ export const StorageKey = { DEFAULT_ZAP_COMMENT: 'defaultZapComment', QUICK_ZAP: 'quickZap', INCLUDE_PUBLIC_ZAP_RECEIPT: 'includePublicZapReceipt', - ZAP_REPLY_THRESHOLD: 'zapReplyThreshold', /** Per-pubkey ms timestamps: last full network hydrate (see ACCOUNT_SESSION_NETWORK_HYDRATE_MIN_INTERVAL_MS). */ ACCOUNT_NETWORK_HYDRATE_AT_MAP: 'accountNetworkHydrateAtMap', AUTOPLAY: 'autoplay', diff --git a/src/hooks/useFeedAttestedSuperchatIds.ts b/src/hooks/useFeedAttestedSuperchatIds.ts new file mode 100644 index 00000000..1e1e50bd --- /dev/null +++ b/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 { + return buildGlobalAttestedSuperchatIdSet(attestations) +} + +/** Attested superchat target ids (9735 / 9740 / 9736 / 1814) for feed filtering. */ +export function useFeedAttestedSuperchatIds(relayUrls: string[]): Set { + const [attestedIds, setAttestedIds] = useState>(() => + 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).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 +} diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index d99fde8c..e69f6f5c 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -1212,6 +1212,10 @@ export default { 'Posts (OPs)': 'Beiträge (OPs)', 'Kind 1 replies': 'Kind-1-Antworten', 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', Articles: 'Artikel', Highlights: 'Highlights', @@ -1533,9 +1537,12 @@ export default { 'Invalid zap receipt': 'Invalid zap receipt', 'Zapped note': 'Zapped note', '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', + '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', Report: 'Melden', 'Successfully report': 'Erfolgreich gemeldet', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index c577892c..6d8821f4 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -1191,6 +1191,10 @@ export default { Posts: 'Posts', 'Posts (OPs)': 'Posts (OPs)', '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', 'Replies & comments': 'Replies & comments', Articles: 'Articles', @@ -1506,9 +1510,12 @@ export default { 'Invalid zap receipt': 'Invalid zap receipt', 'Zapped note': 'Zapped note', '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': - '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', Report: 'Report', 'Successfully report': 'Successfully reported', diff --git a/src/lib/btc-usd-rate.ts b/src/lib/btc-usd-rate.ts index 82ee5e28..46f40013 100644 --- a/src/lib/btc-usd-rate.ts +++ b/src/lib/btc-usd-rate.ts @@ -2,6 +2,12 @@ const CACHE_MS = 5 * 60 * 1000 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). */ export async function fetchBtcUsdRate(): Promise { if (cache && Date.now() - cache.at < CACHE_MS) { diff --git a/src/lib/event-metadata.ts b/src/lib/event-metadata.ts index 3eeb69c9..296b1567 100644 --- a/src/lib/event-metadata.ts +++ b/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 export function dTagToTitleCase(dTag: string): string { return dTag diff --git a/src/lib/feed-kind-filter.test.ts b/src/lib/feed-kind-filter.test.ts new file mode 100644 index 00000000..7a48cc02 --- /dev/null +++ b/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 & Pick): 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) + }) +}) diff --git a/src/lib/feed-kind-filter.ts b/src/lib/feed-kind-filter.ts index 7c681674..1fe2beb1 100644 --- a/src/lib/feed-kind-filter.ts +++ b/src/lib/feed-kind-filter.ts @@ -3,6 +3,86 @@ import { isReplyNoteEvent } from '@/lib/event' import type { Event } 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 * (not {@link shouldHideEvent} / mute / trust layers). @@ -15,12 +95,18 @@ export function eventPassesNoteListKindPicker( showKind1111: boolean ): boolean { 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) { const isReply = isReplyNoteEvent(event) if (isReply && !showKind1Replies) return false if (!isReply && !showKind1OPs) return false } if (event.kind === ExtendedKind.COMMENT && !showKind1111) return false - if (event.kind === ExtendedKind.GIT_RELEASE && !showKind1OPs) return false return true } diff --git a/src/lib/payment-attestation-cache.ts b/src/lib/payment-attestation-cache.ts index 4021466e..37f4fc2e 100644 --- a/src/lib/payment-attestation-cache.ts +++ b/src/lib/payment-attestation-cache.ts @@ -11,6 +11,14 @@ const authorHydrateByPubkey = new Map>() 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 { return `${targetEventId.trim().toLowerCase()}:${recipientPubkey.trim().toLowerCase()}` } diff --git a/src/lib/superchat.test.ts b/src/lib/superchat.test.ts index 62bd21f5..caecd4b1 100644 --- a/src/lib/superchat.test.ts +++ b/src/lib/superchat.test.ts @@ -12,7 +12,9 @@ import { isProfileWallPaymentNotification, isProfileWallZapReceipt, isNestedThreadReplyParentKind, - partitionAttestedSuperchats + buildGlobalAttestedSuperchatIdSet, + partitionAttestedSuperchats, + shouldIncludePaymentInFeed } from '@/lib/superchat' import { parsePaytoTagType } from '@/lib/payto' import { kinds, type Event } from 'nostr-tools' @@ -202,15 +204,14 @@ describe('partitionAttestedSuperchats', () => { const { superchats, rest } = partitionAttestedSuperchats( [zapAttested, zapUnattested, payment, comment], - attested, - 1 + attested ) expect(superchats.map((e) => e.id)).toEqual([payment.id, zapAttested.id]) 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 microZap = fakeEvent({ id: ZAP_ID, @@ -234,12 +235,62 @@ describe('partitionAttestedSuperchats', () => { kind: ExtendedKind.COMMENT, 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(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', () => { it('uses only the first p, e, and a tags', () => { const evt = fakeEvent({ diff --git a/src/lib/superchat.ts b/src/lib/superchat.ts index dcc1a1ea..28e97e60 100644 --- a/src/lib/superchat.ts +++ b/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']) +/** 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 = { senderPubkey: string recipientPubkey: string @@ -267,8 +280,7 @@ export function collectAttestedSuperchatsFromRepliesMap( export function partitionAttestedSuperchats( items: Event[], - attestedIds: Set, - _zapReplyThreshold: number + attestedIds: Set ): { superchats: Event[]; rest: Event[] } { const superchats: Event[] = [] const rest: Event[] = [] @@ -298,6 +310,29 @@ export function partitionAttestedSuperchats( 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 { + const out = new Set() + 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 +): boolean { + if (!isSuperchatKind(event.kind)) return true + return isAttestedSuperchat(event, attestedIds) +} + export function replyFeedSuperchatsFirst(sortedNonSuperchatReplies: Event[], superchats: Event[]) { return [...superchats, ...sortedNonSuperchatReplies] } diff --git a/src/lib/xmr-usd-rate.ts b/src/lib/xmr-usd-rate.ts index a4c3e185..1dec412e 100644 --- a/src/lib/xmr-usd-rate.ts +++ b/src/lib/xmr-usd-rate.ts @@ -2,6 +2,12 @@ const CACHE_MS = 5 * 60 * 1000 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). */ export async function fetchXmrUsdRate(): Promise { if (cache && Date.now() - cache.at < CACHE_MS) { diff --git a/src/pages/secondary/WalletPage/ZapReplyThresholdInput.tsx b/src/pages/secondary/WalletPage/ZapReplyThresholdInput.tsx deleted file mode 100644 index b9747f53..00000000 --- a/src/pages/secondary/WalletPage/ZapReplyThresholdInput.tsx +++ /dev/null @@ -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 ( -
- -
- { - 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 69b99764..07c971b1 100644 --- a/src/pages/secondary/WalletPage/index.tsx +++ b/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 { useTranslation } from 'react-i18next' import LightningAddressInput from './LightningAddressInput' -import ZapReplyThresholdInput from './ZapReplyThresholdInput' import WalletZapSendingSettings from './WalletZapSendingSettings' const WalletPage = forwardRef(({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => { @@ -33,7 +32,6 @@ const WalletPage = forwardRef(({ index, hideTitlebar = false }: { index?: number
{LIGHTNING_WALLET_PAY_ENABLED ? : null} -
) diff --git a/src/providers/ZapProvider.tsx b/src/providers/ZapProvider.tsx index ffefd304..d54360b1 100644 --- a/src/providers/ZapProvider.tsx +++ b/src/providers/ZapProvider.tsx @@ -18,8 +18,6 @@ type TZapContext = { updateDefaultComment: (comment: string) => void quickZap: boolean updateQuickZap: (quickZap: boolean) => void - zapReplyThreshold: number - updateZapReplyThreshold: (sats: number) => void includePublicZapReceipt: boolean updateIncludePublicZapReceipt: (include: boolean) => void } @@ -39,7 +37,6 @@ 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 [includePublicZapReceipt, setIncludePublicZapReceipt] = useState( storage.getIncludePublicZapReceipt() ) @@ -95,11 +92,6 @@ export function ZapProvider({ children }: { children: React.ReactNode }) { setQuickZap(quickZap) } - const updateZapReplyThreshold = (sats: number) => { - storage.setZapReplyThreshold(sats) - setZapReplyThreshold(sats) - } - const updateIncludePublicZapReceipt = (include: boolean) => { setIncludePublicZapReceipt(include) void storage.setIncludePublicZapReceiptAsync(include) @@ -117,8 +109,6 @@ export function ZapProvider({ children }: { children: React.ReactNode }) { updateDefaultComment, quickZap, updateQuickZap, - zapReplyThreshold, - updateZapReplyThreshold, includePublicZapReceipt, updateIncludePublicZapReceipt }} diff --git a/src/services/local-storage.service.ts b/src/services/local-storage.service.ts index 3138166d..f2b336d5 100644 --- a/src/services/local-storage.service.ts +++ b/src/services/local-storage.service.ts @@ -54,7 +54,6 @@ const SETTINGS_KEYS = [ StorageKey.DEFAULT_ZAP_COMMENT, StorageKey.QUICK_ZAP, StorageKey.INCLUDE_PUBLIC_ZAP_RECEIPT, - StorageKey.ZAP_REPLY_THRESHOLD, StorageKey.AUTOPLAY, StorageKey.HIDE_UNTRUSTED_INTERACTIONS, StorageKey.HIDE_UNTRUSTED_NOTIFICATIONS, @@ -102,7 +101,6 @@ class LocalStorageService { private defaultZapComment: string = 'Zap!' private quickZap: boolean = false private includePublicZapReceipt: boolean = true - private zapReplyThreshold: number = 1 private mediaUploadService: string = DEFAULT_NIP_96_SERVICE private autoplay: boolean = true private hideUntrustedInteractions: boolean = false @@ -205,14 +203,6 @@ class LocalStorageService { 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 this.mediaUploadService = window.localStorage.getItem(StorageKey.MEDIA_UPLOAD_SERVICE) ?? DEFAULT_NIP_96_SERVICE @@ -609,11 +599,6 @@ class LocalStorageService { if (quickZapStr != null) this.quickZap = quickZapStr === 'true' const includeReceiptStr = get(StorageKey.INCLUDE_PUBLIC_ZAP_RECEIPT) if (includeReceiptStr != null) this.includePublicZapReceipt = includeReceiptStr !== 'false' - const zapReplyStr = get(StorageKey.ZAP_REPLY_THRESHOLD) - if (zapReplyStr != null) { - const num = parseInt(zapReplyStr) - if (!isNaN(num)) this.zapReplyThreshold = num - } this.autoplay = get(StorageKey.AUTOPLAY) !== 'false' const hideInteractions = get(StorageKey.HIDE_UNTRUSTED_INTERACTIONS) if (hideInteractions != null) this.hideUntrustedInteractions = hideInteractions === 'true' @@ -841,15 +826,6 @@ class LocalStorageService { 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() { return this.autoplay }