Browse Source

make sure that media autoloading doesn't override content blocking

imwald
Silberengel 1 week ago
parent
commit
9a40e301a4
  1. 2
      src/components/Note/ArticleCardCoverImage.tsx
  2. 2
      src/components/Note/CommunityDefinition.tsx
  3. 2
      src/components/Note/GroupMetadata.tsx
  4. 2
      src/components/Note/LiveEvent.tsx
  5. 2
      src/components/Note/LongFormCard.tsx
  6. 3
      src/components/Note/MarkdownArticle/MarkdownArticle.tsx
  7. 2
      src/components/Note/MusicTrackNote.tsx
  8. 2
      src/components/Note/PublicationCard.tsx
  9. 2
      src/components/Note/WikiCard.tsx
  10. 6
      src/components/Note/index.tsx
  11. 10
      src/components/WebPreview/index.tsx
  12. 16
      src/hooks/useShouldAutoLoadMedia.ts
  13. 52
      src/lib/media-auto-load-policy.test.ts
  14. 8
      src/lib/media-auto-load-policy.ts
  15. 21
      src/providers/MediaAutoLoadEventContext.tsx

2
src/components/Note/ArticleCardCoverImage.tsx

@ -22,7 +22,7 @@ export default function ArticleCardCoverImage({
/** 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 autoLoadFromPolicy = useShouldAutoLoadMedia(event.pubkey, event)
const autoLoadMedia = autoLoadMediaProp ?? autoLoadFromPolicy const autoLoadMedia = autoLoadMediaProp ?? autoLoadFromPolicy
const trimmed = imageUrl?.trim() const trimmed = imageUrl?.trim()
if (trimmed) { if (trimmed) {

2
src/components/Note/CommunityDefinition.tsx

@ -12,7 +12,7 @@ export default function CommunityDefinition({
event: Event event: Event
className?: string className?: string
}) { }) {
const autoLoadMedia = useShouldAutoLoadMedia(event.pubkey) const autoLoadMedia = useShouldAutoLoadMedia(event.pubkey, event)
const metadata = useMemo(() => getCommunityDefinitionFromEvent(event), [event]) const metadata = useMemo(() => getCommunityDefinitionFromEvent(event), [event])
const communityNameComponent = ( const communityNameComponent = (

2
src/components/Note/GroupMetadata.tsx

@ -14,7 +14,7 @@ export default function GroupMetadata({
originalNoteId?: string originalNoteId?: string
className?: string className?: string
}) { }) {
const autoLoadMedia = useShouldAutoLoadMedia(event.pubkey) const autoLoadMedia = useShouldAutoLoadMedia(event.pubkey, event)
const metadata = useMemo(() => getGroupMetadataFromEvent(event), [event]) const metadata = useMemo(() => getGroupMetadataFromEvent(event), [event])
const groupNameComponent = ( const groupNameComponent = (

2
src/components/Note/LiveEvent.tsx

@ -26,7 +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 autoLoadMedia = useShouldAutoLoadMedia(event.pubkey) const autoLoadMedia = useShouldAutoLoadMedia(event.pubkey, event)
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])

2
src/components/Note/LongFormCard.tsx

@ -31,7 +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 autoLoadMedia = useShouldAutoLoadMedia(event.pubkey) const autoLoadMedia = useShouldAutoLoadMedia(event.pubkey, event)
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/MarkdownArticle/MarkdownArticle.tsx

@ -1,6 +1,7 @@
import { useSecondaryPageOptional, useSmartHashtagNavigationOptional, useSmartRelayNavigationOptional } from '@/PageManager' import { useSecondaryPageOptional, useSmartHashtagNavigationOptional, useSmartRelayNavigationOptional } from '@/PageManager'
import Image from '@/components/Image' import Image from '@/components/Image'
import UserAvatar from '@/components/UserAvatar' import UserAvatar from '@/components/UserAvatar'
import { MediaAutoLoadEventProvider } from '@/providers/MediaAutoLoadEventContext'
import MediaPlayer from '@/components/MediaPlayer' import MediaPlayer from '@/components/MediaPlayer'
import Wikilink from '@/components/UniversalContent/Wikilink' import Wikilink from '@/components/UniversalContent/Wikilink'
import { BookstrContent } from '@/components/Bookstr' import { BookstrContent } from '@/components/Bookstr'
@ -6192,6 +6193,7 @@ export default function MarkdownArticle({
}, [metadata.tags, hashtagsInContent]) }, [metadata.tags, hashtagsInContent])
return ( return (
<MediaAutoLoadEventProvider event={event}>
<> <>
<style>{` <style>{`
/* Padding (not margin) so separation does not collapse with the prior list's margin */ /* Padding (not margin) so separation does not collapse with the prior list's margin */
@ -6562,5 +6564,6 @@ export default function MarkdownArticle({
document.body document.body
)} )}
</> </>
</MediaAutoLoadEventProvider>
) )
} }

