From e7cc41ceaf47d849df47e02d7b0a38de4f34a50c Mon Sep 17 00:00:00 2001 From: Silberengel Date: Tue, 24 Mar 2026 11:04:55 +0100 Subject: [PATCH] bug-fixes --- src/components/Embedded/EmbeddedNote.tsx | 6 + src/components/NoteCard/MainNoteCard.tsx | 6 +- src/components/NoteCard/index.tsx | 17 +- src/components/NoteDrawer/index.tsx | 11 +- src/components/QuoteList/index.tsx | 257 ------------------ src/components/ReplyNoteList/index.tsx | 135 +++++++-- src/constants.ts | 6 + .../suppress-embedded-note-context.tsx | 8 + src/contexts/user-trust-context.tsx | 1 + src/hooks/index.tsx | 1 + src/hooks/useQuoteEvents.tsx | 182 +++++++++++++ src/i18n/locales/de.ts | 3 + src/i18n/locales/en.ts | 3 + src/layouts/SecondaryPageLayout/index.tsx | 2 +- src/lib/event.ts | 35 ++- src/providers/ReplyProvider.tsx | 38 ++- src/providers/UserTrustProvider.tsx | 11 +- 17 files changed, 415 insertions(+), 307 deletions(-) delete mode 100644 src/components/QuoteList/index.tsx create mode 100644 src/contexts/suppress-embedded-note-context.tsx create mode 100644 src/hooks/useQuoteEvents.tsx diff --git a/src/components/Embedded/EmbeddedNote.tsx b/src/components/Embedded/EmbeddedNote.tsx index 9a7df6cf..93acdb97 100644 --- a/src/components/Embedded/EmbeddedNote.tsx +++ b/src/components/Embedded/EmbeddedNote.tsx @@ -24,6 +24,7 @@ import { type EmbeddedNoteIdValidation, validateEmbeddedNotePointer } from './embeddedNotePointer' +import { useSuppressEmbeddedNoteId } from '@/contexts/suppress-embedded-note-context' /** Embedded `noteId` is often raw hex from parsers; must accept A–F and normalize for REQ `ids`. */ function hexEventIdFromNoteId(noteId: string): string | null { @@ -60,6 +61,11 @@ export function EmbeddedNote({ className?: string containingEvent?: Event }) { + const suppressId = useSuppressEmbeddedNoteId() + const embeddedHexId = useMemo(() => hexEventIdFromNoteId(noteId), [noteId]) + if (suppressId && embeddedHexId && embeddedHexId === suppressId.toLowerCase()) { + return null + } const validation = useMemo(() => validateEmbeddedNotePointer(noteId), [noteId]) if (!validation.valid) { return ( diff --git a/src/components/NoteCard/MainNoteCard.tsx b/src/components/NoteCard/MainNoteCard.tsx index c977ff90..7cd03f7a 100644 --- a/src/components/NoteCard/MainNoteCard.tsx +++ b/src/components/NoteCard/MainNoteCard.tsx @@ -16,7 +16,8 @@ export default function MainNoteCard({ reposter, embedded, originalNoteId, - pinned = false + pinned = false, + hideParentNotePreview = false }: { event: Event className?: string @@ -25,6 +26,8 @@ export default function MainNoteCard({ originalNoteId?: string /** Profile (or other) pinned highlight */ pinned?: boolean + /** Hide the parent note preview (e.g. when showing quotes of current note). */ + hideParentNotePreview?: boolean }) { const { t } = useTranslation() const { navigateToNote } = useSmartNoteNavigationOptional() @@ -71,6 +74,7 @@ export default function MainNoteCard({ event={event} originalNoteId={originalNoteId} disableClick={true} + hideParentNotePreview={hideParentNotePreview} /> {!embedded && ( diff --git a/src/components/NoteCard/index.tsx b/src/components/NoteCard/index.tsx index 844fa548..7b2710d7 100644 --- a/src/components/NoteCard/index.tsx +++ b/src/components/NoteCard/index.tsx @@ -11,12 +11,15 @@ const NoteCard = memo(function NoteCard({ event, className, filterMutedNotes = true, - pinned = false + pinned = false, + hideParentNotePreview = false }: { event: Event className?: string filterMutedNotes?: boolean pinned?: boolean + /** When true, hide the parent/root note preview (e.g. when showing quotes of the current note). */ + hideParentNotePreview?: boolean }) { const { mutePubkeySet } = useMuteList() const { hideContentMentioningMutedUsers } = useContentPolicy() @@ -41,7 +44,14 @@ const NoteCard = memo(function NoteCard({ /> ) } - return + return ( + + ) }, (prevProps, nextProps) => { // Custom comparison function for memo return ( @@ -49,7 +59,8 @@ const NoteCard = memo(function NoteCard({ prevProps.event.created_at === nextProps.event.created_at && prevProps.className === nextProps.className && prevProps.filterMutedNotes === nextProps.filterMutedNotes && - prevProps.pinned === nextProps.pinned + prevProps.pinned === nextProps.pinned && + prevProps.hideParentNotePreview === nextProps.hideParentNotePreview ) }) diff --git a/src/components/NoteDrawer/index.tsx b/src/components/NoteDrawer/index.tsx index 336d55d5..96149f52 100644 --- a/src/components/NoteDrawer/index.tsx +++ b/src/components/NoteDrawer/index.tsx @@ -1,6 +1,7 @@ import { useState, useEffect, useRef } from 'react' import { Sheet, SheetContent } from '@/components/ui/sheet' import NotePage from '@/pages/secondary/NotePage' +import { useSecondaryPage } from '@/PageManager' import type { Event } from 'nostr-tools' interface NoteDrawerProps { @@ -11,6 +12,7 @@ interface NoteDrawerProps { } export default function NoteDrawer({ open, onOpenChange, noteId, initialEvent }: NoteDrawerProps) { + const { currentIndex } = useSecondaryPage() const [displayNoteId, setDisplayNoteId] = useState(noteId) const timeoutRef = useRef(null) @@ -44,8 +46,13 @@ export default function NoteDrawer({ open, onOpenChange, noteId, initialEvent }: return ( -
- +
+
diff --git a/src/components/QuoteList/index.tsx b/src/components/QuoteList/index.tsx deleted file mode 100644 index d06f6e76..00000000 --- a/src/components/QuoteList/index.tsx +++ /dev/null @@ -1,257 +0,0 @@ -import { FAST_READ_RELAY_URLS } from '@/constants' -import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event' -import { normalizeUrl } from '@/lib/url' -import { useCurrentRelays } from '@/providers/CurrentRelaysProvider' -import { useNostr } from '@/providers/NostrProvider' -import { useUserTrust } from '@/contexts/user-trust-context' -import client from '@/services/client.service' -import dayjs from 'dayjs' -import { Event, kinds } from 'nostr-tools' -import { useEffect, useRef, useState } from 'react' -import { useTranslation } from 'react-i18next' -import { cn } from '@/lib/utils' -import NoteCard, { NoteCardLoadingSkeleton } from '../NoteCard' - -const LIMIT = 100 -const SHOW_COUNT = 10 -/** Multi-filter quote subs only set `eosed` after every sub EOSEs; one stuck relay would otherwise leave the UI loading forever. */ -const INITIAL_QUOTE_LOAD_TIMEOUT_MS = 12_000 - -export default function QuoteList({ - event, - className, - embedded = false -}: { - event: Event - className?: string - /** When true, compact layout for use below the replies feed (no full-tab min-height). */ - embedded?: boolean -}) { - const { t } = useTranslation() - const { relayList: userRelayList } = useNostr() - const { relayUrls: browsingRelayUrls } = useCurrentRelays() - const { hideUntrustedInteractions, isUserTrusted } = useUserTrust() - const [timelineKey, setTimelineKey] = useState(undefined) - const [events, setEvents] = useState([]) - const [showCount, setShowCount] = useState(SHOW_COUNT) - const [hasMore, setHasMore] = useState(true) - const [loading, setLoading] = useState(true) - const bottomRef = useRef(null) - const receivedAnyQuotesRef = useRef(false) - - useEffect(() => { - let cancelled = false - let loadTimeoutId: ReturnType | undefined - - async function init() { - setLoading(true) - setEvents([]) - setHasMore(true) - receivedAnyQuotesRef.current = false - - loadTimeoutId = setTimeout(() => { - if (cancelled) return - setLoading(false) - if (!receivedAnyQuotesRef.current) { - setHasMore(false) - } - }, INITIAL_QUOTE_LOAD_TIMEOUT_MS) - - const userRelays = userRelayList?.read || [] - const fromFeed = browsingRelayUrls.map((u) => normalizeUrl(u) || u).filter(Boolean) - const finalRelayUrls = Array.from( - new Set([ - ...fromFeed, - ...userRelays.map((url) => normalizeUrl(url) || url), - ...FAST_READ_RELAY_URLS.map((url) => normalizeUrl(url) || url) - ]) - ) - - const eventId = isReplaceableEvent(event.kind) ? getReplaceableCoordinateFromEvent(event) : event.id - const eventCoordinate = isReplaceableEvent(event.kind) ? getReplaceableCoordinateFromEvent(event) : `${event.kind}:${event.pubkey}:${event.id}` - - const { closer, timelineKey } = await client.subscribeTimeline( - [ - { - urls: finalRelayUrls, - filter: { - '#q': [eventId], - kinds: [ - kinds.ShortTextNote - ], - limit: LIMIT - } - }, - { - urls: finalRelayUrls, - filter: { - '#e': [eventId], - kinds: [ - kinds.Highlights, - kinds.LongFormArticle - ], - limit: LIMIT - } - }, - { - urls: finalRelayUrls, - filter: { - '#a': [eventCoordinate], - kinds: [ - kinds.Highlights, - kinds.LongFormArticle - ], - limit: LIMIT - } - } - ], - { - onEvents: (batch, eosed) => { - if (cancelled) return - if (batch.length > 0) { - receivedAnyQuotesRef.current = true - setEvents(batch) - } - if (batch.length > 0 || eosed) { - setLoading(false) - if (loadTimeoutId) { - clearTimeout(loadTimeoutId) - loadTimeoutId = undefined - } - } - if (eosed) { - setHasMore(batch.length > 0) - } - }, - onNew: (newEvt) => { - if (cancelled) return - receivedAnyQuotesRef.current = true - setLoading(false) - if (loadTimeoutId) { - clearTimeout(loadTimeoutId) - loadTimeoutId = undefined - } - setHasMore(true) - setEvents((oldEvents) => - [newEvt, ...oldEvents].sort((a, b) => b.created_at - a.created_at) - ) - } - } - ) - if (cancelled) { - closer() - return undefined - } - setTimelineKey(timelineKey) - return closer - } - - const promise = init() - return () => { - cancelled = true - if (loadTimeoutId) clearTimeout(loadTimeoutId) - promise.then((closer) => closer?.()) - } - }, [event, browsingRelayUrls, userRelayList?.read]) - - useEffect(() => { - const options = { - root: null, - rootMargin: '10px', - threshold: 0.1 - } - - const loadMore = async () => { - if (showCount < events.length) { - setShowCount((prev) => prev + SHOW_COUNT) - // preload more - if (events.length - showCount > LIMIT / 2) { - return - } - } - - if (!timelineKey || loading || !hasMore) return - setLoading(true) - try { - const newEvents = await client.loadMoreTimeline( - timelineKey, - events.length ? events[events.length - 1].created_at - 1 : dayjs().unix(), - LIMIT - ) - - // CRITICAL FIX: Be more conservative about stopping - // Check if timeline has more cached refs that we haven't loaded yet - if (newEvents.length === 0) { - const until = events.length ? events[events.length - 1].created_at - 1 : dayjs().unix() - const hasMoreCached = client.hasMoreTimelineEvents?.(timelineKey, until) ?? false - - if (hasMoreCached) { - // There are more cached events, keep hasMore true and try again - setLoading(false) - setTimeout(() => { - if (hasMore && !loading) { - loadMore() - } - }, 300) - return - } - - // No more events available, stop loading - setHasMore(false) - } else { - setEvents((oldEvents) => [...oldEvents, ...newEvents]) - } - } catch (error) { - // On error, don't set hasMore to false - might be temporary network issue - console.error('[QuoteList] Error loading more events', error) - } finally { - setLoading(false) - } - } - - const observerInstance = new IntersectionObserver((entries) => { - if (entries[0].isIntersecting && hasMore) { - loadMore() - } - }, options) - - const currentBottomRef = bottomRef.current - - if (currentBottomRef) { - observerInstance.observe(currentBottomRef) - } - - return () => { - if (observerInstance && currentBottomRef) { - observerInstance.unobserve(currentBottomRef) - } - } - }, [timelineKey, loading, hasMore, events, showCount]) - - return ( -
- {embedded && ( -

{t('Quotes')}

- )} -
-
- {events.slice(0, showCount).map((event) => { - if (hideUntrustedInteractions && !isUserTrusted(event.pubkey)) { - return null - } - return - })} -
- {hasMore || loading ? ( -
- -
- ) : ( -
{t('no more notes')}
- )} -
- {!embedded &&
} - {embedded &&
} -
- ) -} diff --git a/src/components/ReplyNoteList/index.tsx b/src/components/ReplyNoteList/index.tsx index dfbd2a3d..ebcba5a3 100644 --- a/src/components/ReplyNoteList/index.tsx +++ b/src/components/ReplyNoteList/index.tsx @@ -1,6 +1,7 @@ -import { ExtendedKind } from '@/constants' +import { E_TAG_FILTER_BLOCKED_RELAY_URLS, ExtendedKind } from '@/constants' import { getArticleUrlFromCommentITags } from '@/lib/rss-article' import { + eventReferencesEventId, getParentETag, getReplaceableCoordinateFromEvent, getRootATag, @@ -33,8 +34,10 @@ import { Filter, Event as NEvent, kinds } from 'nostr-tools' import { useNoteStatsById } from '@/hooks/useNoteStatsById' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' +import { useQuoteEvents } from '@/hooks' +import { SuppressEmbeddedNoteContext } from '@/contexts/suppress-embedded-note-context' import { LoadingBar } from '../LoadingBar' -import QuoteList from '../QuoteList' +import NoteCard, { NoteCardLoadingSkeleton } from '../NoteCard' import ReplyNote, { ReplyNoteSkeleton } from '../ReplyNote' import ZapReplyFeedRow from './ZapReplyFeedRow' @@ -61,15 +64,19 @@ function ReplyNoteList({ const { t } = useTranslation() const { navigateToNote } = useSmartNoteNavigation() const { currentIndex } = useSecondaryPage() - const { hideUntrustedInteractions, isUserTrusted } = useUserTrust() + const { hideUntrustedInteractions, isUserTrusted, isTrustLoaded } = useUserTrust() const noteStats = useNoteStatsById(event.id) const { mutePubkeySet } = useMuteList() const { hideContentMentioningMutedUsers } = useContentPolicy() - const { relayList: userRelayList, pubkey: userPubkey } = useNostr() + const { pubkey: userPubkey } = useNostr() const { blockedRelays } = useFavoriteRelays() const { relayUrls: browsingRelayUrls } = useCurrentRelays() const [rootInfo, setRootInfo] = useState(undefined) const { repliesMap, addReplies } = useReply() + const { quoteEvents, quoteLoading } = useQuoteEvents( + event, + showQuotes ?? false + ) // Helper function to get vote score for a reply const getReplyVoteScore = (reply: NEvent) => { @@ -115,9 +122,9 @@ function ReplyNoteList({ const replyEvents: NEvent[] = [] const currentEventKey = isReplaceableEvent(event.kind) ? getReplaceableCoordinateFromEvent(event) - : event.id + : /^[0-9a-f]{64}$/i.test(event.id) ? event.id.toLowerCase() : event.id // For replaceable events, also check the event ID in case replies are stored there - const eventIdKey = event.id + const eventIdKey = /^[0-9a-f]{64}$/i.test(event.id) ? event.id.toLowerCase() : event.id let parentEventKeys = [currentEventKey] if (isReplaceableEvent(event.kind) && currentEventKey !== eventIdKey) { parentEventKeys.push(eventIdKey) @@ -201,12 +208,30 @@ function ReplyNoteList({ } }, [event.id, repliesMap, mutePubkeySet, hideContentMentioningMutedUsers, sort]) + const replyIdSet = useMemo(() => new Set(replies.map((r) => r.id)), [replies]) + const mergedFeed = useMemo(() => { + if (!showQuotes) return replies + const quoteOnly = quoteEvents.filter((e) => !replyIdSet.has(e.id)) + const merged = [...replies, ...quoteOnly] + if (sort === 'oldest') return merged.sort((a, b) => a.created_at - b.created_at) + if (sort === 'newest') return merged.sort((a, b) => b.created_at - a.created_at) + if (sort === 'top' || sort === 'controversial' || sort === 'most-zapped') { + const replyIds = new Set(replies.map((r) => r.id)) + const sortedReplies = [...replies] + const qo = merged.filter((e) => !replyIds.has(e.id)) + const sortedQuotes = [...qo].sort((a, b) => b.created_at - a.created_at) + return [...sortedReplies, ...sortedQuotes] + } + return merged.sort((a, b) => b.created_at - a.created_at) + }, [replies, quoteEvents, showQuotes, sort, replyIdSet]) + const zapsForFeed = useMemo(() => { if (shouldHideInteractions(event)) return [] const raw = noteStats?.zaps ?? [] - const filtered = hideUntrustedInteractions ? raw.filter((z) => isUserTrusted(z.pubkey)) : raw + const filtered = + isTrustLoaded && hideUntrustedInteractions ? raw.filter((z) => isUserTrusted(z.pubkey)) : raw return [...filtered].sort((a, b) => b.amount - a.amount) - }, [event, noteStats, hideUntrustedInteractions, isUserTrusted]) + }, [event, noteStats, isTrustLoaded, hideUntrustedInteractions, isUserTrusted]) const [timelineKey] = useState(undefined) const [until, setUntil] = useState(undefined) @@ -312,9 +337,13 @@ function ReplyNoteList({ } }, [rootInfo, onNewReply]) + const replyFetchGenRef = useRef(0) + useEffect(() => { if (!rootInfo || currentIndex !== index) return + const fetchGeneration = ++replyFetchGenRef.current + const init = async () => { // Check cache first - get cached data even if stale (for instant display) const cachedData = discussionFeedCache.getCachedReplies(rootInfo) @@ -345,7 +374,7 @@ function ReplyNoteList({ async function fetchFromRelays() { if (!rootInfo) return // Type guard - + try { // READ from: FAST_READ_RELAY_URLS + user's inboxes + local relays + OP author's outboxes const opAuthorPubkey = rootInfo.type === 'E' || rootInfo.type === 'A' ? rootInfo.pubkey : undefined @@ -354,12 +383,18 @@ function ReplyNoteList({ const threadRelayHints = [ ...new Set([...relayHintsFromEventTags(event), ...seenOn, ...fromBrowsingFeed]) ] - const finalRelayUrls = await buildReplyReadRelayList( + let finalRelayUrls = await buildReplyReadRelayList( opAuthorPubkey, userPubkey || undefined, blockedRelays || [], threadRelayHints ) + const eTagBlockedSet = new Set( + E_TAG_FILTER_BLOCKED_RELAY_URLS.map((u) => normalizeUrl(u) || u) + ) + finalRelayUrls = finalRelayUrls.filter( + (u) => !eTagBlockedSet.has(normalizeUrl(u) || u) + ) const filters: Filter[] = [] if (rootInfo.type === 'E') { @@ -375,6 +410,12 @@ function ReplyNoteList({ kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT], limit: LIMIT }) + // Kind-1 notes that quote via #q without e-tags (still part of this thread) + filters.push({ + '#q': [rootInfo.id], + kinds: [kinds.ShortTextNote], + limit: LIMIT + }) // For public messages (kind 24), also look for replies using 'q' tags if (event.kind === ExtendedKind.PUBLIC_MESSAGE) { filters.push({ @@ -416,6 +457,8 @@ function ReplyNoteList({ // Use fetchEvents instead of subscribeTimeline for one-time fetching const allReplies = await queryService.fetchEvents(finalRelayUrls, filters) + if (fetchGeneration !== replyFetchGenRef.current) return + // Filter and add replies const regularReplies = allReplies.filter((evt) => isReplyNoteEvent(evt)) @@ -443,6 +486,7 @@ function ReplyNoteList({ } } catch (error) { logger.error('[ReplyNoteList] Error fetching replies:', error) + if (fetchGeneration !== replyFetchGenRef.current) return if (!hasCache) { // Only set loading to false if we don't have cache to fall back on setLoading(false) @@ -452,7 +496,17 @@ function ReplyNoteList({ } init() - }, [rootInfo, currentIndex, index, userRelayList, event, blockedRelays, browsingRelayUrls, addReplies]) + }, [ + rootInfo, + currentIndex, + index, + userPubkey, + event.id, + event.kind, + blockedRelays, + browsingRelayUrls, + addReplies + ]) useEffect(() => { if (replies.length === 0 && !loading && timelineKey) { @@ -468,7 +522,7 @@ function ReplyNoteList({ } const observerInstance = new IntersectionObserver((entries) => { - if (entries[0].isIntersecting && showCount < replies.length) { + if (entries[0].isIntersecting && showCount < mergedFeed.length) { setShowCount((prev) => prev + SHOW_COUNT) } }, options) @@ -484,7 +538,7 @@ function ReplyNoteList({ observerInstance.unobserve(currentBottomRef) } } - }, [replies, showCount]) + }, [mergedFeed.length, showCount]) const loadMore = useCallback(async () => { if (loading || !until || !timelineKey) return @@ -516,7 +570,7 @@ function ReplyNoteList({ }, []) return ( -
+
{loading && } {zapsForFeed.map((zap) => ( @@ -530,10 +584,13 @@ function ReplyNoteList({
)}
- {replies.slice(0, showCount).map((reply) => { - if (hideUntrustedInteractions && !isUserTrusted(reply.pubkey)) { - const repliesForThisReply = repliesMap.get(reply.id) - // If the reply is not trusted and there are no trusted replies for this reply, skip rendering + {mergedFeed.slice(0, showCount).map((item) => { + const isQuote = !replyIdSet.has(item.id) + // Don't filter by trust until trust data is loaded - prevents replies from + // vanishing when wotSet is still empty (all non-self appear untrusted) + if (isTrustLoaded && hideUntrustedInteractions && !isUserTrusted(item.pubkey)) { + if (isQuote) return null + const repliesForThisReply = repliesMap.get(item.id) if ( !repliesForThisReply || repliesForThisReply.events.every((evt) => !isUserTrusted(evt.pubkey)) @@ -542,11 +599,38 @@ function ReplyNoteList({ } } + if (isQuote) { + const quoteLabel = + item.kind === kinds.Highlights + ? t('highlighted this note') + : item.kind === kinds.LongFormArticle + ? t('cited in article') + : t('quoted this note') + const hideQuotedNote = eventReferencesEventId(item, event.id) + return ( + +
(replyRefs.current[item.id] = el)} + className="scroll-mt-12 border-l-2 border-muted-foreground/40 pl-3 py-1 my-1 rounded-r" + > +
+ {quoteLabel} +
+ +
+
+ ) + } + + const reply = item const parentETag = getParentETag(reply) const parentEventHexId = parentETag?.[1] const parentEventId = parentETag ? generateBech32IdFromETag(parentETag) : undefined - // Check if this reply belongs to the same thread as the root event const replyRootId = getRootEventHexId(reply) const belongsToSameThread = rootInfo && ( (rootInfo.type === 'E' && replyRootId === rootInfo.id) || @@ -572,17 +656,12 @@ function ReplyNoteList({ highlightReply(parentEventHexId) }} onClickReply={belongsToSameThread ? (replyEvent) => { - // Update URL without full navigation const replyNoteUrl = toNote(replyEvent.id) window.history.pushState(null, '', replyNoteUrl) - - // Ensure the reply is visible by expanding the list if needed - const replyIndex = replies.findIndex(r => r.id === replyEvent.id) + const replyIndex = mergedFeed.findIndex((r) => r.id === replyEvent.id) if (replyIndex >= 0 && replyIndex >= showCount) { setShowCount(replyIndex + 1) } - - // Highlight and scroll to the reply (use setTimeout to ensure DOM is updated) setTimeout(() => { highlightReply(replyEvent.id, true) }, 50) @@ -593,14 +672,14 @@ function ReplyNoteList({ ) })}
- {!loading && ( + {quoteLoading && showQuotes && } + {!loading && !quoteLoading && (
- {replies.length > 0 ? t('no more replies') : t('no replies')} + {mergedFeed.length > 0 ? t('no more replies') : t('no replies')}
)}
{loading && } - {showQuotes && }
) } diff --git a/src/constants.ts b/src/constants.ts index b29fbffe..3bbf318b 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -169,6 +169,12 @@ export const KIND_1_BLOCKED_RELAY_URLS = [ 'wss://wikifreedia.xyz' ] +/** Relays that reject #e (and similar) tag filters; skip for reply/quote/stats fetches. */ +export const E_TAG_FILTER_BLOCKED_RELAY_URLS = [ + 'wss://nostr.v0l.io', + 'wss://nostr.sovbit.host' +] + // Optimized relay list for read operations (includes aggregator) export const FAST_READ_RELAY_URLS = [ 'wss://theforest.nostr1.com', diff --git a/src/contexts/suppress-embedded-note-context.tsx b/src/contexts/suppress-embedded-note-context.tsx new file mode 100644 index 00000000..8393fe3e --- /dev/null +++ b/src/contexts/suppress-embedded-note-context.tsx @@ -0,0 +1,8 @@ +import { createContext, useContext } from 'react' + +/** When set, EmbeddedNote should not render notes whose id matches this (avoids redundancy when viewing "quotes of this note"). */ +export const SuppressEmbeddedNoteContext = createContext(undefined) + +export function useSuppressEmbeddedNoteId(): string | undefined { + return useContext(SuppressEmbeddedNoteContext) +} diff --git a/src/contexts/user-trust-context.tsx b/src/contexts/user-trust-context.tsx index 40119448..8c8d6294 100644 --- a/src/contexts/user-trust-context.tsx +++ b/src/contexts/user-trust-context.tsx @@ -1,6 +1,7 @@ import { createContext, useContext } from 'react' export type TUserTrustContext = { + isTrustLoaded: boolean hideUntrustedInteractions: boolean hideUntrustedNotifications: boolean hideUntrustedNotes: boolean diff --git a/src/hooks/index.tsx b/src/hooks/index.tsx index 333ca959..66796fb9 100644 --- a/src/hooks/index.tsx +++ b/src/hooks/index.tsx @@ -1,4 +1,5 @@ export * from './useFetchCalendarRsvps' +export * from './useQuoteEvents' export * from './useFetchEvent' export * from './useFetchFollowings' export * from './useFetchNip05' diff --git a/src/hooks/useQuoteEvents.tsx b/src/hooks/useQuoteEvents.tsx new file mode 100644 index 00000000..a42e4144 --- /dev/null +++ b/src/hooks/useQuoteEvents.tsx @@ -0,0 +1,182 @@ +import { + E_TAG_FILTER_BLOCKED_RELAY_URLS, + FAST_READ_RELAY_URLS, + SEARCHABLE_RELAY_URLS +} from '@/constants' +import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event' +import { normalizeUrl } from '@/lib/url' +import { useCurrentRelays } from '@/providers/CurrentRelaysProvider' +import { useNostr } from '@/providers/NostrProvider' +import client from '@/services/client.service' +import dayjs from 'dayjs' +import { Event, kinds } from 'nostr-tools' +import { useEffect, useRef, useState } from 'react' + +const LIMIT = 100 +const INITIAL_QUOTE_LOAD_TIMEOUT_MS = 12_000 + +/** Fetches events that quote or reference the given event (#q, #e, #a tags). */ +export function useQuoteEvents(event: Event | null, enabled: boolean) { + const { relayList: userRelayList } = useNostr() + const { relayUrls: browsingRelayUrls } = useCurrentRelays() + const [timelineKey, setTimelineKey] = useState(undefined) + const [events, setEvents] = useState([]) + const [loading, setLoading] = useState(true) + const [hasMore, setHasMore] = useState(true) + const receivedAnyQuotesRef = useRef(false) + const lastSubscribedEventIdRef = useRef(null) + + useEffect(() => { + if (!event || !enabled) { + setEvents([]) + setLoading(false) + setHasMore(false) + lastSubscribedEventIdRef.current = null + return + } + + let cancelled = false + let loadTimeoutId: ReturnType | undefined + + async function init() { + const noteRowId = event.id + const isNewTarget = lastSubscribedEventIdRef.current !== noteRowId + lastSubscribedEventIdRef.current = noteRowId + + setLoading(true) + if (isNewTarget) { + setEvents([]) + receivedAnyQuotesRef.current = false + } + setHasMore(true) + + loadTimeoutId = setTimeout(() => { + if (cancelled) return + setLoading(false) + if (!receivedAnyQuotesRef.current) { + setHasMore(false) + } + }, INITIAL_QUOTE_LOAD_TIMEOUT_MS) + + const userRelays = userRelayList?.read || [] + const fromFeed = browsingRelayUrls.map((u) => normalizeUrl(u) || u).filter(Boolean) + const seenOn = client.getSeenEventRelayUrls(event.id) + const eTagBlockedSet = new Set( + E_TAG_FILTER_BLOCKED_RELAY_URLS.map((u) => normalizeUrl(u) || u) + ) + const finalRelayUrls = Array.from( + new Set([ + ...fromFeed, + ...userRelays.map((url) => normalizeUrl(url) || url), + ...seenOn, + ...SEARCHABLE_RELAY_URLS.map((url) => normalizeUrl(url) || url), + ...FAST_READ_RELAY_URLS.map((url) => normalizeUrl(url) || url) + ]) + ) + .filter(Boolean) + .filter((u) => !eTagBlockedSet.has(normalizeUrl(u) || u)) + + const filterQeId = isReplaceableEvent(event.kind) + ? getReplaceableCoordinateFromEvent(event) + : event.id + const eventCoordinate = isReplaceableEvent(event.kind) + ? getReplaceableCoordinateFromEvent(event) + : `${event.kind}:${event.pubkey}:${event.id}` + + const { closer, timelineKey } = await client.subscribeTimeline( + [ + { + urls: finalRelayUrls, + filter: { '#q': [filterQeId], kinds: [kinds.ShortTextNote], limit: LIMIT } + }, + { + urls: finalRelayUrls, + filter: { + '#e': [filterQeId], + kinds: [kinds.Highlights, kinds.LongFormArticle], + limit: LIMIT + } + }, + { + urls: finalRelayUrls, + filter: { + '#a': [eventCoordinate], + kinds: [kinds.Highlights, kinds.LongFormArticle], + limit: LIMIT + } + } + ], + { + onEvents: (batch, eosed) => { + if (cancelled) return + if (batch.length > 0) { + receivedAnyQuotesRef.current = true + setEvents(batch) + } + if (batch.length > 0 || eosed) { + setLoading(false) + if (loadTimeoutId) { + clearTimeout(loadTimeoutId) + loadTimeoutId = undefined + } + } + if (eosed) { + setHasMore(batch.length > 0) + } + }, + onNew: (newEvt) => { + if (cancelled) return + receivedAnyQuotesRef.current = true + setLoading(false) + if (loadTimeoutId) { + clearTimeout(loadTimeoutId) + loadTimeoutId = undefined + } + setHasMore(true) + setEvents((oldEvents) => + [newEvt, ...oldEvents].sort((a, b) => b.created_at - a.created_at) + ) + } + } + ) + if (cancelled) { + closer() + return undefined + } + setTimelineKey(timelineKey) + return closer + } + + const promise = init() + return () => { + cancelled = true + if (loadTimeoutId) clearTimeout(loadTimeoutId) + promise.then((closer) => closer?.()) + } + }, [event, enabled, browsingRelayUrls, userRelayList?.read]) + + const loadMore = async () => { + if (!timelineKey || loading || !hasMore) return + setLoading(true) + try { + const newEvents = await client.loadMoreTimeline( + timelineKey, + events.length ? events[events.length - 1].created_at - 1 : dayjs().unix(), + LIMIT + ) + if (newEvents.length === 0) { + const until = events.length ? events[events.length - 1].created_at - 1 : dayjs().unix() + const hasMoreCached = client.hasMoreTimelineEvents?.(timelineKey, until) ?? false + if (!hasMoreCached) setHasMore(false) + } else { + setEvents((old) => [...old, ...newEvents]) + } + } catch { + setHasMore(false) + } finally { + setLoading(false) + } + } + + return { quoteEvents: events, quoteLoading: loading, quoteHasMore: hasMore, loadMoreQuotes: loadMore } +} diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index 514bc4d2..33e83a49 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -710,6 +710,9 @@ export default { Filter: 'Filter', 'mentioned you in a note': 'hat Sie in einer Notiz erwähnt', 'quoted your note': 'hat Ihre Notiz zitiert', + 'quoted this note': 'Hat diese Notiz zitiert', + 'highlighted this note': 'Hat diese Notiz hervorgehoben', + 'cited in article': 'In Artikel zitiert', 'voted in your poll': 'hat in Ihrer Umfrage abgestimmt', 'reacted to your note': 'hat auf Ihre Notiz reagiert', 'boosted your note': 'hat Ihre Notiz geboostet', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 24278b8b..b1e93422 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -696,6 +696,9 @@ export default { Filter: 'Filter', 'mentioned you in a note': 'mentioned you in a note', 'quoted your note': 'quoted your note', + 'quoted this note': 'Quoted this note', + 'highlighted this note': 'Highlighted this note', + 'cited in article': 'Cited in article', 'voted in your poll': 'voted in your poll', 'reacted to your note': 'reacted to your note', 'boosted your note': 'boosted your note', diff --git a/src/layouts/SecondaryPageLayout/index.tsx b/src/layouts/SecondaryPageLayout/index.tsx index e6c3e5dc..aedd386a 100644 --- a/src/layouts/SecondaryPageLayout/index.tsx +++ b/src/layouts/SecondaryPageLayout/index.tsx @@ -136,7 +136,7 @@ const SecondaryPageLayout = forwardRef( className="min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-auto" > {children} -
+
{displayScrollToTopButton && } diff --git a/src/lib/event.ts b/src/lib/event.ts index e754a2c8..3a70d16d 100644 --- a/src/lib/event.ts +++ b/src/lib/event.ts @@ -18,6 +18,8 @@ import { const EVENT_EMBEDDED_NOTES_CACHE = new LRUCache({ max: 10000 }) const EVENT_EMBEDDED_PUBKEYS_CACHE = new LRUCache({ max: 10000 }) const EVENT_IS_REPLY_NOTE_CACHE = new LRUCache({ max: 10000 }) +/** Bump when isReplyNoteEvent logic changes so cached booleans are not stale. */ +const IS_REPLY_NOTE_CACHE_KEY_SUFFIX = ':v2' export function isNsfwEvent(event: Event) { return event.tags.some( @@ -38,14 +40,26 @@ export function isReplyNoteEvent(event: Event) { if (event.kind !== kinds.ShortTextNote) return false - const cache = EVENT_IS_REPLY_NOTE_CACHE.get(event.id) + const cacheKey = event.id + IS_REPLY_NOTE_CACHE_KEY_SUFFIX + const cache = EVENT_IS_REPLY_NOTE_CACHE.get(cacheKey) if (cache !== undefined) return cache - const isReply = !!getParentETag(event) || !!getParentATag(event) - EVENT_IS_REPLY_NOTE_CACHE.set(event.id, isReply) + // Include #q (quote) — many clients omit e-tags on quote-only notes; they still belong in the thread. + const isReply = + !!getParentETag(event) || + !!getParentATag(event) || + !!getQuotedEventHexIdFromQTags(event) + EVENT_IS_REPLY_NOTE_CACHE.set(cacheKey, isReply) return isReply } +/** First hex event id from `q` / `Q` tags (NIP-18 quote). */ +export function getQuotedEventHexIdFromQTags(event: Event): string | undefined { + const q = event.tags.find((t) => t[0] === 'q' || t[0] === 'Q')?.[1] + if (q && /^[0-9a-f]{64}$/i.test(q)) return q.toLowerCase() + return undefined +} + export function isReplaceableEvent(kind: number) { return ( kinds.isReplaceableKind(kind) || @@ -185,6 +199,21 @@ export function getRootEventHexId(event?: Event) { return tag?.[1] } +/** True if event references targetHexId as root, parent, or quoted (#q) — used to hide redundant preview when showing quotes of current note. */ +export function eventReferencesEventId(event: Event | undefined, targetHexId: string): boolean { + if (!event || !targetHexId) return false + const target = targetHexId.toLowerCase() + const rootId = getRootETag(event)?.[1]?.toLowerCase() + if (rootId === target) return true + const parentId = getParentETag(event)?.[1]?.toLowerCase() + if (parentId === target) return true + const qTag = event.tags.find((t) => t[0] === 'q' || t[0] === 'Q')?.[1]?.toLowerCase() + if (qTag === target) return true + const eTags = event.tags.filter((t) => t[0] === 'e' || t[0] === 'E') + if (eTags.some((t) => t[1]?.toLowerCase() === target)) return true + return false +} + export function getRootBech32Id(event?: Event) { const eTag = getRootETag(event) if (!eTag) { diff --git a/src/providers/ReplyProvider.tsx b/src/providers/ReplyProvider.tsx index dd550467..12603cde 100644 --- a/src/providers/ReplyProvider.tsx +++ b/src/providers/ReplyProvider.tsx @@ -1,5 +1,11 @@ import { getArticleUrlFromCommentITags } from '@/lib/rss-article' -import { getParentATag, getParentETag, getRootATag, getRootETag } from '@/lib/event' +import { + getParentATag, + getParentETag, + getQuotedEventHexIdFromQTags, + getRootATag, + getRootETag +} from '@/lib/event' import { Event } from 'nostr-tools' import { createContext, useCallback, useContext, useState } from 'react' @@ -33,7 +39,7 @@ export function ReplyProvider({ children }: { children: React.ReactNode }) { let rootId: string | undefined const rootETag = getRootETag(reply) if (rootETag) { - rootId = rootETag[1] + rootId = rootETag[1]?.toLowerCase?.() ?? rootETag[1] } else { const rootATag = getRootATag(reply) if (rootATag) { @@ -52,7 +58,7 @@ export function ReplyProvider({ children }: { children: React.ReactNode }) { let parentId: string | undefined const parentETag = getParentETag(reply) if (parentETag) { - parentId = parentETag[1] + parentId = parentETag[1]?.toLowerCase?.() ?? parentETag[1] } else { const parentATag = getParentATag(reply) if (parentATag) { @@ -62,25 +68,35 @@ export function ReplyProvider({ children }: { children: React.ReactNode }) { if (parentId && parentId !== rootId) { newReplyEventMap.set(parentId, [...(newReplyEventMap.get(parentId) || []), reply]) } + + // Quote-only notes (#q, no e-tags): still index under the quoted event id. + if (!rootId && !parentId) { + const qid = getQuotedEventHexIdFromQTags(reply) + if (qid) { + newReplyEventMap.set(qid, [...(newReplyEventMap.get(qid) || []), reply]) + } + } }) if (newReplyEventMap.size === 0) return setRepliesMap((prev) => { + const next = new Map(prev) for (const [id, newReplyEvents] of newReplyEventMap.entries()) { - const replies = prev.get(id) || { events: [], eventIdSet: new Set() } + const existing = next.get(id) + const events = existing ? [...existing.events] : [] + const eventIdSet = existing ? new Set(existing.eventIdSet) : new Set() newReplyEvents.forEach((reply) => { - const existingIdx = replies.events.findIndex((e) => e.id === reply.id) + const existingIdx = events.findIndex((e) => e.id === reply.id) if (existingIdx >= 0) { - replies.events[existingIdx] = reply - replies.eventIdSet.add(reply.id) + events[existingIdx] = reply } else { - replies.events.push(reply) - replies.eventIdSet.add(reply.id) + events.push(reply) } + eventIdSet.add(reply.id) }) - prev.set(id, replies) + next.set(id, { events, eventIdSet }) } - return new Map(prev) + return next }) }, []) diff --git a/src/providers/UserTrustProvider.tsx b/src/providers/UserTrustProvider.tsx index 317face2..b236bf61 100644 --- a/src/providers/UserTrustProvider.tsx +++ b/src/providers/UserTrustProvider.tsx @@ -10,6 +10,7 @@ const wotSet = new Set() export function UserTrustProvider({ children }: { children: ReactNode }) { const { pubkey: currentPubkey } = useNostr() + const [isTrustLoaded, setIsTrustLoaded] = useState(false) const [hideUntrustedInteractions, setHideUntrustedInteractions] = useState(() => storage.getHideUntrustedInteractions() ) @@ -21,7 +22,14 @@ export function UserTrustProvider({ children }: { children: ReactNode }) { ) useEffect(() => { - if (!currentPubkey) return + if (!currentPubkey) { + setIsTrustLoaded(false) + return + } + + // Clear wotSet when account changes to avoid cross-account contamination + wotSet.clear() + setIsTrustLoaded(false) const initWoT = async () => { const followListEvent = await replaceableEventService.fetchReplaceableEvent(currentPubkey, kinds.Contacts) @@ -72,6 +80,7 @@ export function UserTrustProvider({ children }: { children: ReactNode }) { return (