Browse Source

autoloading for follows

imwald
Silberengel 1 week ago
parent
commit
415c654258
  1. 22
      src/components/Content/index.tsx
  2. 9
      src/components/FountainEmbeddedPlayer/index.tsx
  3. 9
      src/components/Image/index.tsx
  4. 9
      src/components/ImageGallery/index.tsx
  5. 5
      src/components/ImageWithLightbox/index.tsx
  6. 15
      src/components/MediaPlayer/index.tsx
  7. 12
      src/components/Note/ArticleCardCoverImage.tsx
  8. 10
      src/components/Note/CommunityDefinition.tsx
  9. 10
      src/components/Note/GroupMetadata.tsx
  10. 8
      src/components/Note/LiveEvent.tsx
  11. 5
      src/components/Note/LongFormCard.tsx
  12. 12
      src/components/Note/MusicTrackNote.tsx
  13. 15
      src/components/Note/PublicationCard.tsx
  14. 5
      src/components/Note/WikiCard.tsx
  15. 3
      src/components/Note/index.tsx
  16. 9
      src/components/SpotifyEmbeddedPlayer/index.tsx
  17. 9
      src/components/WavlakeEmbeddedPlayer/index.tsx
  18. 14
      src/components/WebPreview/index.tsx
  19. 9
      src/components/YoutubeEmbeddedPlayer/index.tsx
  20. 1
      src/constants.ts
  21. 1
      src/hooks/index.tsx
  22. 34
      src/hooks/useShouldAutoLoadMedia.ts
  23. 1
      src/i18n/locales/en.ts
  24. 44
      src/lib/media-auto-load-policy.ts
  25. 5
      src/pages/secondary/GeneralSettingsPage/index.tsx
  26. 22
      src/providers/ContentPolicyProvider.tsx
  27. 2
      src/services/local-storage.service.ts

22
src/components/Content/index.tsx