2
src/components/Note/MusicTrackNote.tsx

@ -42,7 +42,7 @@ export default function MusicTrackNote({
className?: string className?: string
loadMedia?: boolean loadMedia?: boolean
}) { }) {
const autoLoadMedia = useShouldAutoLoadMedia(event.pubkey) const autoLoadMedia = useShouldAutoLoadMedia(event.pubkey, event)
const mustLoad = loadMedia || autoLoadMedia const mustLoad = loadMedia || autoLoadMedia
const { t } = useTranslation() const { t } = useTranslation()

2
src/components/Note/PublicationCard.tsx

@ -26,7 +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 autoLoadMedia = useShouldAutoLoadMedia(event.pubkey) const autoLoadMedia = useShouldAutoLoadMedia(event.pubkey, event)
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()

2
src/components/Note/WikiCard.tsx

@ -19,7 +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 autoLoadMedia = useShouldAutoLoadMedia(event.pubkey) const autoLoadMedia = useShouldAutoLoadMedia(event.pubkey, event)
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()

6
src/components/Note/index.tsx

@ -278,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 = useShouldAutoLoadMedia(event.pubkey) const autoLoadMedia = useShouldAutoLoadMedia(event.pubkey, event)
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>()
@ -469,7 +469,7 @@ export default function Note({
> >
{href} {href}
</a> </a>
<WebPreview url={href} className="w-full" /> <WebPreview url={href} className="w-full" authorPubkey={event.pubkey} sourceEvent={event} />
</div> </div>
) : null} ) : null}
{displayEvent.content?.trim() ? renderEventContent({ hideMetadata: true }) : null} {displayEvent.content?.trim() ? renderEventContent({ hideMetadata: true }) : null}
@ -560,7 +560,7 @@ export default function Note({
<> <>
{voiceArticleUrl && ( {voiceArticleUrl && (
<div className="mt-2 not-prose max-w-full"> <div className="mt-2 not-prose max-w-full">
<WebPreview url={voiceArticleUrl} className="w-full" /> <WebPreview url={voiceArticleUrl} className="w-full" authorPubkey={event.pubkey} sourceEvent={event} />
</div> </div>
)} )}
<AudioPlayer className="mt-2" src={event.content} /> <AudioPlayer className="mt-2" src={event.content} />

10
src/components/WebPreview/index.tsx

@ -9,7 +9,7 @@ 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'
import { nip19, kinds } from 'nostr-tools' import { nip19, kinds, type Event } from 'nostr-tools'
import { useMemo, useEffect, useState } from 'react' import { useMemo, useEffect, useState } from 'react'
import Image from '../Image' import Image from '../Image'
import Username from '../Username' import Username from '../Username'
@ -17,7 +17,6 @@ import { resolveImwaldRouteSocialCopy } from '@/lib/document-meta'
import { cleanUrl, isSafeMediaUrl } from '@/lib/url' import { cleanUrl, isSafeMediaUrl } from '@/lib/url'
import { tagNameEquals } from '@/lib/tag' import { tagNameEquals } from '@/lib/tag'
import { queryService } from '@/services/client.service' import { queryService } from '@/services/client.service'
import { Event } from 'nostr-tools'
import { FAST_READ_RELAY_URLS } from '@/constants' import { FAST_READ_RELAY_URLS } from '@/constants'
import { getImetaInfosFromEvent } from '@/lib/event' import { getImetaInfosFromEvent } from '@/lib/event'
import MarkdownArticle from '../Note/MarkdownArticle/MarkdownArticle' import MarkdownArticle from '../Note/MarkdownArticle/MarkdownArticle'
@ -137,13 +136,16 @@ function getTitleWithFallbacks(event: Event | null, eventMetadata: { title?: str
export default function WebPreview({ export default function WebPreview({
url, url,
className, className,
authorPubkey authorPubkey,
sourceEvent
}: { }: {
url: string url: string
className?: string className?: string
authorPubkey?: string | null authorPubkey?: string | null
/** Note being rendered; content-warning tags block OG/image autoload. */
sourceEvent?: Event | null
}) { }) {
const autoLoadMedia = useShouldAutoLoadMedia(authorPubkey) const autoLoadMedia = useShouldAutoLoadMedia(authorPubkey, sourceEvent)
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
const cleanedUrl = useMemo(() => cleanUrl(url), [url]) const cleanedUrl = useMemo(() => cleanUrl(url), [url])

16
src/hooks/useShouldAutoLoadMedia.ts

@ -1,19 +1,27 @@
import { getPubkeysFromPTags } from '@/lib/tag' import { getPubkeysFromPTags } from '@/lib/tag'
import { resolveAutoLoadMediaForAuthor } from '@/lib/media-auto-load-policy' import { resolveAutoLoadMediaForAuthor } from '@/lib/media-auto-load-policy'
import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider' import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
import { useMediaAutoLoadSourceEvent } from '@/providers/MediaAutoLoadEventContext'
import { useNostrOptional } from '@/providers/nostr-context' import { useNostrOptional } from '@/providers/nostr-context'
import storage from '@/services/local-storage.service' import storage from '@/services/local-storage.service'
import type { Event } from 'nostr-tools'
import { useMemo } from 'react' import { useMemo } from 'react'
export function useShouldAutoLoadMedia(authorPubkey?: string | null): boolean { export function useShouldAutoLoadMedia(
authorPubkey?: string | null,
sourceEvent?: Event | null
): boolean {
const contentPolicy = useContentPolicyOptional() const contentPolicy = useContentPolicyOptional()
const nostr = useNostrOptional() const nostr = useNostrOptional()
const contextSourceEvent = useMediaAutoLoadSourceEvent()
const followings = useMemo( const followings = useMemo(
() => (nostr?.followListEvent ? getPubkeysFromPTags(nostr.followListEvent.tags) : []), () => (nostr?.followListEvent ? getPubkeysFromPTags(nostr.followListEvent.tags) : []),
[nostr?.followListEvent] [nostr?.followListEvent]
) )
const effectiveSourceEvent = sourceEvent ?? contextSourceEvent
return useMemo( return useMemo(
() => () =>
resolveAutoLoadMediaForAuthor({ resolveAutoLoadMediaForAuthor({
@ -21,14 +29,16 @@ export function useShouldAutoLoadMedia(authorPubkey?: string | null): boolean {
connectionType: contentPolicy?.connectionType, connectionType: contentPolicy?.connectionType,
authorPubkey, authorPubkey,
followings, followings,
accountPubkey: nostr?.pubkey ?? null accountPubkey: nostr?.pubkey ?? null,
sourceEvent: effectiveSourceEvent
}), }),
[ [
contentPolicy?.mediaAutoLoadPolicy, contentPolicy?.mediaAutoLoadPolicy,
contentPolicy?.connectionType, contentPolicy?.connectionType,
authorPubkey, authorPubkey,
followings, followings,
nostr?.pubkey nostr?.pubkey,
effectiveSourceEvent
] ]
) )
} }

52
src/lib/media-auto-load-policy.test.ts

@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest'
import { MEDIA_AUTO_LOAD_POLICY } from '@/constants'
import { resolveAutoLoadMediaForAuthor } from '@/lib/media-auto-load-policy'
import type { Event } from 'nostr-tools'
const author = 'aa'.repeat(32)
function warnedNote(): Event {
return {
id: 'id',
sig: 'sig',
kind: 1,
tags: [['content-warning', 'Sensitive content']],
content: 'hello',
created_at: 1,
pubkey: author
}
}
describe('resolveAutoLoadMediaForAuthor', () => {
it('blocks autoload for content-warning notes even when author is followed', () => {
expect(
resolveAutoLoadMediaForAuthor({
policy: MEDIA_AUTO_LOAD_POLICY.FOLLOWS_ONLY,
authorPubkey: author,
followings: [author],
sourceEvent: warnedNote()
})
).toBe(false)
})
it('allows autoload for followed authors on notes without content warnings', () => {
expect(
resolveAutoLoadMediaForAuthor({
policy: MEDIA_AUTO_LOAD_POLICY.FOLLOWS_ONLY,
authorPubkey: author,
followings: [author],
sourceEvent: { ...warnedNote(), tags: [] }
})
).toBe(true)
})
it('blocks autoload for content warnings under ALWAYS policy', () => {
expect(
resolveAutoLoadMediaForAuthor({
policy: MEDIA_AUTO_LOAD_POLICY.ALWAYS,
authorPubkey: author,
sourceEvent: warnedNote()
})
).toBe(false)
})
})

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

@ -1,5 +1,7 @@
import { MEDIA_AUTO_LOAD_POLICY } from '@/constants' import { MEDIA_AUTO_LOAD_POLICY } from '@/constants'
import { isNsfwEvent } from '@/lib/event'
import type { TMediaAutoLoadPolicy } from '@/types' import type { TMediaAutoLoadPolicy } from '@/types'
import type { Event } from 'nostr-tools'
export type TResolveAutoLoadMediaParams = { export type TResolveAutoLoadMediaParams = {
policy: TMediaAutoLoadPolicy policy: TMediaAutoLoadPolicy
@ -7,6 +9,8 @@ export type TResolveAutoLoadMediaParams = {
authorPubkey?: string | null authorPubkey?: string | null
followings?: readonly string[] followings?: readonly string[]
accountPubkey?: string | null accountPubkey?: string | null
/** When set, NIP-36 / legacy NSFW tags block automatic media load (tap-to-reveal still works). */
sourceEvent?: Event | null
} }
/** Whether media for a given author should load without an explicit tap. */ /** Whether media for a given author should load without an explicit tap. */
@ -15,8 +19,10 @@ export function resolveAutoLoadMediaForAuthor({
connectionType, connectionType,
authorPubkey, authorPubkey,
followings = [], followings = [],
accountPubkey accountPubkey,
sourceEvent
}: TResolveAutoLoadMediaParams): boolean { }: TResolveAutoLoadMediaParams): boolean {
if (sourceEvent && isNsfwEvent(sourceEvent)) return false
if (policy === MEDIA_AUTO_LOAD_POLICY.NEVER) return false if (policy === MEDIA_AUTO_LOAD_POLICY.NEVER) return false
if (policy === MEDIA_AUTO_LOAD_POLICY.ALWAYS) return true if (policy === MEDIA_AUTO_LOAD_POLICY.ALWAYS) return true
if (policy === MEDIA_AUTO_LOAD_POLICY.WIFI_ONLY) { if (policy === MEDIA_AUTO_LOAD_POLICY.WIFI_ONLY) {

21
src/providers/MediaAutoLoadEventContext.tsx

@ -0,0 +1,21 @@
import type { Event } from 'nostr-tools'
import { createContext, useContext } from 'react'
const MediaAutoLoadEventContext = createContext<Event | null>(null)
/** Supplies the note/event being rendered so media policy can respect content warnings. */
export function MediaAutoLoadEventProvider({
event,
children
}: {
event: Event
children: React.ReactNode
}) {
return (
<MediaAutoLoadEventContext.Provider value={event}>{children}</MediaAutoLoadEventContext.Provider>
)
}
export function useMediaAutoLoadSourceEvent(): Event | null {
return useContext(MediaAutoLoadEventContext)
}
Loading…
Cancel
Save