From 2f55f050b636ddf6838ff3439f640e24101137c6 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Wed, 8 Apr 2026 13:50:19 +0200 Subject: [PATCH] reorder trending on nostr relay fix rendering of media --- src/components/Image/index.tsx | 10 ++ src/components/ImageGallery/index.tsx | 71 ++++---- src/components/ImageWithLightbox/index.tsx | 152 +++++++++--------- .../Note/AsciidocArticle/AsciidocArticle.tsx | 2 +- .../Note/MarkdownArticle/MarkdownArticle.tsx | 2 +- src/components/Note/index.tsx | 4 +- src/components/NoteList/index.tsx | 16 +- src/pages/primary/NoteListPage/RelaysFeed.tsx | 2 +- src/providers/ContentPolicyProvider.tsx | 6 +- src/providers/FeedProvider.tsx | 40 +++-- 10 files changed, 178 insertions(+), 127 deletions(-) diff --git a/src/components/Image/index.tsx b/src/components/Image/index.tsx index a8f96e46..ec980f16 100644 --- a/src/components/Image/index.tsx +++ b/src/components/Image/index.tsx @@ -67,6 +67,11 @@ export default function Image({ const [imageUrl, setImageUrl] = useState(url) const [fallbackIndex, setFallbackIndex] = useState(0) const loadWatchRef = useRef(null) + // Track whether this image started in the held state (required an explicit click to reveal). + // The timeout is only meaningful when the user already triggered a load — for auto-revealed + // images, delays the browser request until the element nears the viewport, + // so a 10 s timeout would fire before off-screen images are even fetched. + const wasInitiallyHeldRef = useRef(holdUntilClick) const finalAlt = imetaAlt || alt const openLinkHref = @@ -102,6 +107,11 @@ export default function Image({ useEffect(() => { clearLoadWatch() if (badSrc || !url?.trim() || !revealed) return + // Skip the timeout for auto-load images (holdUntilClick was false from mount). + // Their request hasn't necessarily started yet when revealed + // becomes true, so the timeout would fire before the browser even fetches the image. + // For those images, onError is sufficient — it fires whenever the browser does try. + if (!wasInitiallyHeldRef.current) return loadWatchRef.current = window.setTimeout(() => { loadWatchRef.current = null setIsLoading(false) diff --git a/src/components/ImageGallery/index.tsx b/src/components/ImageGallery/index.tsx index add09225..a2f43c4f 100644 --- a/src/components/ImageGallery/index.tsx +++ b/src/components/ImageGallery/index.tsx @@ -107,42 +107,41 @@ export default function ImageGallery({ return (
{imageContent} - {index >= 0 && - createPortal( -
e.stopPropagation()}> - { - const slides = images.map(({ url, alt }) => ({ - src: preferBlossomPrimalDisplayUrl(url), - alt: alt || url, - title: alt || undefined - })) - logger.debug('[ImageGallery] Lightbox slides:', { index, slidesCount: slides.length, slides }) - return slides - })()} - plugins={[Zoom, Captions]} - open={index >= 0} - close={() => setIndex(-1)} - controller={{ - closeOnBackdropClick: false, - closeOnPullUp: true, - closeOnPullDown: true - }} - render={{ - buttonPrev: images.length <= 1 ? () => null : undefined, - buttonNext: images.length <= 1 ? () => null : undefined - }} - styles={{ - toolbar: { paddingTop: '2.25rem' } - }} - carousel={{ - finite: false - }} - /> -
, - document.body - )} + {createPortal( +
e.stopPropagation()}> + { + const slides = images.map(({ url, alt }) => ({ + src: preferBlossomPrimalDisplayUrl(url), + alt: alt || url, + title: alt || undefined + })) + logger.debug('[ImageGallery] Lightbox slides:', { index, slidesCount: slides.length, slides }) + return slides + })()} + plugins={[Zoom, Captions]} + open={index >= 0} + close={() => setIndex(-1)} + controller={{ + closeOnBackdropClick: false, + closeOnPullUp: true, + closeOnPullDown: true + }} + render={{ + buttonPrev: images.length <= 1 ? () => null : undefined, + buttonNext: images.length <= 1 ? () => null : undefined + }} + styles={{ + toolbar: { paddingTop: '2.25rem' } + }} + carousel={{ + finite: false + }} + /> +
, + document.body + )}
) } diff --git a/src/components/ImageWithLightbox/index.tsx b/src/components/ImageWithLightbox/index.tsx index 1ad14d36..6e9092aa 100644 --- a/src/components/ImageWithLightbox/index.tsx +++ b/src/components/ImageWithLightbox/index.tsx @@ -31,6 +31,13 @@ export default function ImageWithLightbox({ const [display, setDisplay] = useState(autoLoadMedia) const [index, setIndex] = useState(-1) + useEffect(() => { + setDisplay(autoLoadMedia) + if (!autoLoadMedia) { + setIndex(-1) + } + }, [autoLoadMedia]) + const logLightboxEvent = useCallback((stage: string, details?: Record) => { logger.info('[LightboxTrace]', { stage, @@ -84,20 +91,6 @@ export default function ImageWithLightbox({ } }, [index, logLightboxEvent]) - if (!display) { - return ( - { - e.stopPropagation() - setDisplay(true) - }} - > - [{t('Click to load image')}] - - ) - } - const handlePhotoClick = (event: React.MouseEvent) => { logLightboxEvent('thumbnail-click', { defaultPreventedBefore: event.defaultPrevented @@ -108,70 +101,85 @@ export default function ImageWithLightbox({ setIndex(0) } + // The portal is always mounted (not conditional on `index >= 0`) so that React + // never removes it while yet-another-react-lightbox is mid-cleanup, which would + // otherwise cause "Node.removeChild: The node to be removed is not a child of + // this node". Visibility is controlled via the `open` prop instead. return (
- handlePhotoClick(e)} - /> - {index >= 0 && - createPortal( -
{ - logLightboxEvent('overlay-click', { target: (e.target as HTMLElement)?.tagName }) - e.stopPropagation() + {display ? ( + handlePhotoClick(e)} + /> + ) : ( + { + e.stopPropagation() + setDisplay(true) + }} + > + [{t('Click to load image')}] + + )} + {createPortal( +
{ + logLightboxEvent('overlay-click', { target: (e.target as HTMLElement)?.tagName }) + e.stopPropagation() + }} + onPointerDown={(e) => { + logLightboxEvent('overlay-pointerdown', { target: (e.target as HTMLElement)?.tagName }) + e.stopPropagation() + }} + onMouseDown={(e) => { + logLightboxEvent('overlay-mousedown', { target: (e.target as HTMLElement)?.tagName }) + e.stopPropagation() + }} + onTouchStart={(e) => { + logLightboxEvent('overlay-touchstart', { target: (e.target as HTMLElement)?.tagName }) + e.stopPropagation() + }} + > + = 0} + close={() => { + logLightboxEvent('lightbox-close-callback') + setIndex(-1) }} - onPointerDown={(e) => { - logLightboxEvent('overlay-pointerdown', { target: (e.target as HTMLElement)?.tagName }) - e.stopPropagation() + controller={{ + closeOnBackdropClick: false, + closeOnPullUp: true, + closeOnPullDown: true }} - onMouseDown={(e) => { - logLightboxEvent('overlay-mousedown', { target: (e.target as HTMLElement)?.tagName }) - e.stopPropagation() + render={{ + buttonPrev: () => null, + buttonNext: () => null }} - onTouchStart={(e) => { - logLightboxEvent('overlay-touchstart', { target: (e.target as HTMLElement)?.tagName }) - e.stopPropagation() + styles={{ + toolbar: { paddingTop: '2.25rem' } }} - > - = 0} - close={() => { - logLightboxEvent('lightbox-close-callback') - setIndex(-1) - }} - controller={{ - closeOnBackdropClick: false, - closeOnPullUp: true, - closeOnPullDown: true - }} - render={{ - buttonPrev: () => null, - buttonNext: () => null - }} - styles={{ - toolbar: { paddingTop: '2.25rem' } - }} - /> -
, - document.body - )} + /> +
, + document.body + )}
) } diff --git a/src/components/Note/AsciidocArticle/AsciidocArticle.tsx b/src/components/Note/AsciidocArticle/AsciidocArticle.tsx index 9d0a9a18..25fa6a85 100644 --- a/src/components/Note/AsciidocArticle/AsciidocArticle.tsx +++ b/src/components/Note/AsciidocArticle/AsciidocArticle.tsx @@ -2121,7 +2121,7 @@ export default function AsciidocArticle({ {/* Image gallery lightbox */} - {allImages.length > 0 && lightboxIndex >= 0 && createPortal( + {allImages.length > 0 && createPortal(
e.stopPropagation()}> {/* Image gallery lightbox */} - {allImages.length > 0 && lightboxOpen && createPortal( + {allImages.length > 0 && createPortal(
e.stopPropagation()} diff --git a/src/components/Note/index.tsx b/src/components/Note/index.tsx index c0868ee2..f076c2e3 100644 --- a/src/components/Note/index.tsx +++ b/src/components/Note/index.tsx @@ -214,12 +214,12 @@ export default function Note({ className={className} event={event} hideMetadata={hideMetadata} - lazyMedia={!showFull || !autoLoadMedia} + lazyMedia={!autoLoadMedia} fullCalendarInvite={fullCalendarInvite} /> ) }, - [event, fullCalendarInvite, showFull, autoLoadMedia] + [event, fullCalendarInvite, autoLoadMedia] ) let content: React.ReactNode diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index 3a665410..299111f1 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -122,7 +122,17 @@ const FEED_PROFILE_BATCH_DEBOUNCE_MS = 50 /** Larger chunks + parallel fetches below — sequential 36-pubkey rounds made notification avatars lag. */ const FEED_PROFILE_CHUNK = 80 -function mergeEventBatchesById(prev: Event[], incoming: Event[], cap: number): Event[] { +function mergeEventBatchesById( + prev: Event[], + incoming: Event[], + cap: number, + preserveOrder = false +): Event[] { + if (preserveOrder) { + const incomingIds = new Set(incoming.map((e) => e.id)) + const prevOnly = prev.filter((e) => !incomingIds.has(e.id)) + return [...incoming, ...prevOnly].slice(0, cap) + } const byId = new Map() for (const e of prev) { byId.set(e.id, e) @@ -1822,7 +1832,7 @@ const NoteList = forwardRef( narrowed, oneShotAfterMergeComparatorRef.current ) - : mergeEventBatchesById(prev, narrowed, eventCap) + : mergeEventBatchesById(prev, narrowed, eventCap, areAlgoRelays) lastEventsForTimelinePrefetchRef.current = next return next }) @@ -2145,7 +2155,7 @@ const NoteList = forwardRef( if (batch.length > 0) { if (narrowed.length > 0) { setEvents((prev) => { - const next = mergeEventBatchesById(prev, narrowed, eventCapDelta) + const next = mergeEventBatchesById(prev, narrowed, eventCapDelta, areAlgoRelays) lastEventsForTimelinePrefetchRef.current = next return next }) diff --git a/src/pages/primary/NoteListPage/RelaysFeed.tsx b/src/pages/primary/NoteListPage/RelaysFeed.tsx index 62914202..d666ceb1 100644 --- a/src/pages/primary/NoteListPage/RelaysFeed.tsx +++ b/src/pages/primary/NoteListPage/RelaysFeed.tsx @@ -169,7 +169,7 @@ const RelaysFeed = forwardRef< { diff --git a/src/providers/FeedProvider.tsx b/src/providers/FeedProvider.tsx index 0e2de612..0e1db00b 100644 --- a/src/providers/FeedProvider.tsx +++ b/src/providers/FeedProvider.tsx @@ -1,13 +1,14 @@ import { DEFAULT_FAVORITE_RELAYS } from '@/constants' -import { getFavoritesFeedRelayUrls } from '@/lib/favorites-feed-relays' -import { getRelaySetFromEvent } from '@/lib/event-metadata' +import { getFavoritesFeedRelayUrls, mergeRelayUrlLayers } from '@/lib/favorites-feed-relays' +import { getRelaySetFromEvent, getRelayListFromEvent, getHttpRelayListFromEvent } from '@/lib/event-metadata' import logger from '@/lib/logger' import { isHttpRelayUrl, isWebsocketUrl, normalizeAnyRelayUrl } from '@/lib/url' +import { buildWispTrendingNotesRelayUrl } from '@/lib/wisp-trending-relay' import indexedDb from '@/services/indexed-db.service' import storage from '@/services/local-storage.service' import { TFeedInfo, TFeedType } from '@/types' import { kinds } from 'nostr-tools' -import { useEffect, useRef, useState, useCallback } from 'react' +import { useEffect, useMemo, useRef, useState, useCallback } from 'react' import { FeedContext } from './feed-context' import { useFavoriteRelays } from './FavoriteRelaysProvider' import { useNostr } from './NostrProvider' @@ -16,8 +17,27 @@ export { useFeed } from './feed-context' export type { TFeedContext } from './feed-context' export function FeedProvider({ children }: { children: React.ReactNode }) { - const { pubkey, isInitialized } = useNostr() + const { pubkey, isInitialized, cacheRelayListEvent, httpRelayListEvent } = useNostr() const { relaySets, favoriteRelays, blockedRelays } = useFavoriteRelays() + + /** + * Extra relay URLs always merged into the all-favorites feed: + * - Cache relays (kind 10432) if the user has configured any + * - HTTP index relays (kind 10243) if the user has configured any + * - The Wisp trending relay (always included) + */ + const extraFeedRelayUrls = useMemo(() => { + const extra: string[] = [buildWispTrendingNotesRelayUrl()] + if (cacheRelayListEvent) { + const list = getRelayListFromEvent(cacheRelayListEvent) + extra.push(...list.read, ...list.write) + } + if (httpRelayListEvent) { + const list = getHttpRelayListFromEvent(httpRelayListEvent) + extra.push(...list.httpRead, ...list.httpWrite) + } + return extra + }, [cacheRelayListEvent, httpRelayListEvent]) const [relayUrls, setRelayUrls] = useState([]) const [isReady, setIsReady] = useState(false) const [feedInfo, setFeedInfo] = useState({ @@ -103,7 +123,8 @@ export function FeedProvider({ children }: { children: React.ReactNode }) { return } if (feedType === 'all-favorites') { - const finalRelays = getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays) + const baseRelays = getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays) + const finalRelays = mergeRelayUrlLayers([baseRelays, extraFeedRelayUrls], blockedRelays) logger.debug('Switching to all-favorites, finalRelays:', finalRelays) const newFeedInfo = { feedType } setFeedInfo(newFeedInfo) @@ -116,7 +137,7 @@ export function FeedProvider({ children }: { children: React.ReactNode }) { return } setIsReady(true) - }, [pubkey, favoriteRelays, blockedRelays, relaySets]) + }, [pubkey, favoriteRelays, blockedRelays, relaySets, extraFeedRelayUrls]) useEffect(() => { const init = async () => { @@ -199,13 +220,14 @@ export function FeedProvider({ children }: { children: React.ReactNode }) { init() }, [pubkey, isInitialized, favoriteRelays, blockedRelays, switchFeed]) - // Update relay URLs when favoriteRelays change and we're in all-favorites mode + // Update relay URLs when favoriteRelays, blocked, or extra relay lists change while in all-favorites mode useEffect(() => { if (feedInfo.feedType !== 'all-favorites') return - const finalRelays = getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays) + const baseRelays = getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays) + const finalRelays = mergeRelayUrlLayers([baseRelays, extraFeedRelayUrls], blockedRelays) logger.debug('Updating relay URLs for all-favorites:', finalRelays) setRelayUrls(finalRelays) - }, [feedInfo.feedType, favoriteRelays, blockedRelays]) + }, [feedInfo.feedType, favoriteRelays, blockedRelays, extraFeedRelayUrls]) return (