17 changed files with 415 additions and 307 deletions
@ -1,257 +0,0 @@
@@ -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> |
||||
) |
||||
} |
||||
@ -0,0 +1,8 @@
@@ -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) |
||||
} |
||||
@ -0,0 +1,182 @@
@@ -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 } |
||||
} |
||||
Loading…
Reference in new issue