Browse Source

bug-fixes

imwald
Silberengel 1 month ago
parent
commit
e7cc41ceaf
  1. 6
      src/components/Embedded/EmbeddedNote.tsx
  2. 6
      src/components/NoteCard/MainNoteCard.tsx
  3. 17
      src/components/NoteCard/index.tsx
  4. 11
      src/components/NoteDrawer/index.tsx
  5. 257
      src/components/QuoteList/index.tsx
  6. 135
      src/components/ReplyNoteList/index.tsx
  7. 6
      src/constants.ts
  8. 8
      src/contexts/suppress-embedded-note-context.tsx
  9. 1
      src/contexts/user-trust-context.tsx
  10. 1
      src/hooks/index.tsx
  11. 182
      src/hooks/useQuoteEvents.tsx
  12. 3
      src/i18n/locales/de.ts
  13. 3
      src/i18n/locales/en.ts
  14. 2
      src/layouts/SecondaryPageLayout/index.tsx
  15. 35
      src/lib/event.ts
  16. 38
      src/providers/ReplyProvider.tsx
  17. 11
      src/providers/UserTrustProvider.tsx

6
src/components/Embedded/EmbeddedNote.tsx

@ -24,6 +24,7 @@ import {
type EmbeddedNoteIdValidation, type EmbeddedNoteIdValidation,
validateEmbeddedNotePointer validateEmbeddedNotePointer
} from './embeddedNotePointer' } 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`. */ /** Embedded `noteId` is often raw hex from parsers; must accept A–F and normalize for REQ `ids`. */
function hexEventIdFromNoteId(noteId: string): string | null { function hexEventIdFromNoteId(noteId: string): string | null {
@ -60,6 +61,11 @@ export function EmbeddedNote({
className?: string className?: string
containingEvent?: Event 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]) const validation = useMemo(() => validateEmbeddedNotePointer(noteId), [noteId])
if (!validation.valid) { if (!validation.valid) {
return ( return (

6
src/components/NoteCard/MainNoteCard.tsx

@ -16,7 +16,8 @@ export default function MainNoteCard({
reposter, reposter,
embedded, embedded,
originalNoteId, originalNoteId,
pinned = false pinned = false,
hideParentNotePreview = false
}: { }: {
event: Event event: Event
className?: string className?: string
@ -25,6 +26,8 @@ export default function MainNoteCard({
originalNoteId?: string originalNoteId?: string
/** Profile (or other) pinned highlight */ /** Profile (or other) pinned highlight */
pinned?: boolean pinned?: boolean
/** Hide the parent note preview (e.g. when showing quotes of current note). */
hideParentNotePreview?: boolean
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { navigateToNote } = useSmartNoteNavigationOptional() const { navigateToNote } = useSmartNoteNavigationOptional()
@ -71,6 +74,7 @@ export default function MainNoteCard({
event={event} event={event}
originalNoteId={originalNoteId} originalNoteId={originalNoteId}
disableClick={true} disableClick={true}
hideParentNotePreview={hideParentNotePreview}
/> />
</Collapsible> </Collapsible>
{!embedded && ( {!embedded && (

17
src/components/NoteCard/index.tsx

@ -11,12 +11,15 @@ const NoteCard = memo(function NoteCard({
event, event,
className, className,
filterMutedNotes = true, filterMutedNotes = true,
pinned = false pinned = false,
hideParentNotePreview = false
}: { }: {
event: Event event: Event
className?: string className?: string
filterMutedNotes?: boolean filterMutedNotes?: boolean
pinned?: 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 { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy() const { hideContentMentioningMutedUsers } = useContentPolicy()
@ -41,7 +44,14 @@ const NoteCard = memo(function NoteCard({
/> />
) )
} }
return <MainNoteCard event={event} className={className} pinned={pinned} /> return (
<MainNoteCard
event={event}
className={className}
pinned={pinned}
hideParentNotePreview={hideParentNotePreview}
/>
)
}, (prevProps, nextProps) => { }, (prevProps, nextProps) => {
// Custom comparison function for memo // Custom comparison function for memo
return ( return (
@ -49,7 +59,8 @@ const NoteCard = memo(function NoteCard({
prevProps.event.created_at === nextProps.event.created_at && prevProps.event.created_at === nextProps.event.created_at &&
prevProps.className === nextProps.className && prevProps.className === nextProps.className &&
prevProps.filterMutedNotes === nextProps.filterMutedNotes && prevProps.filterMutedNotes === nextProps.filterMutedNotes &&
prevProps.pinned === nextProps.pinned prevProps.pinned === nextProps.pinned &&
prevProps.hideParentNotePreview === nextProps.hideParentNotePreview
) )
}) })

11
src/components/NoteDrawer/index.tsx

@ -1,6 +1,7 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef } from 'react'
import { Sheet, SheetContent } from '@/components/ui/sheet' import { Sheet, SheetContent } from '@/components/ui/sheet'
import NotePage from '@/pages/secondary/NotePage' import NotePage from '@/pages/secondary/NotePage'
import { useSecondaryPage } from '@/PageManager'
import type { Event } from 'nostr-tools' import type { Event } from 'nostr-tools'
interface NoteDrawerProps { interface NoteDrawerProps {
@ -11,6 +12,7 @@ interface NoteDrawerProps {
} }
export default function NoteDrawer({ open, onOpenChange, noteId, initialEvent }: NoteDrawerProps) { export default function NoteDrawer({ open, onOpenChange, noteId, initialEvent }: NoteDrawerProps) {
const { currentIndex } = useSecondaryPage()
const [displayNoteId, setDisplayNoteId] = useState<string | null>(noteId) const [displayNoteId, setDisplayNoteId] = useState<string | null>(noteId)
const timeoutRef = useRef<NodeJS.Timeout | null>(null) const timeoutRef = useRef<NodeJS.Timeout | null>(null)
@ -44,8 +46,13 @@ export default function NoteDrawer({ open, onOpenChange, noteId, initialEvent }:
return ( return (
<Sheet open={open} onOpenChange={onOpenChange}> <Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="w-full sm:max-w-[1042px] overflow-y-auto p-0"> <SheetContent side="right" className="w-full sm:max-w-[1042px] overflow-y-auto p-0">
<div className="h-full"> <div className="min-h-full">
<NotePage id={displayNoteId} index={0} hideTitlebar={false} initialEvent={initialEvent ?? undefined} /> <NotePage
id={displayNoteId}
index={currentIndex}
hideTitlebar={false}
initialEvent={initialEvent ?? undefined}
/>
</div> </div>
</SheetContent> </SheetContent>
</Sheet> </Sheet>

257
src/components/QuoteList/index.tsx

@ -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<string | undefined>(undefined)
const [events, setEvents] = useState<Event[]>([])
const [showCount, setShowCount] = useState(SHOW_COUNT)
const [hasMore, setHasMore] = useState<boolean>(true)
const [loading, setLoading] = useState(true)
const bottomRef = useRef<HTMLDivElement | null>(null)
const receivedAnyQuotesRef = useRef(false)
useEffect(() => {
let cancelled = false
let loadTimeoutId: ReturnType<typeof setTimeout> | 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 (
<div className={cn(className, embedded && 'mt-6 border-t border-border pt-4')}>
{embedded && (
<h3 className="text-sm font-semibold text-muted-foreground mb-3 px-4">{t('Quotes')}</h3>
)}
<div className={embedded ? undefined : 'min-h-[80vh]'}>
<div>
{events.slice(0, showCount).map((event) => {
if (hideUntrustedInteractions && !isUserTrusted(event.pubkey)) {
return null
}
return <NoteCard key={event.id} className="w-full" event={event} />
})}
</div>
{hasMore || loading ? (
<div ref={bottomRef}>
<NoteCardLoadingSkeleton />
</div>
) : (
<div className="text-center text-sm text-muted-foreground mt-2">{t('no more notes')}</div>
)}
</div>
{!embedded && <div className="h-40" />}
{embedded && <div className="pb-8" />}
</div>
)
}

135
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 { getArticleUrlFromCommentITags } from '@/lib/rss-article'
import { import {
eventReferencesEventId,
getParentETag, getParentETag,
getReplaceableCoordinateFromEvent, getReplaceableCoordinateFromEvent,
getRootATag, getRootATag,
@ -33,8 +34,10 @@ import { Filter, Event as NEvent, kinds } from 'nostr-tools'
import { useNoteStatsById } from '@/hooks/useNoteStatsById' import { useNoteStatsById } from '@/hooks/useNoteStatsById'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useQuoteEvents } from '@/hooks'
import { SuppressEmbeddedNoteContext } from '@/contexts/suppress-embedded-note-context'
import { LoadingBar } from '../LoadingBar' import { LoadingBar } from '../LoadingBar'
import QuoteList from '../QuoteList' import NoteCard, { NoteCardLoadingSkeleton } from '../NoteCard'
import ReplyNote, { ReplyNoteSkeleton } from '../ReplyNote' import ReplyNote, { ReplyNoteSkeleton } from '../ReplyNote'
import ZapReplyFeedRow from './ZapReplyFeedRow' import ZapReplyFeedRow from './ZapReplyFeedRow'
@ -61,15 +64,19 @@ function ReplyNoteList({
const { t } = useTranslation() const { t } = useTranslation()
const { navigateToNote } = useSmartNoteNavigation() const { navigateToNote } = useSmartNoteNavigation()
const { currentIndex } = useSecondaryPage() const { currentIndex } = useSecondaryPage()
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust() const { hideUntrustedInteractions, isUserTrusted, isTrustLoaded } = useUserTrust()
const noteStats = useNoteStatsById(event.id) const noteStats = useNoteStatsById(event.id)
const { mutePubkeySet } = useMuteList() const { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy() const { hideContentMentioningMutedUsers } = useContentPolicy()
const { relayList: userRelayList, pubkey: userPubkey } = useNostr() const { pubkey: userPubkey } = useNostr()
const { blockedRelays } = useFavoriteRelays() const { blockedRelays } = useFavoriteRelays()
const { relayUrls: browsingRelayUrls } = useCurrentRelays() const { relayUrls: browsingRelayUrls } = useCurrentRelays()
const [rootInfo, setRootInfo] = useState<TRootInfo | undefined>(undefined) const [rootInfo, setRootInfo] = useState<TRootInfo | undefined>(undefined)
const { repliesMap, addReplies } = useReply() const { repliesMap, addReplies } = useReply()
const { quoteEvents, quoteLoading } = useQuoteEvents(
event,
showQuotes ?? false
)
// Helper function to get vote score for a reply // Helper function to get vote score for a reply
const getReplyVoteScore = (reply: NEvent) => { const getReplyVoteScore = (reply: NEvent) => {
@ -115,9 +122,9 @@ function ReplyNoteList({
const replyEvents: NEvent[] = [] const replyEvents: NEvent[] = []
const currentEventKey = isReplaceableEvent(event.kind) const currentEventKey = isReplaceableEvent(event.kind)
? getReplaceableCoordinateFromEvent(event) ? 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 // 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] let parentEventKeys = [currentEventKey]
if (isReplaceableEvent(event.kind) && currentEventKey !== eventIdKey) { if (isReplaceableEvent(event.kind) && currentEventKey !== eventIdKey) {
parentEventKeys.push(eventIdKey) parentEventKeys.push(eventIdKey)
@ -201,12 +208,30 @@ function ReplyNoteList({
} }
}, [event.id, repliesMap, mutePubkeySet, hideContentMentioningMutedUsers, sort]) }, [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(() => { const zapsForFeed = useMemo(() => {
if (shouldHideInteractions(event)) return [] if (shouldHideInteractions(event)) return []
const raw = noteStats?.zaps ?? [] 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) return [...filtered].sort((a, b) => b.amount - a.amount)
}, [event, noteStats, hideUntrustedInteractions, isUserTrusted]) }, [event, noteStats, isTrustLoaded, hideUntrustedInteractions, isUserTrusted])
const [timelineKey] = useState<string | undefined>(undefined) const [timelineKey] = useState<string | undefined>(undefined)
const [until, setUntil] = useState<number | undefined>(undefined) const [until, setUntil] = useState<number | undefined>(undefined)
@ -312,9 +337,13 @@ function ReplyNoteList({
} }
}, [rootInfo, onNewReply]) }, [rootInfo, onNewReply])
const replyFetchGenRef = useRef(0)
useEffect(() => { useEffect(() => {
if (!rootInfo || currentIndex !== index) return if (!rootInfo || currentIndex !== index) return
const fetchGeneration = ++replyFetchGenRef.current
const init = async () => { const init = async () => {
// Check cache first - get cached data even if stale (for instant display) // Check cache first - get cached data even if stale (for instant display)
const cachedData = discussionFeedCache.getCachedReplies(rootInfo) const cachedData = discussionFeedCache.getCachedReplies(rootInfo)
@ -345,7 +374,7 @@ function ReplyNoteList({
async function fetchFromRelays() { async function fetchFromRelays() {
if (!rootInfo) return // Type guard if (!rootInfo) return // Type guard
try { try {
// READ from: FAST_READ_RELAY_URLS + user's inboxes + local relays + OP author's outboxes // 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 const opAuthorPubkey = rootInfo.type === 'E' || rootInfo.type === 'A' ? rootInfo.pubkey : undefined
@ -354,12 +383,18 @@ function ReplyNoteList({
const threadRelayHints = [ const threadRelayHints = [
...new Set([...relayHintsFromEventTags(event), ...seenOn, ...fromBrowsingFeed]) ...new Set([...relayHintsFromEventTags(event), ...seenOn, ...fromBrowsingFeed])
] ]
const finalRelayUrls = await buildReplyReadRelayList( let finalRelayUrls = await buildReplyReadRelayList(
opAuthorPubkey, opAuthorPubkey,
userPubkey || undefined, userPubkey || undefined,
blockedRelays || [], blockedRelays || [],
threadRelayHints 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[] = [] const filters: Filter[] = []
if (rootInfo.type === 'E') { if (rootInfo.type === 'E') {
@ -375,6 +410,12 @@ function ReplyNoteList({
kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT], kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT],
limit: LIMIT 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 // For public messages (kind 24), also look for replies using 'q' tags
if (event.kind === ExtendedKind.PUBLIC_MESSAGE) { if (event.kind === ExtendedKind.PUBLIC_MESSAGE) {
filters.push({ filters.push({
@ -416,6 +457,8 @@ function ReplyNoteList({
// Use fetchEvents instead of subscribeTimeline for one-time fetching // Use fetchEvents instead of subscribeTimeline for one-time fetching
const allReplies = await queryService.fetchEvents(finalRelayUrls, filters) const allReplies = await queryService.fetchEvents(finalRelayUrls, filters)
if (fetchGeneration !== replyFetchGenRef.current) return
// Filter and add replies // Filter and add replies
const regularReplies = allReplies.filter((evt) => isReplyNoteEvent(evt)) const regularReplies = allReplies.filter((evt) => isReplyNoteEvent(evt))
@ -443,6 +486,7 @@ function ReplyNoteList({
} }
} catch (error) { } catch (error) {
logger.error('[ReplyNoteList] Error fetching replies:', error) logger.error('[ReplyNoteList] Error fetching replies:', error)
if (fetchGeneration !== replyFetchGenRef.current) return
if (!hasCache) { if (!hasCache) {
// Only set loading to false if we don't have cache to fall back on // Only set loading to false if we don't have cache to fall back on
setLoading(false) setLoading(false)
@ -452,7 +496,17 @@ function ReplyNoteList({
} }
init() init()
}, [rootInfo, currentIndex, index, userRelayList, event, blockedRelays, browsingRelayUrls, addReplies]) }, [
rootInfo,
currentIndex,
index,
userPubkey,
event.id,
event.kind,
blockedRelays,
browsingRelayUrls,
addReplies
])
useEffect(() => { useEffect(() => {
if (replies.length === 0 && !loading && timelineKey) { if (replies.length === 0 && !loading && timelineKey) {
@ -468,7 +522,7 @@ function ReplyNoteList({
} }
const observerInstance = new IntersectionObserver((entries) => { const observerInstance = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && showCount < replies.length) { if (entries[0].isIntersecting && showCount < mergedFeed.length) {
setShowCount((prev) => prev + SHOW_COUNT) setShowCount((prev) => prev + SHOW_COUNT)
} }
}, options) }, options)
@ -484,7 +538,7 @@ function ReplyNoteList({
observerInstance.unobserve(currentBottomRef) observerInstance.unobserve(currentBottomRef)
} }
} }
}, [replies, showCount]) }, [mergedFeed.length, showCount])
const loadMore = useCallback(async () => { const loadMore = useCallback(async () => {
if (loading || !until || !timelineKey) return if (loading || !until || !timelineKey) return
@ -516,7 +570,7 @@ function ReplyNoteList({
}, []) }, [])
return ( return (
<div className="min-h-[80vh]"> <div className="min-h-[80vh] pb-12">
{loading && <LoadingBar />} {loading && <LoadingBar />}
{zapsForFeed.map((zap) => ( {zapsForFeed.map((zap) => (
<ZapReplyFeedRow key={zap.pr} zap={zap} /> <ZapReplyFeedRow key={zap.pr} zap={zap} />
@ -530,10 +584,13 @@ function ReplyNoteList({
</div> </div>
)} )}
<div> <div>
{replies.slice(0, showCount).map((reply) => { {mergedFeed.slice(0, showCount).map((item) => {
if (hideUntrustedInteractions && !isUserTrusted(reply.pubkey)) { const isQuote = !replyIdSet.has(item.id)
const repliesForThisReply = repliesMap.get(reply.id) // Don't filter by trust until trust data is loaded - prevents replies from
// If the reply is not trusted and there are no trusted replies for this reply, skip rendering // 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 ( if (
!repliesForThisReply || !repliesForThisReply ||
repliesForThisReply.events.every((evt) => !isUserTrusted(evt.pubkey)) 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 (
<SuppressEmbeddedNoteContext.Provider key={item.id} value={event.id}>
<div
ref={(el) => (replyRefs.current[item.id] = el)}
className="scroll-mt-12 border-l-2 border-muted-foreground/40 pl-3 py-1 my-1 rounded-r"
>
<div className="text-xs font-medium text-muted-foreground mb-1">
{quoteLabel}
</div>
<NoteCard
event={item}
className="w-full"
hideParentNotePreview={hideQuotedNote}
/>
</div>
</SuppressEmbeddedNoteContext.Provider>
)
}
const reply = item
const parentETag = getParentETag(reply) const parentETag = getParentETag(reply)
const parentEventHexId = parentETag?.[1] const parentEventHexId = parentETag?.[1]
const parentEventId = parentETag ? generateBech32IdFromETag(parentETag) : undefined const parentEventId = parentETag ? generateBech32IdFromETag(parentETag) : undefined
// Check if this reply belongs to the same thread as the root event
const replyRootId = getRootEventHexId(reply) const replyRootId = getRootEventHexId(reply)
const belongsToSameThread = rootInfo && ( const belongsToSameThread = rootInfo && (
(rootInfo.type === 'E' && replyRootId === rootInfo.id) || (rootInfo.type === 'E' && replyRootId === rootInfo.id) ||
@ -572,17 +656,12 @@ function ReplyNoteList({
highlightReply(parentEventHexId) highlightReply(parentEventHexId)
}} }}
onClickReply={belongsToSameThread ? (replyEvent) => { onClickReply={belongsToSameThread ? (replyEvent) => {
// Update URL without full navigation
const replyNoteUrl = toNote(replyEvent.id) const replyNoteUrl = toNote(replyEvent.id)
window.history.pushState(null, '', replyNoteUrl) window.history.pushState(null, '', replyNoteUrl)
const replyIndex = mergedFeed.findIndex((r) => r.id === replyEvent.id)
// Ensure the reply is visible by expanding the list if needed
const replyIndex = replies.findIndex(r => r.id === replyEvent.id)
if (replyIndex >= 0 && replyIndex >= showCount) { if (replyIndex >= 0 && replyIndex >= showCount) {
setShowCount(replyIndex + 1) setShowCount(replyIndex + 1)
} }
// Highlight and scroll to the reply (use setTimeout to ensure DOM is updated)
setTimeout(() => { setTimeout(() => {
highlightReply(replyEvent.id, true) highlightReply(replyEvent.id, true)
}, 50) }, 50)
@ -593,14 +672,14 @@ function ReplyNoteList({
) )
})} })}
</div> </div>
{!loading && ( {quoteLoading && showQuotes && <NoteCardLoadingSkeleton />}
{!loading && !quoteLoading && (
<div className="text-sm mt-2 mb-3 text-center text-muted-foreground"> <div className="text-sm mt-2 mb-3 text-center text-muted-foreground">
{replies.length > 0 ? t('no more replies') : t('no replies')} {mergedFeed.length > 0 ? t('no more replies') : t('no replies')}
</div> </div>
)} )}
<div ref={bottomRef} /> <div ref={bottomRef} />
{loading && <ReplyNoteSkeleton />} {loading && <ReplyNoteSkeleton />}
{showQuotes && <QuoteList event={event} embedded />}
</div> </div>
) )
} }

6
src/constants.ts

@ -169,6 +169,12 @@ export const KIND_1_BLOCKED_RELAY_URLS = [
'wss://wikifreedia.xyz' '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) // Optimized relay list for read operations (includes aggregator)
export const FAST_READ_RELAY_URLS = [ export const FAST_READ_RELAY_URLS = [
'wss://theforest.nostr1.com', 'wss://theforest.nostr1.com',

8
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<string | undefined>(undefined)
export function useSuppressEmbeddedNoteId(): string | undefined {
return useContext(SuppressEmbeddedNoteContext)
}

1
src/contexts/user-trust-context.tsx

@ -1,6 +1,7 @@
import { createContext, useContext } from 'react' import { createContext, useContext } from 'react'
export type TUserTrustContext = { export type TUserTrustContext = {
isTrustLoaded: boolean
hideUntrustedInteractions: boolean hideUntrustedInteractions: boolean
hideUntrustedNotifications: boolean hideUntrustedNotifications: boolean
hideUntrustedNotes: boolean hideUntrustedNotes: boolean

1
src/hooks/index.tsx

@ -1,4 +1,5 @@
export * from './useFetchCalendarRsvps' export * from './useFetchCalendarRsvps'
export * from './useQuoteEvents'
export * from './useFetchEvent' export * from './useFetchEvent'
export * from './useFetchFollowings' export * from './useFetchFollowings'
export * from './useFetchNip05' export * from './useFetchNip05'

182
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<string | undefined>(undefined)
const [events, setEvents] = useState<Event[]>([])
const [loading, setLoading] = useState(true)
const [hasMore, setHasMore] = useState(true)
const receivedAnyQuotesRef = useRef(false)
const lastSubscribedEventIdRef = useRef<string | null>(null)
useEffect(() => {
if (!event || !enabled) {
setEvents([])
setLoading(false)
setHasMore(false)
lastSubscribedEventIdRef.current = null
return
}
let cancelled = false
let loadTimeoutId: ReturnType<typeof setTimeout> | 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 }
}

3
src/i18n/locales/de.ts

@ -710,6 +710,9 @@ export default {
Filter: 'Filter', Filter: 'Filter',
'mentioned you in a note': 'hat Sie in einer Notiz erwähnt', 'mentioned you in a note': 'hat Sie in einer Notiz erwähnt',
'quoted your note': 'hat Ihre Notiz zitiert', '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', 'voted in your poll': 'hat in Ihrer Umfrage abgestimmt',
'reacted to your note': 'hat auf Ihre Notiz reagiert', 'reacted to your note': 'hat auf Ihre Notiz reagiert',
'boosted your note': 'hat Ihre Notiz geboostet', 'boosted your note': 'hat Ihre Notiz geboostet',

3
src/i18n/locales/en.ts

@ -696,6 +696,9 @@ export default {
Filter: 'Filter', Filter: 'Filter',
'mentioned you in a note': 'mentioned you in a note', 'mentioned you in a note': 'mentioned you in a note',
'quoted your note': 'quoted your 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', 'voted in your poll': 'voted in your poll',
'reacted to your note': 'reacted to your note', 'reacted to your note': 'reacted to your note',
'boosted your note': 'boosted your note', 'boosted your note': 'boosted your note',

2
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" className="min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-auto"
> >
{children} {children}
<div className="h-4" /> <div className="h-12" />
</div> </div>
</div> </div>
{displayScrollToTopButton && <ScrollToTopButton scrollAreaRef={scrollAreaRef} />} {displayScrollToTopButton && <ScrollToTopButton scrollAreaRef={scrollAreaRef} />}

35
src/lib/event.ts

@ -18,6 +18,8 @@ import {
const EVENT_EMBEDDED_NOTES_CACHE = new LRUCache<string, string[]>({ max: 10000 }) const EVENT_EMBEDDED_NOTES_CACHE = new LRUCache<string, string[]>({ max: 10000 })
const EVENT_EMBEDDED_PUBKEYS_CACHE = new LRUCache<string, string[]>({ max: 10000 }) const EVENT_EMBEDDED_PUBKEYS_CACHE = new LRUCache<string, string[]>({ max: 10000 })
const EVENT_IS_REPLY_NOTE_CACHE = new LRUCache<string, boolean>({ max: 10000 }) const EVENT_IS_REPLY_NOTE_CACHE = new LRUCache<string, boolean>({ 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) { export function isNsfwEvent(event: Event) {
return event.tags.some( return event.tags.some(
@ -38,14 +40,26 @@ export function isReplyNoteEvent(event: Event) {
if (event.kind !== kinds.ShortTextNote) return false 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 if (cache !== undefined) return cache
const isReply = !!getParentETag(event) || !!getParentATag(event) // Include #q (quote) — many clients omit e-tags on quote-only notes; they still belong in the thread.
EVENT_IS_REPLY_NOTE_CACHE.set(event.id, isReply) const isReply =
!!getParentETag(event) ||
!!getParentATag(event) ||
!!getQuotedEventHexIdFromQTags(event)
EVENT_IS_REPLY_NOTE_CACHE.set(cacheKey, isReply)
return 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) { export function isReplaceableEvent(kind: number) {
return ( return (
kinds.isReplaceableKind(kind) || kinds.isReplaceableKind(kind) ||
@ -185,6 +199,21 @@ export function getRootEventHexId(event?: Event) {
return tag?.[1] 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) { export function getRootBech32Id(event?: Event) {
const eTag = getRootETag(event) const eTag = getRootETag(event)
if (!eTag) { if (!eTag) {

38
src/providers/ReplyProvider.tsx

@ -1,5 +1,11 @@
import { getArticleUrlFromCommentITags } from '@/lib/rss-article' 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 { Event } from 'nostr-tools'
import { createContext, useCallback, useContext, useState } from 'react' import { createContext, useCallback, useContext, useState } from 'react'
@ -33,7 +39,7 @@ export function ReplyProvider({ children }: { children: React.ReactNode }) {
let rootId: string | undefined let rootId: string | undefined
const rootETag = getRootETag(reply) const rootETag = getRootETag(reply)
if (rootETag) { if (rootETag) {
rootId = rootETag[1] rootId = rootETag[1]?.toLowerCase?.() ?? rootETag[1]
} else { } else {
const rootATag = getRootATag(reply) const rootATag = getRootATag(reply)
if (rootATag) { if (rootATag) {
@ -52,7 +58,7 @@ export function ReplyProvider({ children }: { children: React.ReactNode }) {
let parentId: string | undefined let parentId: string | undefined
const parentETag = getParentETag(reply) const parentETag = getParentETag(reply)
if (parentETag) { if (parentETag) {
parentId = parentETag[1] parentId = parentETag[1]?.toLowerCase?.() ?? parentETag[1]
} else { } else {
const parentATag = getParentATag(reply) const parentATag = getParentATag(reply)
if (parentATag) { if (parentATag) {
@ -62,25 +68,35 @@ export function ReplyProvider({ children }: { children: React.ReactNode }) {
if (parentId && parentId !== rootId) { if (parentId && parentId !== rootId) {
newReplyEventMap.set(parentId, [...(newReplyEventMap.get(parentId) || []), reply]) 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 if (newReplyEventMap.size === 0) return
setRepliesMap((prev) => { setRepliesMap((prev) => {
const next = new Map(prev)
for (const [id, newReplyEvents] of newReplyEventMap.entries()) { 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<string>()
newReplyEvents.forEach((reply) => { 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) { if (existingIdx >= 0) {
replies.events[existingIdx] = reply events[existingIdx] = reply
replies.eventIdSet.add(reply.id)
} else { } else {
replies.events.push(reply) events.push(reply)
replies.eventIdSet.add(reply.id)
} }
eventIdSet.add(reply.id)
}) })
prev.set(id, replies) next.set(id, { events, eventIdSet })
} }
return new Map(prev) return next
}) })
}, []) }, [])

11
src/providers/UserTrustProvider.tsx

@ -10,6 +10,7 @@ const wotSet = new Set<string>()
export function UserTrustProvider({ children }: { children: ReactNode }) { export function UserTrustProvider({ children }: { children: ReactNode }) {
const { pubkey: currentPubkey } = useNostr() const { pubkey: currentPubkey } = useNostr()
const [isTrustLoaded, setIsTrustLoaded] = useState(false)
const [hideUntrustedInteractions, setHideUntrustedInteractions] = useState(() => const [hideUntrustedInteractions, setHideUntrustedInteractions] = useState(() =>
storage.getHideUntrustedInteractions() storage.getHideUntrustedInteractions()
) )
@ -21,7 +22,14 @@ export function UserTrustProvider({ children }: { children: ReactNode }) {
) )
useEffect(() => { 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 initWoT = async () => {
const followListEvent = await replaceableEventService.fetchReplaceableEvent(currentPubkey, kinds.Contacts) const followListEvent = await replaceableEventService.fetchReplaceableEvent(currentPubkey, kinds.Contacts)
@ -72,6 +80,7 @@ export function UserTrustProvider({ children }: { children: ReactNode }) {
return ( return (
<UserTrustContext.Provider <UserTrustContext.Provider
value={{ value={{
isTrustLoaded,
hideUntrustedInteractions, hideUntrustedInteractions,
hideUntrustedNotifications, hideUntrustedNotifications,
hideUntrustedNotes, hideUntrustedNotes,

Loading…
Cancel
Save