From 46ba6978dddfb692eb7d728bd8c409a0c236dad1 Mon Sep 17 00:00:00 2001 From: Mary Kate Fain Date: Sun, 5 Apr 2026 17:44:13 -0500 Subject: [PATCH 01/14] Fix scroll position lost when navigating back from post detail page ScrollToTop was calling window.scrollTo(0, 0) on every pathname change, including back/forward (POP) navigation. This destroyed the browser's native scroll restoration, forcing users back to the top of the feed. Use useNavigationType() to only scroll to top on PUSH navigation (user clicked a link), preserving scroll position on POP (back/forward). Closes #217 --- src/components/ScrollToTop.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/components/ScrollToTop.tsx b/src/components/ScrollToTop.tsx index 5e383026..6c68a251 100644 --- a/src/components/ScrollToTop.tsx +++ b/src/components/ScrollToTop.tsx @@ -1,12 +1,17 @@ import { useEffect } from 'react'; -import { useLocation } from 'react-router-dom'; +import { useLocation, useNavigationType } from 'react-router-dom'; export function ScrollToTop() { const { pathname } = useLocation(); + const navigationType = useNavigationType(); useEffect(() => { - window.scrollTo(0, 0); - }, [pathname]); + // Only scroll to top on PUSH navigation (user clicked a link). + // On POP (back/forward), let the browser restore scroll position naturally. + if (navigationType === 'PUSH') { + window.scrollTo(0, 0); + } + }, [pathname, navigationType]); return null; } \ No newline at end of file From 313222d12eedac5bdfebf30a9f320a2edca6b9dc Mon Sep 17 00:00:00 2001 From: Mary Kate Fain Date: Sun, 5 Apr 2026 17:54:41 -0500 Subject: [PATCH 02/14] Fix custom feed scroll position lost on back navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SavedFeedContent was using useStreamPosts which stores data in React component state (useState). When navigating to a post detail page the component unmounts and all state is destroyed, forcing a full re-fetch on back navigation — losing the user's scroll position and content. Replace useStreamPosts with useTabFeed (useInfiniteQuery) to match how the Home, Ditto, and Global feeds work. TanStack Query caches all fetched pages independently of component lifecycle (gcTime = 30 min), so navigating back renders content instantly from cache, preserving scroll position. This also adds proper infinite scroll pagination and repost unwrapping to custom saved feeds, which previously loaded a single batch. Closes #217 --- src/components/Feed.tsx | 89 +++++++++++++++++++++++++++-------------- 1 file changed, 58 insertions(+), 31 deletions(-) diff --git a/src/components/Feed.tsx b/src/components/Feed.tsx index 544f2468..d0a1cc1c 100644 --- a/src/components/Feed.tsx +++ b/src/components/Feed.tsx @@ -1,7 +1,7 @@ -import { useState, useEffect, useMemo, useCallback } from 'react'; +import { useState, useEffect, useMemo } from 'react'; import { useInView } from 'react-intersection-observer'; import { useNostr } from '@nostrify/react'; -import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useQuery } from '@tanstack/react-query'; import { usePageRefresh } from '@/hooks/usePageRefresh'; import { ComposeBox } from '@/components/ComposeBox'; import { LandingHero } from '@/components/LandingHero'; @@ -19,8 +19,8 @@ import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useFeedTab } from '@/hooks/useFeedTab'; import { useInterests } from '@/hooks/useInterests'; import { useMuteList } from '@/hooks/useMuteList'; +import { useTabFeed } from '@/hooks/useProfileFeed'; import { useSavedFeeds } from '@/hooks/useSavedFeeds'; -import { useStreamPosts } from '@/hooks/useStreamPosts'; import { useResolveTabFilter } from '@/hooks/useResolveTabFilter'; import { getEnabledFeedKinds } from '@/lib/extraKinds'; import { isRepostKind, shouldHideFeedEvent } from '@/lib/feedUtils'; @@ -361,11 +361,11 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee ); } -/** Renders a saved search feed using useStreamPosts (live streaming). */ +/** Renders a saved search feed using useTabFeed (TanStack Query cached, infinite scroll). */ function SavedFeedContent({ feed }: { feed: SavedFeed }) { const { ref: scrollRef, inView } = useInView({ threshold: 0, rootMargin: '400px' }); const { user } = useCurrentUser(); - const queryClient = useQueryClient(); + const { muteItems } = useMuteList(); // Resolve variable placeholders ($follows etc.) the same way profile tabs do const { filter: resolvedFilter, isLoading: isResolving } = useResolveTabFilter( @@ -374,32 +374,46 @@ function SavedFeedContent({ feed }: { feed: SavedFeed }) { user?.pubkey ?? '', ); - const search = typeof resolvedFilter?.search === 'string' ? resolvedFilter.search : ''; - const kindsOverride = Array.isArray(resolvedFilter?.kinds) ? resolvedFilter.kinds as number[] : undefined; - const authorPubkeys = Array.isArray(resolvedFilter?.authors) ? resolvedFilter.authors as string[] : undefined; + const { + data: rawData, + isLoading: isFeedLoading, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = useTabFeed(resolvedFilter, `saved-${feed.id}`, !isResolving); - const { posts, isLoading: isStreamLoading } = useStreamPosts(search, { - includeReplies: true, - mediaType: 'all', - kindsOverride, - authorPubkeys: authorPubkeys && authorPubkeys.length > 0 ? authorPubkeys : undefined, - }); + const isLoading = isResolving || isFeedLoading; - const isLoading = isResolving || isStreamLoading; + const queryKey = useMemo( + () => ['tab-feed', `saved-${feed.id}`], + [feed.id], + ); + const handleRefresh = usePageRefresh(queryKey); - // useStreamPosts doesn't use TanStack Query, so refresh by invalidating the - // resolution query and letting the stream reconnect via remount. - const handleRefresh = useCallback(async () => { - await queryClient.invalidateQueries({ queryKey: ['resolve-tab-filter'] }); - }, [queryClient]); - - // Simple scroll-based load more isn't available with useStreamPosts (it's a stream), - // but we still wire the ref for future pagination support + // Infinite scroll: fetch next page when sentinel is in view useEffect(() => { - // intentionally empty — useStreamPosts handles its own streaming - }, [inView]); + if (inView && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]); - if (isLoading && posts.length === 0) { + // Flatten pages, deduplicate, and filter muted content + const feedItems = useMemo(() => { + if (!rawData?.pages) return []; + const seen = new Set(); + return rawData.pages + .flatMap((page) => page.items) + .filter((item) => { + const key = item.repostedBy ? `repost-${item.repostedBy}-${item.event.id}` : item.event.id; + if (!key || seen.has(key)) return false; + seen.add(key); + if (shouldHideFeedEvent(item.event)) return false; + if (muteItems.length > 0 && isEventMuted(item.event, muteItems)) return false; + return true; + }); + }, [rawData?.pages, muteItems]); + + if (isLoading && feedItems.length === 0) { return (
{Array.from({ length: 5 }).map((_, i) => ( @@ -409,10 +423,10 @@ function SavedFeedContent({ feed }: { feed: SavedFeed }) { ); } - if (posts.length === 0) { + if (feedItems.length === 0) { return ( - + ); } @@ -420,10 +434,23 @@ function SavedFeedContent({ feed }: { feed: SavedFeed }) { return (
- {posts.map((event) => ( - + {feedItems.map((item) => ( + ))} -
+ {hasNextPage && ( +
+ {isFetchingNextPage && ( +
+ +
+ )} +
+ )} + {!hasNextPage &&
}
); From 2fbc9e04090845dd0eb18203f2bb83ce45f12c38 Mon Sep 17 00:00:00 2001 From: Mary Kate Fain Date: Sun, 5 Apr 2026 17:59:13 -0500 Subject: [PATCH 03/14] Add protocol:nostr to saved feed queries for latest results The previous useStreamPosts always injected 'protocol:nostr' into the NIP-50 search string, which is a Ditto relay extension that filters for native Nostr events. Without it, useTabFeed's queries return stale or fewer results because the relay doesn't scope to the Nostr protocol. Augment the resolved filter's search field with 'protocol:nostr' before passing it to useTabFeed, matching the old behavior. --- src/components/Feed.tsx | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/components/Feed.tsx b/src/components/Feed.tsx index d0a1cc1c..c4b7cc07 100644 --- a/src/components/Feed.tsx +++ b/src/components/Feed.tsx @@ -374,13 +374,25 @@ function SavedFeedContent({ feed }: { feed: SavedFeed }) { user?.pubkey ?? '', ); + // Augment the resolved filter with protocol:nostr (NIP-50 Ditto extension) + // to match the behavior of the core feeds and ensure latest native Nostr + // posts are returned. + const augmentedFilter = useMemo(() => { + if (!resolvedFilter) return null; + const existing = resolvedFilter.search ?? ''; + const search = existing + ? `${existing} protocol:nostr` + : 'protocol:nostr'; + return { ...resolvedFilter, search }; + }, [resolvedFilter]); + const { data: rawData, isLoading: isFeedLoading, fetchNextPage, hasNextPage, isFetchingNextPage, - } = useTabFeed(resolvedFilter, `saved-${feed.id}`, !isResolving); + } = useTabFeed(augmentedFilter, `saved-${feed.id}`, !isResolving); const isLoading = isResolving || isFeedLoading; From 5c8c33747e4f1363178a056e7fad6d50b40c1532 Mon Sep 17 00:00:00 2001 From: Mary Kate Fain Date: Fri, 10 Apr 2026 12:36:27 -0500 Subject: [PATCH 04/14] Guard against redundant protocol:nostr and document prefix queryKey - Skip appending protocol:nostr if the resolved filter already contains it - Add comment explaining why the 2-element prefix key correctly invalidates the full 5-element useTabFeed query key via TanStack prefix matching --- src/components/Feed.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/components/Feed.tsx b/src/components/Feed.tsx index c4b7cc07..3a2e979c 100644 --- a/src/components/Feed.tsx +++ b/src/components/Feed.tsx @@ -380,9 +380,11 @@ function SavedFeedContent({ feed }: { feed: SavedFeed }) { const augmentedFilter = useMemo(() => { if (!resolvedFilter) return null; const existing = resolvedFilter.search ?? ''; - const search = existing - ? `${existing} protocol:nostr` - : 'protocol:nostr'; + const search = existing.includes('protocol:nostr') + ? existing + : existing + ? `${existing} protocol:nostr` + : 'protocol:nostr'; return { ...resolvedFilter, search }; }, [resolvedFilter]); @@ -396,6 +398,8 @@ function SavedFeedContent({ feed }: { feed: SavedFeed }) { const isLoading = isResolving || isFeedLoading; + // Prefix key -- usePageRefresh does prefix matching, so this invalidates + // the full ['tab-feed', tabKey, kindsKey, authorsKey, searchKey] used by useTabFeed. const queryKey = useMemo( () => ['tab-feed', `saved-${feed.id}`], [feed.id], From a55ff616699e96c5d36c408f3a0672ddd66599d6 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 16 Apr 2026 13:21:15 -0500 Subject: [PATCH 05/14] Verify NIP-17 inner rumor pubkey matches seal pubkey NIP-17 requires that clients verify `messageEvent.pubkey === sealEvent.pubkey` before trusting a gift-wrapped direct message. Without this check, any attacker can construct a rumor claiming to be from another user and gift-wrap it to the victim -- the seal signature only authenticates the seal author, not the (unsigned) inner rumor. Ditto's primary sender display uses sealEvent.pubkey so the headline impersonation case is mitigated in practice, but the inner event's fields (including its pubkey) are passed whole to NoteContent for kind 15 file attachments, which could leak into downstream zap/reply targeting. Add the spec-mandated check to prevent any trust in the inner pubkey. --- src/components/DMProvider.tsx | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/components/DMProvider.tsx b/src/components/DMProvider.tsx index 192aebf8..1c271d43 100644 --- a/src/components/DMProvider.tsx +++ b/src/components/DMProvider.tsx @@ -905,6 +905,29 @@ export function DMProvider({ children, config }: DMProviderProps) { const messageContent = await user.signer.nip44.decrypt(sealEvent.pubkey, sealEvent.content); const messageEvent = JSON.parse(messageContent) as NostrEvent; + // NIP-17: clients MUST verify that the inner rumor's pubkey matches the + // seal's pubkey. Without this check, anyone can gift-wrap a rumor whose + // `pubkey` field claims to be someone else and impersonate that user. + // The seal signature authenticates only the seal author, not whatever + // pubkey appears inside the (unsigned) rumor. + if (messageEvent.pubkey !== sealEvent.pubkey) { + console.log(`[DM] ⚠️ NIP-17 IMPERSONATION ATTEMPT - inner pubkey does not match seal pubkey`, { + giftWrapId: event.id, + sealPubkey: sealEvent.pubkey, + innerPubkey: messageEvent.pubkey, + }); + return { + processedMessage: { + ...event, + content: '', + decryptedContent: '', + error: 'Inner event pubkey does not match seal pubkey (possible impersonation)', + }, + conversationPartner: event.pubkey, + sealEvent, + }; + } + // Accept both kind 14 (text) and kind 15 (files/attachments) if (messageEvent.kind !== 14 && messageEvent.kind !== 15) { console.log(`[DM] ⚠️ NIP-17 MESSAGE WITH UNSUPPORTED INNER EVENT KIND:`, { From e01ed039fb138d1c85e9fefd1dd1b1e38ee8ab6f Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 16 Apr 2026 13:22:55 -0500 Subject: [PATCH 06/14] Add restrictive sandbox attribute to web sandbox iframe Previously the SandboxFrame iframe relied entirely on cross-origin subdomain isolation (the HMAC-derived `.sandbox.ditto.pub` origin) for containment. That does give origin-keyed storage and postMessage isolation, but it does not restrict top-frame navigation, pointer lock, or other capabilities that a hostile nsite/webxdc app could abuse. The highest-value protection here is blocking `allow-top-navigation`: without it, a malicious nsite could do `window.top.location = evilUrl` and redirect the entire Ditto tab to a phishing page that impersonates the app. The user opened a preview expecting to stay inside Ditto, so this is a realistic and impactful attack. The policy grants the capabilities that real web apps legitimately use (scripts, same-origin storage + Service Workers per iframe.diy's architecture, forms, modals, popups that escape the sandbox, downloads) while withholding the ones that are either attacks (top navigation) or unused niche features (pointer lock, presentation API, orientation lock). Also Omit 'sandbox' from the spread props so consumers cannot accidentally weaken the policy. --- src/components/SandboxFrame.tsx | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/components/SandboxFrame.tsx b/src/components/SandboxFrame.tsx index e5060e02..0e37b587 100644 --- a/src/components/SandboxFrame.tsx +++ b/src/components/SandboxFrame.tsx @@ -33,7 +33,7 @@ import { // --------------------------------------------------------------------------- export interface SandboxFrameProps - extends Omit, 'src' | 'id'> { + extends Omit, 'src' | 'id' | 'sandbox'> { /** HMAC-derived subdomain identifier. */ id: string; /** @@ -324,6 +324,20 @@ const SandboxFrameWeb = forwardRef(