diff --git a/src/components/Embedded/EmbeddedNote.tsx b/src/components/Embedded/EmbeddedNote.tsx index 2e23f192..1a443114 100644 --- a/src/components/Embedded/EmbeddedNote.tsx +++ b/src/components/Embedded/EmbeddedNote.tsx @@ -1,26 +1,33 @@ import { Skeleton } from '@/components/ui/skeleton' import ExternalLink from '@/components/ExternalLink' -import { FAST_READ_RELAY_URLS, SEARCHABLE_RELAY_URLS, ExtendedKind } from '@/constants' +import { + FAST_READ_RELAY_URLS, + FAST_WRITE_RELAY_URLS, + PROFILE_RELAY_URLS, + SEARCHABLE_RELAY_URLS, + ExtendedKind +} from '@/constants' import { getFavoritesFeedRelayUrls } from '@/lib/favorites-feed-relays' import { LIVE_ACTIVITY_KINDS } from '@/lib/live-activities' import { isRenderableNoteKind } from '@/lib/note-renderable-kinds' -import { useFetchEvent } from '@/hooks' +import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter' import { normalizeUrl } from '@/lib/url' import { cn } from '@/lib/utils' -import client from '@/services/client.service' +import client, { eventService } from '@/services/client.service' import indexedDb from '@/services/indexed-db.service' import nip66Service from '@/services/nip66.service' +import { navigationEventStore } from '@/services/navigation-event-store' import { useFavoriteRelays } from '@/providers/favorite-relays-context' +import { useDeletedEvent } from '@/providers/DeletedEventProvider' +import { useReply } from '@/providers/ReplyProvider' import { useTranslation } from 'react-i18next' -import { useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { relayHintWssUrlsFromEvent } from '@/lib/event' import { Event, nip19 } from 'nostr-tools' import ClientSelect from '../ClientSelect' import MainNoteCard from '../NoteCard/MainNoteCard' import UnknownNote from '../Note/UnknownNote' -import { Button } from '../ui/button' import { EmbeddedCalendarEvent } from './EmbeddedCalendarEvent' -import { Search } from 'lucide-react' import logger from '@/lib/logger' import { extractBookMetadata } from '@/lib/bookstr-parser' import { contentParserService } from '@/services/content-parser.service' @@ -186,8 +193,8 @@ function SuppressedLiveStreamEmbed({ noteId, className }: { noteId: string; clas } /** - * Fetches and renders an embedded note. Split out so we never call {@link useFetchEvent} with `undefined` - * (skipping fetch for suppressed live naddrs is handled in the parent without that hook). + * Fetches and renders an embedded note: wide-relay REQ immediately in parallel with the normal + * fetch path (no “try external” gate — embeds are always worth the index-relay fan-out). */ function EmbeddedNoteFetched({ noteId, @@ -202,19 +209,167 @@ function EmbeddedNoteFetched({ showFull: boolean allowLiveEmbeds: boolean }) { - const relayHints = useMemo( + const { isEventDeleted } = useDeletedEvent() + const { addReplies } = useReply() + const { favoriteRelays, blockedRelays } = useFavoriteRelays() + const [event, setEvent] = useState(undefined) + const [isFetching, setIsFetching] = useState(true) + const eventRef = useRef(undefined) + const retryIntervalRef = useRef | null>(null) + eventRef.current = event + + const relayHintsFromParent = useMemo( () => relayHintWssUrlsFromEvent(containingEvent), [containingEvent?.id] ) + const menuRelayUrls = useMemo( + () => + getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays) + .map((url) => normalizeUrl(url)) + .filter((url): url is string => Boolean(url)), + [favoriteRelays, blockedRelays] + ) + const wideRelaysStatic = useMemo( + () => buildEmbedWideRelayUrlsStatic(menuRelayUrls, relayHintsFromParent), + [menuRelayUrls, relayHintsFromParent] + ) const fetchRelayOpts = useMemo( - () => (relayHints.length > 0 ? { relayHints } : undefined), - [relayHints] + () => (relayHintsFromParent.length > 0 ? { relayHints: relayHintsFromParent } : undefined), + [relayHintsFromParent] ) - const { event, isFetching } = useFetchEvent(noteId, undefined, fetchRelayOpts) - /** Filled when “Try external relays” / IndexedDB recovery finds the event after the hook missed. */ - const [resolvedEvent, setResolvedEvent] = useState(undefined) - const finalEvent = event || resolvedEvent + const resolveAndSet = useCallback( + (ev: Event | undefined) => { + if (!ev || isEventDeleted(ev) || shouldDropEventOnIngest(ev)) return false + if (retryIntervalRef.current) { + clearInterval(retryIntervalRef.current) + retryIntervalRef.current = null + } + client.addEventToCache(ev) + setEvent(ev) + addReplies([ev]) + return true + }, + [addReplies, isEventDeleted] + ) + + /** Latest relay lists without listing them as effect deps (favorites context churn would re-run the effect and `setEvent(undefined)` wiped loaded embeds → loop / “crash”). */ + const embedFetchCtxRef = useRef({ + fetchRelayOpts: undefined as { relayHints?: string[] } | undefined, + wideRelaysStatic: [] as string[] + }) + embedFetchCtxRef.current = { fetchRelayOpts, wideRelaysStatic } + + const resolveAndSetRef = useRef(resolveAndSet) + resolveAndSetRef.current = resolveAndSet + + const isEventDeletedRef = useRef(isEventDeleted) + isEventDeletedRef.current = isEventDeleted + + const containingEventRef = useRef(containingEvent) + containingEventRef.current = containingEvent + + useEffect(() => { + let cancelled = false + const noteKey = noteId.trim() + eventRef.current = undefined + setEvent(undefined) + setIsFetching(true) + + const resolve = (ev: Event | undefined) => resolveAndSetRef.current(ev) + + const tryShortcuts = (): boolean => { + const nav = navigationEventStore.getEvent(noteKey) + if (nav && resolve(nav)) return true + const peek = client.peekSessionCachedEvent(noteKey) + if (peek && resolve(peek)) return true + return false + } + + const runWidePass = async (relayUrls: string[]) => { + if (!canSearchOnExternalRelays(noteKey) || relayUrls.length === 0) return undefined + const ev = await client.fetchEventWithExternalRelays(noteKey, relayUrls) + return ev + } + + const runParallelFetch = async () => { + const { fetchRelayOpts: opts, wideRelaysStatic: wide0 } = embedFetchCtxRef.current + const hex = hexEventIdFromNoteId(noteKey) + const primary = client.fetchEvent(noteKey, opts) + const wide = runWidePass(wide0) + const idb = + hex && /^[0-9a-f]{64}$/i.test(hex) + ? indexedDb.getEventFromPublicationStore(hex.toLowerCase()).catch(() => undefined) + : Promise.resolve(undefined) + const [p, w, db] = await Promise.all([primary, wide, idb]) + if (cancelled) return + const chosen = pickUsableEvent([p, w, db], isEventDeletedRef.current) + if (chosen) { + resolve(chosen) + setIsFetching(false) + return + } + setIsFetching(false) + } + + if (tryShortcuts()) { + setIsFetching(false) + } else { + void runParallelFetch() + } + + void (async () => { + if (tryShortcuts() || eventRef.current) return + const extra = await loadAsyncEmbedRelayHints(noteKey, containingEventRef.current) + if (cancelled || eventRef.current) return + const wide0 = embedFetchCtxRef.current.wideRelaysStatic + const wideMerged = preferPublicIndexRelaysFirst(dedupeRelayUrls([...wide0, ...extra])) + const ev = await runWidePass(wideMerged) + if (cancelled || !ev) return + resolve(ev) + })() + + if (eventRef.current) { + return () => { + cancelled = true + if (retryIntervalRef.current) { + clearInterval(retryIntervalRef.current) + retryIntervalRef.current = null + } + setIsFetching(false) + } + } + + retryIntervalRef.current = setInterval(() => { + if (cancelled || eventRef.current) return + void (async () => { + const opts = embedFetchCtxRef.current.fetchRelayOpts + const ev = await client.fetchEventForceRetry(noteKey, opts) + if (!cancelled && ev) resolve(ev) + })() + }, 8000) + + return () => { + cancelled = true + if (retryIntervalRef.current) { + clearInterval(retryIntervalRef.current) + retryIntervalRef.current = null + } + setIsFetching(false) + } + /** Only the embed pointer and parent note identity — not relay arrays / callbacks (avoids wipe+refetch loops). */ + }, [noteId, containingEvent?.id]) + + useEffect(() => { + if (!noteId.trim() || event !== undefined) return undefined + const id = noteId.trim() + return eventService.subscribeWhenSessionHasEvent(id, () => { + const peek = client.peekSessionCachedEvent(id) + if (peek) resolveAndSetRef.current(peek) + }) + }, [noteId, event]) + + const finalEvent = event if (isFetching && !finalEvent) { return @@ -222,12 +377,14 @@ function EmbeddedNoteFetched({ if (!finalEvent) { return ( - +
e.stopPropagation()} + data-embedded-note-loading + > + + +
) } @@ -347,53 +504,25 @@ function preferPublicIndexRelaysFirst(urls: readonly string[]): string[] { return [...urls].sort((a, b) => score(a) - score(b) || a.localeCompare(b)) } -function EmbeddedNoteSkeleton({ className }: { className?: string }) { - return ( -
e.stopPropagation()} - > -
- -
- - -
-
- - -
+/** Static + menu favorites: REQ immediately on embed mount (no NIP-65 round-trip first). */ +function buildEmbedWideRelayUrlsStatic(menuRelayUrls: string[], relayHintsFromParent: string[]): string[] { + return preferPublicIndexRelaysFirst( + dedupeRelayUrls([ + ...relayHintsFromParent, + ...nip66Service.getSearchableRelayUrls(), + ...SEARCHABLE_RELAY_URLS, + ...FAST_READ_RELAY_URLS, + ...FAST_WRITE_RELAY_URLS, + ...PROFILE_RELAY_URLS, + ...menuRelayUrls, + ]) ) } -function EmbeddedNoteNotFound({ - noteId, - className, - onEventFound, - containingEvent -}: { - noteId: string - className?: string - onEventFound?: (event: Event) => void - containingEvent?: Event // Event that contains this embedded note - use its author's relays and relay hints -}) { - const { t } = useTranslation() - const { favoriteRelays, blockedRelays } = useFavoriteRelays() - const menuRelayUrls = useMemo( - () => - getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays) - .map((url) => normalizeUrl(url)) - .filter((url): url is string => Boolean(url)), - [favoriteRelays, blockedRelays] - ) - const [isSearchingExternal, setIsSearchingExternal] = useState(false) - const [triedExternal, setTriedExternal] = useState(false) - const [asyncHintRelays, setAsyncHintRelays] = useState([]) - const [externalSearchDetail, setExternalSearchDetail] = useState< - null | 'unparseable' | 'no_relays' | 'searched' - >(null) - - const resolvedHexId = useMemo(() => { +/** NIP-65 / nevent relays / seen-on — merged into a second wide REQ if the first pass missed. */ +async function loadAsyncEmbedRelayHints(noteId: string, containingEvent?: Event): Promise { + const hintRelays: string[] = [] + const resolvedHexId = (() => { const h = hexEventIdFromNoteId(noteId) if (h) return h try { @@ -401,258 +530,84 @@ function EmbeddedNoteNotFound({ if (type === 'nevent') return data.id if (type === 'note') return data } catch { - /* plain hex handled above */ + return null } return null - }, [noteId]) - - /** Always available immediately: static searchable + fast-read + favorites + NIP-66 search-capable relays. */ - const coreExternalRelays = useMemo( - () => - preferPublicIndexRelaysFirst( - dedupeRelayUrls([ - ...nip66Service.getSearchableRelayUrls(), - ...SEARCHABLE_RELAY_URLS, - ...FAST_READ_RELAY_URLS, - ...menuRelayUrls, - ]) - ), - [menuRelayUrls] - ) - - const externalRelays = useMemo( - () => preferPublicIndexRelaysFirst(dedupeRelayUrls([...asyncHintRelays, ...coreExternalRelays])), - [asyncHintRelays, coreExternalRelays] - ) - - // Extra hints (parent tags, NIP-65, nevent/naddr relay lists, “seen on”) — merged on top of {@link coreExternalRelays}. - useEffect(() => { - let cancelled = false - const loadHints = async () => { - const hintRelays: string[] = [] - - if (containingEvent) { - for (const tag of containingEvent.tags) { - if (['e', 'a', 'q'].includes(tag[0]) && tag.length > 2 && typeof tag[2] === 'string') { - const hint = tag[2] - if (hint.startsWith('wss://') || hint.startsWith('ws://')) hintRelays.push(hint) - } - } - try { - const containingAuthorRelayList = await client - .fetchRelayList(containingEvent.pubkey) - .catch(() => ({ read: [] as string[], write: [] as string[] })) - hintRelays.push( - ...(containingAuthorRelayList.read ?? []).slice(0, 10), - ...(containingAuthorRelayList.write ?? []).slice(0, 10) - ) - } catch (err) { - logger.debug('Failed to fetch containing event author relays', { error: err }) - } - } - - try { - const { type, data } = nip19.decode(noteId.trim()) - if (type === 'nevent') { - if (data.relays) hintRelays.push(...data.relays) - if (data.author) { - const authorRelayList = await client - .fetchRelayList(data.author) - .catch(() => ({ read: [] as string[], write: [] as string[] })) - hintRelays.push( - ...(authorRelayList.read ?? []).slice(0, 10), - ...(authorRelayList.write ?? []).slice(0, 10) - ) - } - } else if (type === 'naddr') { - if (data.relays) hintRelays.push(...data.relays) - const authorRelayList = await client - .fetchRelayList(data.pubkey) - .catch(() => ({ read: [] as string[], write: [] as string[] })) - hintRelays.push( - ...(authorRelayList.read ?? []).slice(0, 10), - ...(authorRelayList.write ?? []).slice(0, 10) - ) - } - } catch { - /* invalid bech32 */ - } - - const seenOn = resolvedHexId ? client.getSeenEventRelayUrls(resolvedHexId) : [] - hintRelays.push(...seenOn) + })() - if (!cancelled) { - setAsyncHintRelays(dedupeRelayUrls(hintRelays)) - logger.debug('External relay hints merged', { - noteId, - hintCount: hintRelays.length, - totalRelays: dedupeRelayUrls([...hintRelays, ...coreExternalRelays]).length - }) - } - } - - void loadHints() - return () => { - cancelled = true - } - }, [noteId, containingEvent?.id, resolvedHexId, coreExternalRelays]) - - const handleTryExternalRelays = async () => { - if (isSearchingExternal) return - - if (!canSearchOnExternalRelays(noteId)) { - logger.warn('External relay search skipped: unsupported note id', { noteId }) - setExternalSearchDetail('unparseable') - setTriedExternal(true) - return - } - - if (externalRelays.length === 0) { - logger.warn('No external relays to search', { noteId }) - setExternalSearchDetail('no_relays') - setTriedExternal(true) - return - } - - setIsSearchingExternal(true) - setExternalSearchDetail(null) - let found: Event | undefined + if (containingEvent) { try { - const idHex = resolvedHexId ?? hexEventIdFromNoteId(noteId) - if (idHex) { - const fromDb = await indexedDb.getEventFromPublicationStore(idHex) - if (fromDb) { - client.addEventToCache(fromDb) - found = fromDb - onEventFound?.(fromDb) - logger.info('Event found in IndexedDB (try-harder)', { noteId }) - } - } - - if (!found) { - const retried = await client.fetchEventForceRetry(noteId) - if (retried) { - found = retried - onEventFound?.(retried) - logger.info('Event found after fetchEventForceRetry', { noteId }) - } - } - - if (!found) { - const idLog = idHex ?? noteId.slice(0, 16) - logger.info('Searching external relays', { - noteId, - hexOrHint: idLog, - relayCount: externalRelays.length, - relays: externalRelays.slice(0, 5) - }) - - const event = await client.fetchEventWithExternalRelays(noteId, externalRelays) - if (event) { - logger.info('Event found on external relay', { noteId }) - found = event - client.addEventToCache(event) - onEventFound?.(event) - } - } + const containingAuthorRelayList = await client + .fetchRelayList(containingEvent.pubkey) + .catch(() => ({ read: [] as string[], write: [] as string[] })) + hintRelays.push( + ...(containingAuthorRelayList.read ?? []).slice(0, 12), + ...(containingAuthorRelayList.write ?? []).slice(0, 12) + ) + } catch (err) { + logger.debug('[EmbeddedNote] containing author relays failed', { error: err }) + } + } - if (!found) { - logger.info('Event not found on external relays', { - noteId, - relayCount: externalRelays.length - }) - setExternalSearchDetail('searched') - } - } catch (error) { - logger.error('External relay fetch failed', { error, noteId, externalRelays }) - setExternalSearchDetail('searched') - } finally { - setIsSearchingExternal(false) - if (!found) { - setTriedExternal(true) + try { + const { type, data } = nip19.decode(noteId.trim()) + if (type === 'nevent') { + if (data.relays) hintRelays.push(...data.relays) + if (data.author) { + const authorRelayList = await client + .fetchRelayList(data.author) + .catch(() => ({ read: [] as string[], write: [] as string[] })) + hintRelays.push( + ...(authorRelayList.read ?? []).slice(0, 12), + ...(authorRelayList.write ?? []).slice(0, 12) + ) } + } else if (type === 'naddr') { + if (data.relays) hintRelays.push(...data.relays) + const authorRelayList = await client + .fetchRelayList(data.pubkey) + .catch(() => ({ read: [] as string[], write: [] as string[] })) + hintRelays.push( + ...(authorRelayList.read ?? []).slice(0, 12), + ...(authorRelayList.write ?? []).slice(0, 12) + ) } + } catch { + /* invalid */ } - const hasExternalRelays = externalRelays.length > 0 - const showExternalTryButton = - !triedExternal && hasExternalRelays && canSearchOnExternalRelays(noteId) - - return ( -
-
-
{t('Note not found')}
- - {showExternalTryButton && ( -
- -
- - {t('Show relays')} - -
- {externalRelays.map((relay, i) => ( -
- {relay} -
- ))} -
-
-
- )} - - {!triedExternal && !hasExternalRelays && ( -
{t('No external relay hints available')}
- )} - - {!triedExternal && hasExternalRelays && !canSearchOnExternalRelays(noteId) && ( -
- {t('External relay search is not available for this link type')} -
- )} - - {triedExternal && externalSearchDetail === 'unparseable' && ( -
{t('External relay search is not available for this link type')}
- )} - - {triedExternal && externalSearchDetail === 'no_relays' && ( -
{t('No external relay hints available')}
- )} + if (resolvedHexId) { + hintRelays.push(...client.getSeenEventRelayUrls(resolvedHexId)) + } + return dedupeRelayUrls(hintRelays) +} - {triedExternal && externalSearchDetail === 'searched' && ( -
- {t('Searched external relays not found', { count: externalRelays.length })} -
- )} +function pickUsableEvent( + candidates: (Event | undefined)[], + isEventDeleted: (e: Event) => boolean +): Event | undefined { + for (const e of candidates) { + if (!e || isEventDeleted(e) || shouldDropEventOnIngest(e)) continue + return e + } + return undefined +} - {triedExternal && !externalSearchDetail && ( -
{t('Note could not be found anywhere')}
- )} - - +function EmbeddedNoteSkeleton({ className }: { className?: string }) { + return ( +
e.stopPropagation()} + > +
+ +
+ + +
+ +
) } @@ -703,7 +658,12 @@ function EmbeddedBookstrEvent({ event, originalNoteId, className }: { event: Eve return } e.stopPropagation() - const noteUrl = toNote(originalNoteId ?? event) + const noteUrl = toNote( + originalNoteId ?? event, + typeof originalNoteId === 'string' && /^[0-9a-f]{64}$/i.test(originalNoteId.trim()) + ? event + : undefined + ) navigateToNote(noteUrl, event, getCachedThreadContextEvents(event)) }} > diff --git a/src/components/Note/LongFormCard.tsx b/src/components/Note/LongFormCard.tsx index 67a9c566..4da4e90b 100644 --- a/src/components/Note/LongFormCard.tsx +++ b/src/components/Note/LongFormCard.tsx @@ -42,7 +42,7 @@ export default function LongFormCard({ const handleCardClick = (e: React.MouseEvent) => { if (!interactive) return e.stopPropagation() - push(toNote(event.id)) + push(toNote(event)) } const titleComponent = ( diff --git a/src/components/Note/PublicationCard.tsx b/src/components/Note/PublicationCard.tsx index 541d5bd6..140937a6 100644 --- a/src/components/Note/PublicationCard.tsx +++ b/src/components/Note/PublicationCard.tsx @@ -32,7 +32,7 @@ export default function PublicationCard({ const handleCardClick = (e: React.MouseEvent) => { e.stopPropagation() - push(toNote(event.id)) + push(toNote(event)) } const titleComponent = metadata.title ?
{metadata.title}
: null diff --git a/src/components/Note/WikiCard.tsx b/src/components/Note/WikiCard.tsx index 42eb2fad..4bdfa3fe 100644 --- a/src/components/Note/WikiCard.tsx +++ b/src/components/Note/WikiCard.tsx @@ -27,7 +27,7 @@ export default function WikiCard({ const handleCardClick = (e: React.MouseEvent) => { e.stopPropagation() - push(toNote(event.id)) + push(toNote(event)) } const titleComponent =
{metadata.title}
diff --git a/src/components/Note/Zap.tsx b/src/components/Note/Zap.tsx index a8d6a273..210c04af 100644 --- a/src/components/Note/Zap.tsx +++ b/src/components/Note/Zap.tsx @@ -80,7 +80,7 @@ export default function Zap({ e.stopPropagation() if (isEventZap) { if (targetEvent) { - navigateToNote(toNote(targetEvent.id), targetEvent) + navigateToNote(toNote(targetEvent), targetEvent) } else if (zapInfo.eventId) { navigateToNote(toNote(zapInfo.eventId)) } diff --git a/src/components/NoteCard/MainNoteCard.tsx b/src/components/NoteCard/MainNoteCard.tsx index f0b8d38f..1f2f55a6 100644 --- a/src/components/NoteCard/MainNoteCard.tsx +++ b/src/components/NoteCard/MainNoteCard.tsx @@ -72,7 +72,12 @@ export default function MainNoteCard({ } e.stopPropagation() client.addEventToCache(event) - const noteUrl = toNote(originalNoteId ?? event) + const noteUrl = toNote( + originalNoteId ?? event, + typeof originalNoteId === 'string' && /^[0-9a-f]{64}$/i.test(originalNoteId.trim()) + ? event + : undefined + ) navigateToNote(noteUrl, event, getCachedThreadContextEvents(event)) }} > diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index fc699032..05131891 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -235,6 +235,56 @@ function feedTimelineAlreadyRepresentsNip18Target(targetId: string | undefined, return false } +/** + * `mergeEventBatchesById` only dedupes by event id; multiple kind-6/16 reposts of the same target stay + * separate. Collapse to one timeline row per target (first row in array order wins — live merges are + * newest-first). Dropped rows still update `noteStatsService` for “boosted by” aggregation, same as + * `onNew` / `showNewEvents`. + */ +function collapseDuplicateNip18RepostTimelineRows(sortedNewestFirst: Event[]): Event[] { + const kept: Event[] = [] + const statsOnly: Event[] = [] + for (const e of sortedNewestFirst) { + if (isNip18RepostKind(e.kind)) { + const t = getNip18RepostTargetId(e) + if (t && feedTimelineAlreadyRepresentsNip18Target(t, kept)) { + statsOnly.push(e) + continue + } + kept.push(e) + continue + } + const idKey = normalizeFeedRepostTargetKey(e.id) + const coveredByRepost = kept.some((k) => { + if (!isNip18RepostKind(k.kind)) return false + const rt = getNip18RepostTargetId(k) + return Boolean(rt && normalizeFeedRepostTargetKey(rt) === idKey) + }) + if (coveredByRepost) { + statsOnly.push(e) + continue + } + if (isReplaceableEvent(e.kind)) { + const coord = getReplaceableCoordinateFromEvent(e) + const coordNorm = normalizeFeedRepostTargetKey(coord) + const coveredByCoordRepost = kept.some((k) => { + if (!isNip18RepostKind(k.kind)) return false + const rt = getNip18RepostTargetId(k) + return Boolean(rt && normalizeFeedRepostTargetKey(rt) === coordNorm) + }) + if (coveredByCoordRepost) { + statsOnly.push(e) + continue + } + } + kept.push(e) + } + if (statsOnly.length > 0) { + noteStatsService.updateNoteStatsByEvents(statsOnly, undefined) + } + return kept +} + const FEED_PROFILE_PREFETCH_MAX_P_TAGS = 64 const FEED_STATS_PROFILE_REPOSTS_CAP = 48 const FEED_STATS_PROFILE_LIKES_PER_NOTE = 8 @@ -1801,8 +1851,9 @@ const NoteList = forwardRef( if (!keepExistingTimelineEvents) { if (restoredFromSession && sessionSnap) { feedPaintSessionPendingRef.current = true - setEvents(sessionSnap) - lastEventsForTimelinePrefetchRef.current = sessionSnap + const restored = collapseDuplicateNip18RepostTimelineRows(sessionSnap) + setEvents(restored) + lastEventsForTimelinePrefetchRef.current = restored setNewEvents([]) setShowCount(revealBatchSize ?? SHOW_COUNT) setLoading(!!oneShotFetch) @@ -1949,6 +2000,7 @@ const NoteList = forwardRef( if (mergeCmp) { next = [...next].sort(mergeCmp) } + next = collapseDuplicateNip18RepostTimelineRows(next) lastEventsForTimelinePrefetchRef.current = next return next }) @@ -1979,8 +2031,9 @@ const NoteList = forwardRef( : {}) }) } - setEvents(merged) - lastEventsForTimelinePrefetchRef.current = merged + const collapsed = collapseDuplicateNip18RepostTimelineRows(merged) + setEvents(collapsed) + lastEventsForTimelinePrefetchRef.current = collapsed } if (oneShotDebugLabel && isProgressiveLayers) { const f0 = mappedSubRequests[0]?.filter @@ -2170,7 +2223,9 @@ const NoteList = forwardRef( narrowed, oneShotAfterMergeComparatorRef.current ) - : mergeEventBatchesById(prev, narrowed, eventCap, areAlgoRelays) + : collapseDuplicateNip18RepostTimelineRows( + mergeEventBatchesById(prev, narrowed, eventCap, areAlgoRelays) + ) lastEventsForTimelinePrefetchRef.current = next return next }) @@ -2535,7 +2590,9 @@ const NoteList = forwardRef( if (batch.length > 0) { if (narrowed.length > 0) { setEvents((prev) => { - const next = mergeEventBatchesById(prev, narrowed, eventCapDelta, areAlgoRelays) + const next = collapseDuplicateNip18RepostTimelineRows( + mergeEventBatchesById(prev, narrowed, eventCapDelta, areAlgoRelays) + ) lastEventsForTimelinePrefetchRef.current = next return next }) @@ -3009,7 +3066,9 @@ const NoteList = forwardRef( consecutiveEmptyRef.current = 0 - setEvents((oldEvents) => [...oldEvents, ...toAppend]) + setEvents((oldEvents) => + collapseDuplicateNip18RepostTimelineRows([...oldEvents, ...toAppend]) + ) // After appending, the bottom sentinel may have moved below the fold. Re-check after // paint: if it's still in/near view, trigger loadMore again so user doesn't have to scroll. diff --git a/src/components/Profile/ProfileHeaderInteractions.tsx b/src/components/Profile/ProfileHeaderInteractions.tsx index 0392a5a8..ac42d143 100644 --- a/src/components/Profile/ProfileHeaderInteractions.tsx +++ b/src/components/Profile/ProfileHeaderInteractions.tsx @@ -103,7 +103,7 @@ function CommentBadge({ event }: { event: Event }) {