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

9
src/components/FountainEmbeddedPlayer/index.tsx

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

9
src/components/Image/index.tsx

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

9
src/components/ImageGallery/index.tsx

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

5
src/components/ImageWithLightbox/index.tsx

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

15
src/components/MediaPlayer/index.tsx

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

12
src/components/Note/ArticleCardCoverImage.tsx

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

10
src/components/Note/CommunityDefinition.tsx

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

10
src/components/Note/GroupMetadata.tsx

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

8
src/components/Note/LiveEvent.tsx

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

5
src/components/Note/LongFormCard.tsx

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

12
src/components/Note/MusicTrackNote.tsx

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

15
src/components/Note/PublicationCard.tsx

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

5
src/components/Note/WikiCard.tsx

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

3
src/components/Note/index.tsx

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

9
src/components/SpotifyEmbeddedPlayer/index.tsx

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

9
src/components/WavlakeEmbeddedPlayer/index.tsx

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

14
src/components/WebPreview/index.tsx

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

9
src/components/YoutubeEmbeddedPlayer/index.tsx

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

1
src/constants.ts

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

1
src/hooks/index.tsx

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

34
src/hooks/useShouldAutoLoadMedia.ts

@ -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 {
'POW {{difficulty}}': 'POW {{difficulty}}', 'POW {{difficulty}}': 'POW {{difficulty}}',
'via {{client}}': 'via {{client}}', 'via {{client}}': 'via {{client}}',
'Auto-load media': 'Auto-load media', 'Auto-load media': 'Auto-load media',
'Follows only': 'Follows only',
Always: 'Always', Always: 'Always',
'Wi-Fi only': 'Wi-Fi only', 'Wi-Fi only': 'Wi-Fi only',
Never: 'Never', Never: 'Never',

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

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

22
src/providers/ContentPolicyProvider.tsx

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

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

@ -113,7 +113,7 @@ class LocalStorageService {
private feedKindFilterBypass: boolean = false private feedKindFilterBypass: boolean = false
private hideContentMentioningMutedUsers: boolean = false private hideContentMentioningMutedUsers: boolean = false
private notificationListStyle: TNotificationStyle = NOTIFICATION_LIST_STYLE.DETAILED 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 showRecommendedRelaysPanel: boolean = false
private shownCreateWalletGuideToastPubkeys: Set<string> = new Set() private shownCreateWalletGuideToastPubkeys: Set<string> = new Set()
private defaultExpirationEnabled: boolean = false private defaultExpirationEnabled: boolean = false

Loading…
Cancel
Save