Browse Source

reorder trending on nostr relay

fix rendering of media
imwald
Silberengel 3 weeks ago
parent
commit
2f55f050b6
  1. 10
      src/components/Image/index.tsx
  2. 71
      src/components/ImageGallery/index.tsx
  3. 152
      src/components/ImageWithLightbox/index.tsx
  4. 2
      src/components/Note/AsciidocArticle/AsciidocArticle.tsx
  5. 2
      src/components/Note/MarkdownArticle/MarkdownArticle.tsx
  6. 4
      src/components/Note/index.tsx
  7. 16
      src/components/NoteList/index.tsx
  8. 2
      src/pages/primary/NoteListPage/RelaysFeed.tsx
  9. 6
      src/providers/ContentPolicyProvider.tsx
  10. 40
      src/providers/FeedProvider.tsx

10
src/components/Image/index.tsx

@ -67,6 +67,11 @@ export default function Image({ @@ -67,6 +67,11 @@ export default function Image({
const [imageUrl, setImageUrl] = useState(url)
const [fallbackIndex, setFallbackIndex] = useState(0)
const loadWatchRef = useRef<number | null>(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, <img loading="lazy"> 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({ @@ -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 <img loading="lazy"> 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)

71
src/components/ImageGallery/index.tsx

@ -107,42 +107,41 @@ export default function ImageGallery({ @@ -107,42 +107,41 @@ export default function ImageGallery({
return (
<div className={cn(displayImages.length === 1 ? 'w-fit max-w-[400px]' : 'w-full', className)}>
{imageContent}
{index >= 0 &&
createPortal(
<div onClick={(e) => e.stopPropagation()}>
<Lightbox
index={index}
slides={(() => {
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
}}
/>
</div>,
document.body
)}
{createPortal(
<div onClick={(e) => e.stopPropagation()}>
<Lightbox
index={index}
slides={(() => {
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
}}
/>
</div>,
document.body
)}
</div>
)
}

152
src/components/ImageWithLightbox/index.tsx

@ -31,6 +31,13 @@ export default function ImageWithLightbox({ @@ -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<string, unknown>) => {
logger.info('[LightboxTrace]', {
stage,
@ -84,20 +91,6 @@ export default function ImageWithLightbox({ @@ -84,20 +91,6 @@ export default function ImageWithLightbox({
}
}, [index, logLightboxEvent])
if (!display) {
return (
<span
className="text-primary hover:underline truncate w-fit cursor-pointer inline-block"
onClick={(e) => {
e.stopPropagation()
setDisplay(true)
}}
>
[{t('Click to load image')}]
</span>
)
}
const handlePhotoClick = (event: React.MouseEvent) => {
logLightboxEvent('thumbnail-click', {
defaultPreventedBefore: event.defaultPrevented
@ -108,70 +101,85 @@ export default function ImageWithLightbox({ @@ -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 (
<div className="max-w-[400px]">
<Image
key={0}
className={className}
classNames={{
wrapper: cn('rounded-lg cursor-zoom-in', classNames.wrapper),
errorPlaceholder: 'aspect-square h-[30vh]'
}}
image={image}
onClick={(e) => handlePhotoClick(e)}
/>
{index >= 0 &&
createPortal(
<div
data-lightbox-overlay
onClick={(e) => {
logLightboxEvent('overlay-click', { target: (e.target as HTMLElement)?.tagName })
e.stopPropagation()
{display ? (
<Image
key={0}
className={className}
classNames={{
wrapper: cn('rounded-lg cursor-zoom-in', classNames.wrapper),
errorPlaceholder: 'aspect-square h-[30vh]'
}}
image={image}
onClick={(e) => handlePhotoClick(e)}
/>
) : (
<span
className="text-primary hover:underline truncate w-fit cursor-pointer inline-block"
onClick={(e) => {
e.stopPropagation()
setDisplay(true)
}}
>
[{t('Click to load image')}]
</span>
)}
{createPortal(
<div
data-lightbox-overlay
onClick={(e) => {
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()
}}
>
<Lightbox
index={index}
slides={[
{
src: preferBlossomPrimalDisplayUrl(image.url),
alt: image.alt || image.url,
title: image.alt || undefined
}
]}
plugins={[Zoom, Captions]}
open={index >= 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' }
}}
>
<Lightbox
index={index}
slides={[
{
src: preferBlossomPrimalDisplayUrl(image.url),
alt: image.alt || image.url,
title: image.alt || undefined
}
]}
plugins={[Zoom, Captions]}
open={index >= 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' }
}}
/>
</div>,
document.body
)}
/>
</div>,
document.body
)}
</div>
)
}

2
src/components/Note/AsciidocArticle/AsciidocArticle.tsx

@ -2121,7 +2121,7 @@ export default function AsciidocArticle({ @@ -2121,7 +2121,7 @@ export default function AsciidocArticle({
</div>
{/* Image gallery lightbox */}
{allImages.length > 0 && lightboxIndex >= 0 && createPortal(
{allImages.length > 0 && createPortal(
<div onClick={(e) => e.stopPropagation()}>
<Lightbox
index={lightboxIndex}

2
src/components/Note/MarkdownArticle/MarkdownArticle.tsx

@ -5083,7 +5083,7 @@ export default function MarkdownArticle({ @@ -5083,7 +5083,7 @@ export default function MarkdownArticle({
</div>
{/* Image gallery lightbox */}
{allImages.length > 0 && lightboxOpen && createPortal(
{allImages.length > 0 && createPortal(
<div
data-lightbox-overlay
onClick={(e) => e.stopPropagation()}

4
src/components/Note/index.tsx

@ -214,12 +214,12 @@ export default function Note({ @@ -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

16
src/components/NoteList/index.tsx

@ -122,7 +122,17 @@ const FEED_PROFILE_BATCH_DEBOUNCE_MS = 50 @@ -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<string, Event>()
for (const e of prev) {
byId.set(e.id, e)
@ -1822,7 +1832,7 @@ const NoteList = forwardRef( @@ -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( @@ -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
})

2
src/pages/primary/NoteListPage/RelaysFeed.tsx

@ -169,7 +169,7 @@ const RelaysFeed = forwardRef< @@ -169,7 +169,7 @@ const RelaysFeed = forwardRef<
<NormalFeed
ref={ref}
subRequests={subRequests}
areAlgoRelays={areAlgoRelays}
areAlgoRelays={wispTrendingSingleRelay || areAlgoRelays}
isMainFeed
setSubHeader={setSubHeader}
onSubHeaderRefresh={onSubHeaderRefresh}

6
src/providers/ContentPolicyProvider.tsx

@ -64,8 +64,10 @@ export function ContentPolicyProvider({ children }: { children: React.ReactNode @@ -64,8 +64,10 @@ export function ContentPolicyProvider({ children }: { children: React.ReactNode
if (mediaAutoLoadPolicy === MEDIA_AUTO_LOAD_POLICY.NEVER) {
return false
}
// WIFI_ONLY
return connectionType === 'wifi' || connectionType === 'ethernet'
// WIFI_ONLY: block only when explicitly on cellular — connection.type returns
// 'unknown' on Linux/Windows desktop (Network Information API is reliable only
// on Android/ChromeOS), so an allowlist would wrongly block desktop wifi.
return connectionType !== 'cellular'
}, [mediaAutoLoadPolicy, connectionType])
const updateAutoplay = (autoplay: boolean) => {

40
src/providers/FeedProvider.tsx

@ -1,13 +1,14 @@ @@ -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' @@ -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<string[]>([])
const [isReady, setIsReady] = useState(false)
const [feedInfo, setFeedInfo] = useState<TFeedInfo>({
@ -103,7 +123,8 @@ export function FeedProvider({ children }: { children: React.ReactNode }) { @@ -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 }) { @@ -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 }) { @@ -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 (
<FeedContext.Provider

Loading…
Cancel
Save