@ -92,6 +92,7 @@ export default function Content({ @@ -92,6 +92,7 @@ export default function Content({
mustLoadMedia?: boolean
}) {
const _content = event?.content ?? content
const authorPubkey = event?.pubkey
const iArticleUrl = useMemo(() => (event ? getHttpUrlFromITags(event) : undefined), [event])
const iArticleCleaned = useMemo(
() => (iArticleUrl ? cleanUrl(iArticleUrl) || iArticleUrl : ''),
@ -485,7 +486,7 @@ export default function Content({ @@ -485,7 +486,7 @@ export default function Content({
<div className={cn('text-wrap break-words whitespace-pre-wrap', className)}>
{iArticleUrl && (
<div className="mb-2 max-w-full">
<WebPreview url={iArticleUrl} className="w-full" />
<WebPreview url={iArticleUrl} className="w-full" authorPubkey={authorPubkey} />
</div>
)}
{/* Render images that appear in content in a single carousel at the top */}
@ -497,6 +498,7 @@ export default function Content({ @@ -497,6 +498,7 @@ export default function Content({
start={0}
end={imagesInContent.length}
mustLoad={mustLoadMedia}
authorPubkey={authorPubkey}
/>
)}
@ -510,6 +512,7 @@ export default function Content({ @@ -510,6 +512,7 @@ export default function Content({
start={0}
end={carouselImages.length}
mustLoad={mustLoadMedia}
authorPubkey={authorPubkey}
/>
)}
@ -520,6 +523,7 @@ export default function Content({ @@ -520,6 +523,7 @@ export default function Content({
src={video.url}
className="w-full max-w-full"
mustLoad={mustLoadMedia}
authorPubkey={authorPubkey}
deferLoadUntilClick={deferLongVideoLoad}
poster={video.image || video.thumb}
blurHash={video.blurHash}
@ -534,6 +538,7 @@ export default function Content({ @@ -534,6 +538,7 @@ export default function Content({
src={audio.url}
className="mt-2"
mustLoad={mustLoadMedia}
authorPubkey={authorPubkey}
poster={audio.thumb}
blurHash={audio.blurHash}
/>
@ -546,6 +551,7 @@ export default function Content({ @@ -546,6 +551,7 @@ export default function Content({
url={url}
className="mt-2"
mustLoad={mustLoadMedia}
authorPubkey={authorPubkey}
/>
))}
@ -555,6 +561,7 @@ export default function Content({ @@ -555,6 +561,7 @@ export default function Content({
url={url}
className="mt-2"
mustLoad={mustLoadMedia}
authorPubkey={authorPubkey}
/>
))}
@ -564,6 +571,7 @@ export default function Content({ @@ -564,6 +571,7 @@ export default function Content({
url={url}
className="mt-2"
mustLoad={mustLoadMedia}
authorPubkey={authorPubkey}
/>
))}
@ -573,6 +581,7 @@ export default function Content({ @@ -573,6 +581,7 @@ export default function Content({
url={url}
className="mt-2"
mustLoad={mustLoadMedia}
authorPubkey={authorPubkey}
/>
))}
@ -613,6 +622,7 @@ export default function Content({ @@ -613,6 +622,7 @@ export default function Content({
key={index}
src={cleanedUrl}
mustLoad={mustLoadMedia}
authorPubkey={authorPubkey}
deferLoadUntilClick={deferLongVideoLoad}
poster={tagMediaInfo?.image || tagMediaInfo?.thumb}
blurHash={tagMediaInfo?.blurHash}
@ -642,6 +652,7 @@ export default function Content({ @@ -642,6 +652,7 @@ export default function Content({
key={`url-media-${index}`}
src={cleanedUrl}
mustLoad={mustLoadMedia}
authorPubkey={authorPubkey}
deferLoadUntilClick={deferLongVideoLoad}
poster={poster}
blurHash={mediaInfo?.blurHash}
@ -667,6 +678,7 @@ export default function Content({ @@ -667,6 +678,7 @@ export default function Content({
start={0}
end={1}
mustLoad={mustLoadMedia}
authorPubkey={authorPubkey}
/>
)
}
@ -729,6 +741,7 @@ export default function Content({ @@ -729,6 +741,7 @@ export default function Content({
url={node.data}
className="mt-2"
mustLoad={mustLoadMedia}
authorPubkey={authorPubkey}
/>
)
}
@ -739,6 +752,7 @@ export default function Content({ @@ -739,6 +752,7 @@ export default function Content({
url={node.data}
className="mt-2"
mustLoad={mustLoadMedia}
authorPubkey={authorPubkey}
/>
)
}
@ -749,6 +763,7 @@ export default function Content({ @@ -749,6 +763,7 @@ export default function Content({
url={node.data}
className="mt-2"
mustLoad={mustLoadMedia}
authorPubkey={authorPubkey}
/>
)
}
@ -759,6 +774,7 @@ export default function Content({ @@ -759,6 +774,7 @@ export default function Content({
url={node.data}
className="mt-2"
mustLoad={mustLoadMedia}
authorPubkey={authorPubkey}
/>
)
}
@ -781,7 +797,7 @@ export default function Content({ @@ -781,7 +797,7 @@ export default function Content({
<div className="space-y-3 mt-6 pt-4 border-t">
<h3 className="text-sm font-semibold text-muted-foreground mb-3">Links</h3>
{contentLinks.map((url, index) => (
<WebPreview key={`content-${index}-${url}`} url={url} className="w-full" />
<WebPreview key={`content-${index}-${url}`} url={url} className="w-full" authorPubkey={authorPubkey} />
))}
</div>
)}
@ -791,7 +807,7 @@ export default function Content({ @@ -791,7 +807,7 @@ export default function Content({
<div className="space-y-3 mt-6 pt-4 border-t">
<h3 className="text-sm font-semibold text-muted-foreground mb-3">Related Links</h3>
{tagLinks.map((url, index) => (
<WebPreview key={`tag-${index}-${url}`} url={url} className="w-full" />
<WebPreview key={`tag-${index}-${url}`} url={url} className="w-full" authorPubkey={authorPubkey} />
))}
</div>
)}

9
src/components/FountainEmbeddedPlayer/index.tsx

@ -8,7 +8,7 @@ import { @@ -8,7 +8,7 @@ import {
} from '@/lib/fountain-url'
import { cleanUrl } from '@/lib/url'
import { cn } from '@/lib/utils'
import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
import { useShouldAutoLoadMedia } from '@/hooks/useShouldAutoLoadMedia'
import { Skeleton } from '@/components/ui/skeleton'
import { useLayoutEffect, useMemo, useState } from 'react'
import LazyMediaTapPlaceholder from '../MediaPlayer/LazyMediaTapPlaceholder'
@ -68,14 +68,15 @@ const cardShell = (className?: string) => @@ -68,14 +68,15 @@ const cardShell = (className?: string) =>
export default function FountainEmbeddedPlayer({
url,
className,
mustLoad = false
mustLoad = false,
authorPubkey
}: {
url: string
className?: string
mustLoad?: boolean
authorPubkey?: string | null
}) {
const contentPolicy = useContentPolicyOptional()
const autoLoadMedia = contentPolicy?.autoLoadMedia ?? true
const autoLoadMedia = useShouldAutoLoadMedia(authorPubkey)
const [userClickedLoad, setUserClickedLoad] = useState(false)
const cleanedUrl = useMemo(() => cleanUrl(url) || url, [url])
const minHeight = useMemo(() => fountainEmbedMinHeight(cleanedUrl), [cleanedUrl])

9
src/components/Image/index.tsx

@ -22,7 +22,7 @@ import { @@ -22,7 +22,7 @@ import {
useRef,
useState
} from 'react'
import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
import { useShouldAutoLoadMedia } from '@/hooks/useShouldAutoLoadMedia'
import { useTranslation } from 'react-i18next'
/** Browsers often never fire `onError` for invalid URIs, ORB, or stalled fetches — this forces a visible error. */
@ -65,7 +65,7 @@ function extensionWithDotFromUrl(url: string): string { @@ -65,7 +65,7 @@ function extensionWithDotFromUrl(url: string): string {
}
export default function Image({
image: { url, blurHash, dim, alt: imetaAlt, fallback, size: fileSizeBytes, x: imetaHash },
image: { url, blurHash, dim, alt: imetaAlt, fallback, size: fileSizeBytes, x: imetaHash, pubkey },
alt,
className = '',
classNames = {},
@ -108,10 +108,9 @@ export default function Image({ @@ -108,10 +108,9 @@ export default function Image({
holdUntilClick?: boolean
}) {
const { t } = useTranslation()
const contentPolicy = useContentPolicyOptional()
const autoLoadForAuthor = useShouldAutoLoadMedia(pubkey)
/** Tap-to-load only if the parent asked and policy allows (or there is no policy — trust the parent). */
const effectiveHoldUntilClick =
holdUntilClick && (contentPolicy !== undefined ? !contentPolicy.autoLoadMedia : true)
const effectiveHoldUntilClick = holdUntilClick && !autoLoadForAuthor
const urlOk = !!url?.trim()
const [revealed, setRevealed] = useState(!effectiveHoldUntilClick)

9
src/components/ImageGallery/index.tsx

@ -1,7 +1,7 @@ @@ -1,7 +1,7 @@
import { randomString } from '@/lib/random'
import { cn } from '@/lib/utils'
import logger from '@/lib/logger'
import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
import { useShouldAutoLoadMedia } from '@/hooks/useShouldAutoLoadMedia'
import modalManager from '@/services/modal-manager.service'
import { TImetaInfo } from '@/types'
import { ReactNode, useEffect, useLayoutEffect, useMemo, useState } from 'react'
@ -23,17 +23,18 @@ export default function ImageGallery({ @@ -23,17 +23,18 @@ export default function ImageGallery({
images,
start = 0,
end = images.length,
mustLoad = false
mustLoad = false,
authorPubkey
}: {
className?: string
images: TImetaInfo[]
start?: number
end?: number
mustLoad?: boolean
authorPubkey?: string | null
}) {
const id = useMemo(() => `image-gallery-${randomString()}`, [])
const contentPolicy = useContentPolicyOptional()
const autoLoadMedia = contentPolicy?.autoLoadMedia ?? true
const autoLoadMedia = useShouldAutoLoadMedia(authorPubkey ?? images[start]?.pubkey)
const [index, setIndex] = useState(-1)
const [lightboxPortalActive, setLightboxPortalActive] = useState(false)

5
src/components/ImageWithLightbox/index.tsx

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
import { randomString } from '@/lib/random'
import { cn } from '@/lib/utils'
import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
import { useShouldAutoLoadMedia } from '@/hooks/useShouldAutoLoadMedia'
import modalManager from '@/services/modal-manager.service'
import { TImetaInfo } from '@/types'
import { useEffect, useMemo, useState } from 'react'
@ -28,8 +28,7 @@ export default function ImageWithLightbox({ @@ -28,8 +28,7 @@ export default function ImageWithLightbox({
mustLoad?: boolean
}) {
const id = useMemo(() => `image-with-lightbox-${randomString()}`, [])
const contentPolicy = useContentPolicyOptional()
const autoLoadMedia = contentPolicy?.autoLoadMedia ?? true
const autoLoadMedia = useShouldAutoLoadMedia(image.pubkey)
const [index, setIndex] = useState(-1)
const [lightboxPortalActive, setLightboxPortalActive] = useState(false)

15
src/components/MediaPlayer/index.tsx

@ -6,7 +6,7 @@ import { @@ -6,7 +6,7 @@ import {
resolvePrimalBlossomPlayableUrl
} from '@/lib/url'
import { cn } from '@/lib/utils'
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useShouldAutoLoadMedia } from '@/hooks/useShouldAutoLoadMedia'
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import AudioPlayer from '../AudioPlayer'
@ -48,6 +48,7 @@ export default function MediaPlayer({ @@ -48,6 +48,7 @@ export default function MediaPlayer({
className,
mustLoad = false,
deferLoadUntilClick = false,
authorPubkey,
poster,
blurHash,
fallbackPageUrl
@ -60,6 +61,8 @@ export default function MediaPlayer({ @@ -60,6 +61,8 @@ export default function MediaPlayer({
* placeholder. Used for NIP-71 long-form video events in feeds.
*/
deferLoadUntilClick?: boolean
/** Note author; when set, follow-only and related policies apply per author. */
authorPubkey?: string | null
poster?: string
/** NIP-94 / imeta blurhash for lazy placeholder when poster is missing */
blurHash?: string
@ -67,8 +70,8 @@ export default function MediaPlayer({ @@ -67,8 +70,8 @@ export default function MediaPlayer({
fallbackPageUrl?: string
}) {
const { t } = useTranslation()
const { autoLoadMedia } = useContentPolicy()
/** Tap-to-load when {@link autoLoadMedia} is off; cleared when policy switches back to never. */
const authorAutoLoad = useShouldAutoLoadMedia(authorPubkey)
/** Tap-to-load when auto-load is off for this author; cleared when policy switches back to never. */
const [userClickedLoad, setUserClickedLoad] = useState(false)
const [mediaType, setMediaType] = useState<MediaSurface>(null)
const [probeFailed, setProbeFailed] = useState(false)
@ -96,11 +99,11 @@ export default function MediaPlayer({ @@ -96,11 +99,11 @@ export default function MediaPlayer({
const effectiveMediaType = mediaType ?? urlEmbedSurfaceHint
const showEmbed =
mustLoad || (!deferLoadUntilClick && autoLoadMedia) || userClickedLoad
mustLoad || (!deferLoadUntilClick && authorAutoLoad) || userClickedLoad
useLayoutEffect(() => {
if (!autoLoadMedia) setUserClickedLoad(false)
}, [autoLoadMedia])
if (!authorAutoLoad) setUserClickedLoad(false)
}, [authorAutoLoad])
useEffect(() => {
readyOnceRef.current = false

12
src/components/Note/ArticleCardCoverImage.tsx

@ -1,5 +1,6 @@ @@ -1,5 +1,6 @@
import ContentImage from '@/components/Image'
import UserAvatar from '@/components/UserAvatar'
import { useShouldAutoLoadMedia } from '@/hooks/useShouldAutoLoadMedia'
import { cn } from '@/lib/utils'
import type { Event } from 'nostr-tools'
@ -9,19 +10,22 @@ import type { Event } from 'nostr-tools' @@ -9,19 +10,22 @@ import type { Event } from 'nostr-tools'
export default function ArticleCardCoverImage({
event,
imageUrl,
autoLoadMedia,
autoLoadMedia: autoLoadMediaProp,
layout,
hideImageIfError = false
}: {
event: Event
imageUrl?: string
autoLoadMedia: boolean
/** Deprecated: prefer per-author policy via {@link useShouldAutoLoadMedia}. Kept for callers that pass it. */
autoLoadMedia?: boolean
layout: 'stacked' | 'row'
/** Passed through to {@link ContentImage} when an `image` tag URL exists. */
hideImageIfError?: boolean
}) {
const autoLoadFromPolicy = useShouldAutoLoadMedia(event.pubkey)
const autoLoadMedia = autoLoadMediaProp ?? autoLoadFromPolicy
const trimmed = imageUrl?.trim()
if (trimmed && autoLoadMedia) {
if (trimmed) {
return (
<ContentImage
image={{ url: trimmed, pubkey: event.pubkey }}
@ -32,10 +36,10 @@ export default function ArticleCardCoverImage({ @@ -32,10 +36,10 @@ export default function ArticleCardCoverImage({
}
classNames={layout === 'row' ? { wrapper: 'w-auto max-w-[400px] shrink-0' } : undefined}
hideIfError={hideImageIfError}
holdUntilClick={!autoLoadMedia}
/>
)
}
if (trimmed) return null
return (
<div

10
src/components/Note/CommunityDefinition.tsx

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
import { useShouldAutoLoadMedia } from '@/hooks/useShouldAutoLoadMedia'
import { getCommunityDefinitionFromEvent } from '@/lib/event-metadata'
import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import ClientSelect from '../ClientSelect'
@ -12,8 +12,7 @@ export default function CommunityDefinition({ @@ -12,8 +12,7 @@ export default function CommunityDefinition({
event: Event
className?: string
}) {
const contentPolicy = useContentPolicyOptional()
const autoLoadMedia = contentPolicy?.autoLoadMedia ?? true
const autoLoadMedia = useShouldAutoLoadMedia(event.pubkey)
const metadata = useMemo(() => getCommunityDefinitionFromEvent(event), [event])
const communityNameComponent = (
@ -27,13 +26,14 @@ export default function CommunityDefinition({ @@ -27,13 +26,14 @@ export default function CommunityDefinition({
return (
<div className={className}>
<div className="flex gap-4">
{metadata.image && autoLoadMedia && (
{metadata.image ? (
<Image
image={{ url: metadata.image, pubkey: event.pubkey }}
className="aspect-square bg-foreground h-20"
hideIfError
holdUntilClick={!autoLoadMedia}
/>
)}
) : null}
<div className="flex-1 w-0 space-y-1">
{communityNameComponent}
{communityDescriptionComponent}

10
src/components/Note/GroupMetadata.tsx

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
import { useShouldAutoLoadMedia } from '@/hooks/useShouldAutoLoadMedia'
import { getGroupMetadataFromEvent } from '@/lib/event-metadata'
import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import ClientSelect from '../ClientSelect'
@ -14,8 +14,7 @@ export default function GroupMetadata({ @@ -14,8 +14,7 @@ export default function GroupMetadata({
originalNoteId?: string
className?: string
}) {
const contentPolicy = useContentPolicyOptional()
const autoLoadMedia = contentPolicy?.autoLoadMedia ?? true
const autoLoadMedia = useShouldAutoLoadMedia(event.pubkey)
const metadata = useMemo(() => getGroupMetadataFromEvent(event), [event])
const groupNameComponent = (
@ -29,13 +28,14 @@ export default function GroupMetadata({ @@ -29,13 +28,14 @@ export default function GroupMetadata({
return (
<div className={className}>
<div className="flex gap-4">
{metadata.picture && autoLoadMedia && (
{metadata.picture ? (
<Image
image={{ url: metadata.picture, pubkey: event.pubkey }}
className="aspect-square bg-foreground h-20"
hideIfError
holdUntilClick={!autoLoadMedia}
/>
)}
) : null}
<div className="flex-1 w-0 space-y-1">
{groupNameComponent}
{groupAboutComponent}

8
src/components/Note/LiveEvent.tsx

@ -9,7 +9,7 @@ import { @@ -9,7 +9,7 @@ import {
preferredLiveJoinUrlForEvent
} from '@/lib/live-activities'
import { cn } from '@/lib/utils'
import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
import { useShouldAutoLoadMedia } from '@/hooks/useShouldAutoLoadMedia'
import { useLiveActivitiesOptional } from '@/providers/useLiveActivities'
import { useScreenSizeOptional } from '@/providers/ScreenSizeProvider'
import { Event, kinds } from 'nostr-tools'
@ -26,8 +26,7 @@ export default function LiveEvent({ event, className }: { event: Event; classNam @@ -26,8 +26,7 @@ export default function LiveEvent({ event, className }: { event: Event; classNam
const liveActivities = useLiveActivitiesOptional()
const screenSize = useScreenSizeOptional()
const isSmallScreen = screenSize?.isSmallScreen ?? false
const contentPolicy = useContentPolicyOptional()
const autoLoadMedia = contentPolicy?.autoLoadMedia ?? true
const autoLoadMedia = useShouldAutoLoadMedia(event.pubkey)
const metadata = useMemo(() => getLiveEventMetadataFromEvent(event), [event])
const playback = useMemo(() => liveEventInlinePlaybackFromEvent(event), [event])
const joinUrl = useMemo(() => preferredLiveJoinUrlForEvent(event), [event])
@ -87,7 +86,7 @@ export default function LiveEvent({ event, className }: { event: Event; classNam @@ -87,7 +86,7 @@ export default function LiveEvent({ event, className }: { event: Event; classNam
<MarkdownArticle
event={summaryMarkdownEvent}
hideMetadata
lazyMedia={autoLoadMedia}
lazyMedia={!autoLoadMedia}
className="prose-sm max-w-none min-w-0 w-full"
/>
</div>
@ -168,6 +167,7 @@ export default function LiveEvent({ event, className }: { event: Event; classNam @@ -168,6 +167,7 @@ export default function LiveEvent({ event, className }: { event: Event; classNam
src={playback.src}
poster={inlinePlayerPoster}
className="w-full"
authorPubkey={event.pubkey}
fallbackPageUrl={zapStreamFallbackUrl ?? joinUrl ?? undefined}
/>
</div>

5
src/components/Note/LongFormCard.tsx

@ -2,7 +2,7 @@ import { cardEventBodyBlurb } from '@/lib/card-event-body-blurb' @@ -2,7 +2,7 @@ import { cardEventBodyBlurb } from '@/lib/card-event-body-blurb'
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata'
import { toNote, toNoteList } from '@/lib/link'
import { useSecondaryPageOptional } from '@/PageManager'
import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
import { useShouldAutoLoadMedia } from '@/hooks/useShouldAutoLoadMedia'
import { useScreenSizeOptional } from '@/providers/ScreenSizeProvider'
import { cn } from '@/lib/utils'
import { Event, kinds } from 'nostr-tools'
@ -31,8 +31,7 @@ export default function LongFormCard({ @@ -31,8 +31,7 @@ export default function LongFormCard({
const push = secondaryPage?.push ?? ((url: string) => {
window.location.href = url
})
const contentPolicy = useContentPolicyOptional()
const autoLoadMedia = contentPolicy?.autoLoadMedia ?? true
const autoLoadMedia = useShouldAutoLoadMedia(event.pubkey)
const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event])
const bodyBlurb = useMemo(() => cardEventBodyBlurb(event.content), [event.content])
const summaryText = (metadata.summary?.trim() || bodyBlurb).trim()

12
src/components/Note/MusicTrackNote.tsx

@ -7,7 +7,7 @@ import { @@ -7,7 +7,7 @@ import {
} from '@/lib/music-track'
import { primalR2aMirrorForBlossomPrimalUrl } from '@/lib/url'
import { cn } from '@/lib/utils'
import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
import { useShouldAutoLoadMedia } from '@/hooks/useShouldAutoLoadMedia'
import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
@ -42,8 +42,7 @@ export default function MusicTrackNote({ @@ -42,8 +42,7 @@ export default function MusicTrackNote({
className?: string
loadMedia?: boolean
}) {
const contentPolicy = useContentPolicyOptional()
const autoLoadMedia = contentPolicy?.autoLoadMedia ?? true
const autoLoadMedia = useShouldAutoLoadMedia(event.pubkey)
const mustLoad = loadMedia || autoLoadMedia
const { t } = useTranslation()
@ -107,7 +106,12 @@ export default function MusicTrackNote({ @@ -107,7 +106,12 @@ export default function MusicTrackNote({
<p className="mb-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
{t('Music video', { defaultValue: 'Music video' })}
</p>
<MediaPlayer src={track.videoUrl} className="w-full max-w-none" mustLoad={mustLoad} />
<MediaPlayer
src={track.videoUrl}
className="w-full max-w-none"
mustLoad={mustLoad}
authorPubkey={event.pubkey}
/>
</div>
) : null}
</div>

15
src/components/Note/PublicationCard.tsx

@ -3,7 +3,7 @@ import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' @@ -3,7 +3,7 @@ import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata'
import { toNote, toNoteList } from '@/lib/link'
import { cn } from '@/lib/utils'
import { useSecondaryPageOptional, useSmartNoteNavigationOptional } from '@/PageManager'
import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
import { useShouldAutoLoadMedia } from '@/hooks/useShouldAutoLoadMedia'
import { useScreenSizeOptional } from '@/providers/ScreenSizeProvider'
import { Event, kinds } from 'nostr-tools'
import { useMemo } from 'react'
@ -26,8 +26,7 @@ export default function PublicationCard({ @@ -26,8 +26,7 @@ export default function PublicationCard({
const { navigateToNote } = useSmartNoteNavigationOptional()
const secondaryPage = useSecondaryPageOptional()
const push = secondaryPage?.push ?? ((url: string) => { window.location.href = url })
const contentPolicy = useContentPolicyOptional()
const autoLoadMedia = contentPolicy?.autoLoadMedia ?? true
const autoLoadMedia = useShouldAutoLoadMedia(event.pubkey)
const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event])
const bodyBlurb = useMemo(() => cardEventBodyBlurb(event.content), [event.content])
const summaryText = (metadata.summary?.trim() || bodyBlurb).trim()
@ -93,13 +92,14 @@ export default function PublicationCard({ @@ -93,13 +92,14 @@ export default function PublicationCard({
)}
onClick={disableNavigation ? undefined : handleCardClick}
>
{metadata.image && autoLoadMedia && (
{metadata.image ? (
<Image
image={{ url: metadata.image, pubkey: event.pubkey }}
className="mb-3 aspect-video w-full max-w-full"
hideIfError
holdUntilClick={!autoLoadMedia}
/>
)}
) : null}
<div className="min-w-0 space-y-2 overflow-hidden">
{titleComponent}
{bookstrMetadataComponent}
@ -122,14 +122,15 @@ export default function PublicationCard({ @@ -122,14 +122,15 @@ export default function PublicationCard({
onClick={disableNavigation ? undefined : handleCardClick}
>
<div className="flex min-w-0 gap-4">
{metadata.image && autoLoadMedia && (
{metadata.image ? (
<Image
image={{ url: metadata.image, pubkey: event.pubkey }}
classNames={{ wrapper: 'w-auto max-w-[min(400px,42%)] shrink-0 xl:max-w-[400px]' }}
className="aspect-[4/3] h-44 max-h-44 w-auto max-w-[min(400px,42%)] min-w-0 shrink rounded-lg bg-foreground object-cover xl:aspect-video xl:max-w-[400px]"
hideIfError
holdUntilClick={!autoLoadMedia}
/>
)}
) : null}
<div className="min-h-0 min-w-[10rem] flex-1 basis-0 space-y-2 overflow-hidden">
{titleComponent}
{bookstrMetadataComponent}

5
src/components/Note/WikiCard.tsx

@ -2,7 +2,7 @@ import { cardEventBodyBlurb } from '@/lib/card-event-body-blurb' @@ -2,7 +2,7 @@ import { cardEventBodyBlurb } from '@/lib/card-event-body-blurb'
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata'
import { toNote, toNoteList } from '@/lib/link'
import { useSecondaryPageOptional } from '@/PageManager'
import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
import { useShouldAutoLoadMedia } from '@/hooks/useShouldAutoLoadMedia'
import { useScreenSizeOptional } from '@/providers/ScreenSizeProvider'
import { Event, kinds } from 'nostr-tools'
import { useMemo } from 'react'
@ -19,8 +19,7 @@ export default function WikiCard({ @@ -19,8 +19,7 @@ export default function WikiCard({
const isSmallScreen = screenSize?.isSmallScreen ?? false
const secondaryPage = useSecondaryPageOptional()
const push = secondaryPage?.push ?? ((url: string) => { window.location.href = url })
const contentPolicy = useContentPolicyOptional()
const autoLoadMedia = contentPolicy?.autoLoadMedia ?? true
const autoLoadMedia = useShouldAutoLoadMedia(event.pubkey)
const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event])
const bodyBlurb = useMemo(() => cardEventBodyBlurb(event.content), [event.content])
const summaryText = (metadata.summary?.trim() || bodyBlurb).trim()

3
src/components/Note/index.tsx

@ -24,6 +24,7 @@ import { @@ -24,6 +24,7 @@ import {
} from '@/hooks/useNotificationReactionDisplay'
import logger from '@/lib/logger'
import client from '@/services/client.service'
import { useShouldAutoLoadMedia } from '@/hooks/useShouldAutoLoadMedia'
import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
import { useMuteListOptional } from '@/contexts/mute-list-context'
import { muteSetHas } from '@/lib/mute-set'
@ -277,7 +278,7 @@ export default function Note({ @@ -277,7 +278,7 @@ export default function Note({
const parentFetchRelayHints = useMemo(() => relayHintsFromEventTags(event), [event])
const contentPolicy = useContentPolicyOptional()
const defaultShowNsfw = contentPolicy?.defaultShowNsfw ?? true
const autoLoadMedia = contentPolicy?.autoLoadMedia ?? true
const autoLoadMedia = useShouldAutoLoadMedia(event.pubkey)
const [showNsfw, setShowNsfw] = useState(false)
const muteList = useMuteListOptional()
const mutePubkeySet = muteList?.mutePubkeySet ?? new Set<string>()

9
src/components/SpotifyEmbeddedPlayer/index.tsx

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
import { useShouldAutoLoadMedia } from '@/hooks/useShouldAutoLoadMedia'
import { spotifyOpenUrlToEmbedSrc } from '@/lib/spotify-url'
import { cn } from '@/lib/utils'
import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
import { useLayoutEffect, useMemo, useState } from 'react'
import ExternalLink from '../ExternalLink'
import LazyMediaTapPlaceholder from '../MediaPlayer/LazyMediaTapPlaceholder'
@ -8,14 +8,15 @@ import LazyMediaTapPlaceholder from '../MediaPlayer/LazyMediaTapPlaceholder' @@ -8,14 +8,15 @@ import LazyMediaTapPlaceholder from '../MediaPlayer/LazyMediaTapPlaceholder'
export default function SpotifyEmbeddedPlayer({
url,
className,
mustLoad = false
mustLoad = false,
authorPubkey
}: {
url: string
className?: string
mustLoad?: boolean
authorPubkey?: string | null
}) {
const contentPolicy = useContentPolicyOptional()
const autoLoadMedia = contentPolicy?.autoLoadMedia ?? true
const autoLoadMedia = useShouldAutoLoadMedia(authorPubkey)
const [userClickedLoad, setUserClickedLoad] = useState(false)
const embedSrc = useMemo(() => spotifyOpenUrlToEmbedSrc(url), [url])
const showEmbed = mustLoad || autoLoadMedia || userClickedLoad

9
src/components/WavlakeEmbeddedPlayer/index.tsx

@ -3,8 +3,8 @@ import { @@ -3,8 +3,8 @@ import {
wavlakeEmbedMinHeight,
wavlakeOpenUrlToEmbedSrc
} from '@/lib/wavlake-url'
import { useShouldAutoLoadMedia } from '@/hooks/useShouldAutoLoadMedia'
import { cn } from '@/lib/utils'
import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
import { useLayoutEffect, useMemo, useState } from 'react'
import ExternalLink from '../ExternalLink'
import LazyMediaTapPlaceholder from '../MediaPlayer/LazyMediaTapPlaceholder'
@ -12,14 +12,15 @@ import LazyMediaTapPlaceholder from '../MediaPlayer/LazyMediaTapPlaceholder' @@ -12,14 +12,15 @@ import LazyMediaTapPlaceholder from '../MediaPlayer/LazyMediaTapPlaceholder'
export default function WavlakeEmbeddedPlayer({
url,
className,
mustLoad = false
mustLoad = false,
authorPubkey
}: {
url: string
className?: string
mustLoad?: boolean
authorPubkey?: string | null
}) {
const contentPolicy = useContentPolicyOptional()
const autoLoadMedia = contentPolicy?.autoLoadMedia ?? true
const autoLoadMedia = useShouldAutoLoadMedia(authorPubkey)
const [userClickedLoad, setUserClickedLoad] = useState(false)
const embedSrc = useMemo(() => wavlakeOpenUrlToEmbedSrc(url), [url])
const minHeight = useMemo(() => wavlakeEmbedMinHeight(url), [url])

14
src/components/WebPreview/index.tsx

@ -5,7 +5,7 @@ import { ExtendedKind } from '@/constants' @@ -5,7 +5,7 @@ import { ExtendedKind } from '@/constants'
import { getLongFormArticleMetadataFromEvent, dTagToTitleCase } from '@/lib/event-metadata'
import { extractBookMetadata } from '@/lib/bookstr-parser'
import { cn } from '@/lib/utils'
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useShouldAutoLoadMedia } from '@/hooks/useShouldAutoLoadMedia'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { Skeleton } from '@/components/ui/skeleton'
import { ExternalLink } from 'lucide-react'
@ -134,8 +134,16 @@ function getTitleWithFallbacks(event: Event | null, eventMetadata: { title?: str @@ -134,8 +134,16 @@ function getTitleWithFallbacks(event: Event | null, eventMetadata: { title?: str
return null
}
export default function WebPreview({ url, className }: { url: string; className?: string }) {
const { autoLoadMedia } = useContentPolicy()
export default function WebPreview({
url,
className,
authorPubkey
}: {
url: string
className?: string
authorPubkey?: string | null
}) {
const autoLoadMedia = useShouldAutoLoadMedia(authorPubkey)
const { isSmallScreen } = useScreenSize()
const cleanedUrl = useMemo(() => cleanUrl(url), [url])

9
src/components/YoutubeEmbeddedPlayer/index.tsx

@ -1,8 +1,8 @@ @@ -1,8 +1,8 @@
import { isImwaldElectron } from '@/lib/client-platform'
import { ensureYouTubeIframeApi } from '@/lib/youtube-iframe-api'
import { parseYoutubeUrl } from '@/lib/youtube-url'
import { useShouldAutoLoadMedia } from '@/hooks/useShouldAutoLoadMedia'
import { cn } from '@/lib/utils'
import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
import mediaManager from '@/services/media-manager.service'
import { YouTubePlayer } from '@/types/youtube'
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
@ -13,14 +13,15 @@ import logger from '@/lib/logger' @@ -13,14 +13,15 @@ import logger from '@/lib/logger'
export default function YoutubeEmbeddedPlayer({
url,
className,
mustLoad = false
mustLoad = false,
authorPubkey
}: {
url: string
className?: string
mustLoad?: boolean
authorPubkey?: string | null
}) {
const contentPolicy = useContentPolicyOptional()
const autoLoadMedia = contentPolicy?.autoLoadMedia ?? true
const autoLoadMedia = useShouldAutoLoadMedia(authorPubkey)
const [userClickedLoad, setUserClickedLoad] = useState(false)
const { videoId, isShort } = useMemo(() => parseYoutubeUrl(url), [url])
const [initSuccess, setInitSuccess] = useState(false)

1
src/constants.ts

@ -1146,6 +1146,7 @@ export const NOTIFICATION_LIST_STYLE = { @@ -1146,6 +1146,7 @@ export const NOTIFICATION_LIST_STYLE = {
} as const
export const MEDIA_AUTO_LOAD_POLICY = {
FOLLOWS_ONLY: 'follows-only',
ALWAYS: 'always',
WIFI_ONLY: 'wifi-only',
NEVER: 'never'

1
src/hooks/index.tsx

@ -1,3 +1,4 @@ @@ -1,3 +1,4 @@
export * from './useShouldAutoLoadMedia'
export * from './useSignGatedControl'
export * from './useRelayPageFeedPolicy'
export * from './useNearViewport'

34
src/hooks/useShouldAutoLoadMedia.ts

@ -0,0 +1,34 @@ @@ -0,0 +1,34 @@
import { getPubkeysFromPTags } from '@/lib/tag'
import { resolveAutoLoadMediaForAuthor } from '@/lib/media-auto-load-policy'
import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
import { useNostrOptional } from '@/providers/nostr-context'
import storage from '@/services/local-storage.service'
import { useMemo } from 'react'
export function useShouldAutoLoadMedia(authorPubkey?: string | null): boolean {
const contentPolicy = useContentPolicyOptional()
const nostr = useNostrOptional()
const followings = useMemo(
() => (nostr?.followListEvent ? getPubkeysFromPTags(nostr.followListEvent.tags) : []),
[nostr?.followListEvent]
)
return useMemo(
() =>
resolveAutoLoadMediaForAuthor({
policy: contentPolicy?.mediaAutoLoadPolicy ?? storage.getMediaAutoLoadPolicy(),
connectionType: contentPolicy?.connectionType,
authorPubkey,
followings,
accountPubkey: nostr?.pubkey ?? null
}),
[
contentPolicy?.mediaAutoLoadPolicy,
contentPolicy?.connectionType,
authorPubkey,
followings,
nostr?.pubkey
]
)
}

1
src/i18n/locales/en.ts

@ -1643,6 +1643,7 @@ export default { @@ -1643,6 +1643,7 @@ export default {
'POW {{difficulty}}': 'POW {{difficulty}}',
'via {{client}}': 'via {{client}}',
'Auto-load media': 'Auto-load media',
'Follows only': 'Follows only',
Always: 'Always',
'Wi-Fi only': 'Wi-Fi only',
Never: 'Never',

44
src/lib/media-auto-load-policy.ts

@ -0,0 +1,44 @@ @@ -0,0 +1,44 @@
import { MEDIA_AUTO_LOAD_POLICY } from '@/constants'
import type { TMediaAutoLoadPolicy } from '@/types'
export type TResolveAutoLoadMediaParams = {
policy: TMediaAutoLoadPolicy
connectionType?: string
authorPubkey?: string | null
followings?: readonly string[]
accountPubkey?: string | null
}
/** Whether media for a given author should load without an explicit tap. */
export function resolveAutoLoadMediaForAuthor({
policy,
connectionType,
authorPubkey,
followings = [],
accountPubkey
}: TResolveAutoLoadMediaParams): boolean {
if (policy === MEDIA_AUTO_LOAD_POLICY.NEVER) return false
if (policy === MEDIA_AUTO_LOAD_POLICY.ALWAYS) return true
if (policy === MEDIA_AUTO_LOAD_POLICY.WIFI_ONLY) {
return connectionType !== 'cellular'
}
if (policy === MEDIA_AUTO_LOAD_POLICY.FOLLOWS_ONLY) {
if (!authorPubkey) return false
if (accountPubkey && authorPubkey === accountPubkey) return true
return followings.includes(authorPubkey)
}
return false
}
/** Coarse global flag (false when the policy needs per-author resolution). */
export function resolveGlobalAutoLoadMedia(
policy: TMediaAutoLoadPolicy,
connectionType?: string
): boolean {
if (policy === MEDIA_AUTO_LOAD_POLICY.ALWAYS) return true
if (policy === MEDIA_AUTO_LOAD_POLICY.NEVER) return false
if (policy === MEDIA_AUTO_LOAD_POLICY.WIFI_ONLY) {
return connectionType !== 'cellular'
}
return false
}

5
src/pages/secondary/GeneralSettingsPage/index.tsx

@ -154,7 +154,7 @@ const GeneralSettingsPage = forwardRef(({ index, hideTitlebar = false }: { index @@ -154,7 +154,7 @@ const GeneralSettingsPage = forwardRef(({ index, hideTitlebar = false }: { index
{t('Auto-load media')}
</Label>
<Select
defaultValue="wifi-only"
defaultValue={MEDIA_AUTO_LOAD_POLICY.FOLLOWS_ONLY}
value={mediaAutoLoadPolicy}
onValueChange={(value: TMediaAutoLoadPolicy) =>
setMediaAutoLoadPolicy(value as TMediaAutoLoadPolicy)
@ -164,6 +164,9 @@ const GeneralSettingsPage = forwardRef(({ index, hideTitlebar = false }: { index @@ -164,6 +164,9 @@ const GeneralSettingsPage = forwardRef(({ index, hideTitlebar = false }: { index
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={MEDIA_AUTO_LOAD_POLICY.FOLLOWS_ONLY}>
{t('Follows only')}
</SelectItem>
<SelectItem value={MEDIA_AUTO_LOAD_POLICY.ALWAYS}>{t('Always')}</SelectItem>
{isSupportCheckConnectionType() && (
<SelectItem value={MEDIA_AUTO_LOAD_POLICY.WIFI_ONLY}>{t('Wi-Fi only')}</SelectItem>

22
src/providers/ContentPolicyProvider.tsx

@ -1,4 +1,4 @@ @@ -1,4 +1,4 @@
import { MEDIA_AUTO_LOAD_POLICY } from '@/constants'
import { resolveGlobalAutoLoadMedia } from '@/lib/media-auto-load-policy'
import storage from '@/services/local-storage.service'
import { TMediaAutoLoadPolicy } from '@/types'
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
@ -16,6 +16,8 @@ type TContentPolicyContext = { @@ -16,6 +16,8 @@ type TContentPolicyContext = {
autoLoadMedia: boolean
mediaAutoLoadPolicy: TMediaAutoLoadPolicy
setMediaAutoLoadPolicy: (policy: TMediaAutoLoadPolicy) => void
/** From the Network Information API when available (`undefined` on most desktop browsers). */
connectionType: string | undefined
/** True when `navigator.onLine` is false or the connection type is 'none'. */
isOffline: boolean
@ -68,18 +70,10 @@ export function ContentPolicyProvider({ children }: { children: React.ReactNode @@ -68,18 +70,10 @@ export function ContentPolicyProvider({ children }: { children: React.ReactNode
}
}, [])
const autoLoadMedia = useMemo(() => {
if (mediaAutoLoadPolicy === MEDIA_AUTO_LOAD_POLICY.ALWAYS) {
return true
}
if (mediaAutoLoadPolicy === MEDIA_AUTO_LOAD_POLICY.NEVER) {
return false
}
// 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 autoLoadMedia = useMemo(
() => resolveGlobalAutoLoadMedia(mediaAutoLoadPolicy, connectionType),
[mediaAutoLoadPolicy, connectionType]
)
const updateAutoplay = useCallback((autoplay: boolean) => {
storage.setAutoplay(autoplay)
@ -119,6 +113,7 @@ export function ContentPolicyProvider({ children }: { children: React.ReactNode @@ -119,6 +113,7 @@ export function ContentPolicyProvider({ children }: { children: React.ReactNode
autoLoadMedia,
mediaAutoLoadPolicy,
setMediaAutoLoadPolicy: updateMediaAutoLoadPolicy,
connectionType,
isOffline
}),
[
@ -131,6 +126,7 @@ export function ContentPolicyProvider({ children }: { children: React.ReactNode @@ -131,6 +126,7 @@ export function ContentPolicyProvider({ children }: { children: React.ReactNode
autoLoadMedia,
mediaAutoLoadPolicy,
updateMediaAutoLoadPolicy,
connectionType,
isOffline
]
)

2
src/services/local-storage.service.ts

@ -113,7 +113,7 @@ class LocalStorageService { @@ -113,7 +113,7 @@ class LocalStorageService {
private feedKindFilterBypass: boolean = false
private hideContentMentioningMutedUsers: boolean = false
private notificationListStyle: TNotificationStyle = NOTIFICATION_LIST_STYLE.DETAILED
private mediaAutoLoadPolicy: TMediaAutoLoadPolicy = MEDIA_AUTO_LOAD_POLICY.ALWAYS
private mediaAutoLoadPolicy: TMediaAutoLoadPolicy = MEDIA_AUTO_LOAD_POLICY.FOLLOWS_ONLY
private showRecommendedRelaysPanel: boolean = false
private shownCreateWalletGuideToastPubkeys: Set<string> = new Set()
private defaultExpirationEnabled: boolean = false

Loading…
Cancel
Save