feat: add auto-load media content setting option

This commit is contained in:
codytseng
2025-09-13 23:05:21 +08:00
parent 6d7ecfe2fd
commit f785d0d8a2
35 changed files with 458 additions and 105 deletions

View File

@@ -5,10 +5,10 @@ import { TImetaInfo } from '@/types'
import { getHashFromURL } from 'blossom-client-sdk' import { getHashFromURL } from 'blossom-client-sdk'
import { decode } from 'blurhash' import { decode } from 'blurhash'
import { ImageOff } from 'lucide-react' import { ImageOff } from 'lucide-react'
import { HTMLAttributes, useEffect, useState } from 'react' import { HTMLAttributes, useEffect, useMemo, useRef, useState } from 'react'
export default function Image({ export default function Image({
image: { url, blurHash, pubkey }, image: { url, blurHash, pubkey, dim },
alt, alt,
className = '', className = '',
classNames = {}, classNames = {},
@@ -26,8 +26,7 @@ export default function Image({
errorPlaceholder?: React.ReactNode errorPlaceholder?: React.ReactNode
}) { }) {
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [displayBlurHash, setDisplayBlurHash] = useState(true) const [displaySkeleton, setDisplaySkeleton] = useState(true)
const [blurDataUrl, setBlurDataUrl] = useState<string | null>(null)
const [hasError, setHasError] = useState(false) const [hasError, setHasError] = useState(false)
const [imageUrl, setImageUrl] = useState(url) const [imageUrl, setImageUrl] = useState(url)
const [tried, setTried] = useState(new Set()) const [tried, setTried] = useState(new Set())
@@ -36,32 +35,13 @@ export default function Image({
setImageUrl(url) setImageUrl(url)
setIsLoading(true) setIsLoading(true)
setHasError(false) setHasError(false)
setDisplayBlurHash(true) setDisplaySkeleton(true)
setTried(new Set()) setTried(new Set())
}, [url]) }, [url])
useEffect(() => {
if (blurHash) {
const { numX, numY } = decodeBlurHashSize(blurHash)
const width = numX * 3
const height = numY * 3
const pixels = decode(blurHash, width, height)
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
const ctx = canvas.getContext('2d')
if (ctx) {
const imageData = ctx.createImageData(width, height)
imageData.data.set(pixels)
ctx.putImageData(imageData, 0, 0)
setBlurDataUrl(canvas.toDataURL())
}
}
}, [blurHash])
if (hideIfError && hasError) return null if (hideIfError && hasError) return null
const handleImageError = async () => { const handleError = async () => {
let oldImageUrl: URL | undefined let oldImageUrl: URL | undefined
let hash: string | null = null let hash: string | null = null
try { try {
@@ -101,26 +81,52 @@ export default function Image({
setImageUrl(nextUrl.toString()) setImageUrl(nextUrl.toString())
} }
const handleLoad = () => {
setIsLoading(false)
setHasError(false)
setTimeout(() => setDisplaySkeleton(false), 600)
}
return ( return (
<div className={cn('relative', classNames.wrapper)} {...props}> <div className={cn('relative overflow-hidden', classNames.wrapper)} {...props}>
{isLoading && <Skeleton className={cn('absolute inset-0', className)} />} {displaySkeleton && (
{!hasError ? ( <div className="absolute inset-0 z-10">
{blurHash ? (
<BlurHashCanvas
blurHash={blurHash}
className={cn(
'absolute inset-0 transition-opacity duration-500 rounded-lg',
isLoading ? 'opacity-100' : 'opacity-0'
)}
/>
) : (
<Skeleton
className={cn(
'absolute inset-0 transition-opacity duration-500 rounded-lg',
isLoading ? 'opacity-100' : 'opacity-0'
)}
/>
)}
</div>
)}
{!hasError && (
<img <img
src={imageUrl} src={imageUrl}
alt={alt} alt={alt}
decoding="async"
loading="lazy"
onLoad={handleLoad}
onError={handleError}
className={cn( className={cn(
'object-cover transition-opacity duration-300', 'object-cover rounded-lg w-full h-full transition-opacity duration-500',
isLoading ? 'opacity-0' : 'opacity-100',
className className
)} )}
onLoad={() => { width={dim?.width}
setIsLoading(false) height={dim?.height}
setHasError(false) {...props}
setTimeout(() => setDisplayBlurHash(false), 500)
}}
onError={handleImageError}
/> />
) : ( )}
{hasError && (
<div <div
className={cn( className={cn(
'object-cover flex flex-col items-center justify-center w-full h-full bg-muted', 'object-cover flex flex-col items-center justify-center w-full h-full bg-muted',
@@ -131,21 +137,49 @@ export default function Image({
{errorPlaceholder} {errorPlaceholder}
</div> </div>
)} )}
{displayBlurHash && blurDataUrl && !hasError && (
<img
src={blurDataUrl}
className={cn('absolute inset-0 object-cover w-full h-full -z-10', className)}
alt={alt}
/>
)}
</div> </div>
) )
} }
const DIGITS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~' const blurHashWidth = 32
function decodeBlurHashSize(blurHash: string) { const blurHashHeight = 32
const sizeValue = DIGITS.indexOf(blurHash[0]) function BlurHashCanvas({ blurHash, className = '' }: { blurHash: string; className?: string }) {
const numY = (sizeValue / 9 + 1) | 0 const canvasRef = useRef<HTMLCanvasElement>(null)
const numX = (sizeValue % 9) + 1
return { numX, numY } const pixels = useMemo(() => {
if (!blurHash) return null
try {
return decode(blurHash, blurHashWidth, blurHashHeight)
} catch (error) {
console.warn('Failed to decode blurhash:', error)
return null
}
}, [blurHash])
useEffect(() => {
if (!pixels || !canvasRef.current) return
const canvas = canvasRef.current
const ctx = canvas.getContext('2d')
if (!ctx) return
const imageData = ctx.createImageData(blurHashWidth, blurHashHeight)
imageData.data.set(pixels)
ctx.putImageData(imageData, 0, 0)
}, [pixels])
if (!blurHash) return null
return (
<canvas
ref={canvasRef}
width={blurHashWidth}
height={blurHashHeight}
className={cn('w-full h-full object-cover rounded-lg', className)}
style={{
imageRendering: 'auto',
filter: 'blur(0.5px)'
}}
/>
)
} }

View File

@@ -1,5 +1,6 @@
import { randomString } from '@/lib/random' import { randomString } from '@/lib/random'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
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, useMemo, useState } from 'react' import { ReactNode, useEffect, useMemo, useState } from 'react'
@@ -7,6 +8,7 @@ import { createPortal } from 'react-dom'
import Lightbox from 'yet-another-react-lightbox' import Lightbox from 'yet-another-react-lightbox'
import Zoom from 'yet-another-react-lightbox/plugins/zoom' import Zoom from 'yet-another-react-lightbox/plugins/zoom'
import Image from '../Image' import Image from '../Image'
import ImageWithLightbox from '../ImageWithLightbox'
export default function ImageGallery({ export default function ImageGallery({
className, className,
@@ -20,6 +22,7 @@ export default function ImageGallery({
end?: number end?: number
}) { }) {
const id = useMemo(() => `image-gallery-${randomString()}`, []) const id = useMemo(() => `image-gallery-${randomString()}`, [])
const { autoLoadMedia } = useContentPolicy()
const [index, setIndex] = useState(-1) const [index, setIndex] = useState(-1)
useEffect(() => { useEffect(() => {
if (index >= 0) { if (index >= 0) {
@@ -38,12 +41,26 @@ export default function ImageGallery({
} }
const displayImages = images.slice(start, end) const displayImages = images.slice(start, end)
if (!autoLoadMedia) {
return displayImages.map((image, i) => (
<ImageWithLightbox
key={i}
image={image}
className="max-h-[80vh] sm:max-h-[50vh] object-contain"
classNames={{
wrapper: cn('w-fit max-w-full', className)
}}
/>
))
}
let imageContent: ReactNode | null = null let imageContent: ReactNode | null = null
if (displayImages.length === 1) { if (displayImages.length === 1) {
imageContent = ( imageContent = (
<Image <Image
key={0} key={0}
className="rounded-lg max-h-[80vh] sm:max-h-[50vh] border cursor-zoom-in" className="max-h-[80vh] sm:max-h-[50vh] cursor-zoom-in object-contain"
classNames={{ classNames={{
errorPlaceholder: 'aspect-square h-[30vh]' errorPlaceholder: 'aspect-square h-[30vh]'
}} }}
@@ -57,7 +74,7 @@ export default function ImageGallery({
{displayImages.map((image, i) => ( {displayImages.map((image, i) => (
<Image <Image
key={i} key={i}
className="aspect-square w-full rounded-lg border cursor-zoom-in" className="aspect-square w-full cursor-zoom-in"
image={image} image={image}
onClick={(e) => handlePhotoClick(e, i)} onClick={(e) => handlePhotoClick(e, i)}
/> />
@@ -70,7 +87,7 @@ export default function ImageGallery({
{displayImages.map((image, i) => ( {displayImages.map((image, i) => (
<Image <Image
key={i} key={i}
className="aspect-square w-full rounded-lg border cursor-zoom-in" className="aspect-square w-full cursor-zoom-in"
image={image} image={image}
onClick={(e) => handlePhotoClick(e, i)} onClick={(e) => handlePhotoClick(e, i)}
/> />

View File

@@ -1,21 +1,30 @@
import { randomString } from '@/lib/random' import { randomString } from '@/lib/random'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
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'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next'
import Lightbox from 'yet-another-react-lightbox' import Lightbox from 'yet-another-react-lightbox'
import Zoom from 'yet-another-react-lightbox/plugins/zoom' import Zoom from 'yet-another-react-lightbox/plugins/zoom'
import Image from '../Image' import Image from '../Image'
export default function ImageWithLightbox({ export default function ImageWithLightbox({
image, image,
className className,
classNames = {}
}: { }: {
image: TImetaInfo image: TImetaInfo
className?: string className?: string
classNames?: {
wrapper?: string
}
}) { }) {
const id = useMemo(() => `image-with-lightbox-${randomString()}`, []) const id = useMemo(() => `image-with-lightbox-${randomString()}`, [])
const { t } = useTranslation()
const { autoLoadMedia } = useContentPolicy()
const [display, setDisplay] = useState(autoLoadMedia)
const [index, setIndex] = useState(-1) const [index, setIndex] = useState(-1)
useEffect(() => { useEffect(() => {
if (index >= 0) { if (index >= 0) {
@@ -27,6 +36,20 @@ export default function ImageWithLightbox({
} }
}, [index]) }, [index])
if (!display) {
return (
<div
className="text-primary hover:underline truncate w-fit cursor-pointer"
onClick={(e) => {
e.stopPropagation()
setDisplay(true)
}}
>
[{t('Click to load image')}]
</div>
)
}
const handlePhotoClick = (event: React.MouseEvent) => { const handlePhotoClick = (event: React.MouseEvent) => {
event.stopPropagation() event.stopPropagation()
event.preventDefault() event.preventDefault()
@@ -34,11 +57,12 @@ export default function ImageWithLightbox({
} }
return ( return (
<div className="w-full"> <div>
<Image <Image
key={0} key={0}
className={cn('rounded-lg border cursor-zoom-in', className)} className={className}
classNames={{ classNames={{
wrapper: cn('rounded-lg border cursor-zoom-in', classNames.wrapper),
errorPlaceholder: 'aspect-square h-[30vh]' errorPlaceholder: 'aspect-square h-[30vh]'
}} }}
image={image} image={image}

View File

@@ -1,11 +1,28 @@
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import AudioPlayer from '../AudioPlayer' import AudioPlayer from '../AudioPlayer'
import VideoPlayer from '../VideoPlayer' import VideoPlayer from '../VideoPlayer'
export default function MediaPlayer({ src, className }: { src: string; className?: string }) { export default function MediaPlayer({ src, className }: { src: string; className?: string }) {
const { t } = useTranslation()
const { autoLoadMedia } = useContentPolicy()
const [display, setDisplay] = useState(autoLoadMedia)
const [mediaType, setMediaType] = useState<'video' | 'audio' | null>(null) const [mediaType, setMediaType] = useState<'video' | 'audio' | null>(null)
useEffect(() => { useEffect(() => {
if (autoLoadMedia) {
setDisplay(true)
} else {
setDisplay(false)
}
}, [autoLoadMedia])
useEffect(() => {
if (!display) {
setMediaType(null)
return
}
if (!src) { if (!src) {
setMediaType(null) setMediaType(null)
return return
@@ -35,7 +52,21 @@ export default function MediaPlayer({ src, className }: { src: string; className
return () => { return () => {
video.src = '' video.src = ''
} }
}, [src]) }, [src, display])
if (!display) {
return (
<div
className="text-primary hover:underline truncate w-fit cursor-pointer"
onClick={(e) => {
e.stopPropagation()
setDisplay(true)
}}
>
[{t('Click to load media')}]
</div>
)
}
if (!mediaType) { if (!mediaType) {
return null return null

View File

@@ -1,4 +1,5 @@
import { getCommunityDefinitionFromEvent } from '@/lib/event-metadata' import { getCommunityDefinitionFromEvent } from '@/lib/event-metadata'
import { useContentPolicy } 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'
@@ -11,6 +12,7 @@ export default function CommunityDefinition({
event: Event event: Event
className?: string className?: string
}) { }) {
const { autoLoadMedia } = useContentPolicy()
const metadata = useMemo(() => getCommunityDefinitionFromEvent(event), [event]) const metadata = useMemo(() => getCommunityDefinitionFromEvent(event), [event])
const communityNameComponent = ( const communityNameComponent = (
@@ -24,10 +26,10 @@ export default function CommunityDefinition({
return ( return (
<div className={className}> <div className={className}>
<div className="flex gap-4"> <div className="flex gap-4">
{metadata.image && ( {metadata.image && autoLoadMedia && (
<Image <Image
image={{ url: metadata.image, pubkey: event.pubkey }} image={{ url: metadata.image, pubkey: event.pubkey }}
className="rounded-lg aspect-square object-cover bg-foreground h-20" className="aspect-square bg-foreground h-20"
hideIfError hideIfError
/> />
)} )}

View File

@@ -1,4 +1,5 @@
import { getGroupMetadataFromEvent } from '@/lib/event-metadata' import { getGroupMetadataFromEvent } from '@/lib/event-metadata'
import { useContentPolicy } 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'
@@ -13,6 +14,7 @@ export default function GroupMetadata({
originalNoteId?: string originalNoteId?: string
className?: string className?: string
}) { }) {
const { autoLoadMedia } = useContentPolicy()
const metadata = useMemo(() => getGroupMetadataFromEvent(event), [event]) const metadata = useMemo(() => getGroupMetadataFromEvent(event), [event])
const groupNameComponent = ( const groupNameComponent = (
@@ -26,10 +28,10 @@ export default function GroupMetadata({
return ( return (
<div className={className}> <div className={className}>
<div className="flex gap-4"> <div className="flex gap-4">
{metadata.picture && ( {metadata.picture && autoLoadMedia && (
<Image <Image
image={{ url: metadata.picture, pubkey: event.pubkey }} image={{ url: metadata.picture, pubkey: event.pubkey }}
className="rounded-lg aspect-square object-cover bg-foreground h-20" className="aspect-square bg-foreground h-20"
hideIfError hideIfError
/> />
)} )}

View File

@@ -1,5 +1,6 @@
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { getLiveEventMetadataFromEvent } from '@/lib/event-metadata' import { getLiveEventMetadataFromEvent } from '@/lib/event-metadata'
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { useMemo } from 'react' import { useMemo } from 'react'
@@ -8,6 +9,8 @@ import Image from '../Image'
export default function LiveEvent({ event, className }: { event: Event; className?: string }) { export default function LiveEvent({ event, className }: { event: Event; className?: string }) {
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
const { autoLoadMedia } = useContentPolicy()
const metadata = useMemo(() => getLiveEventMetadataFromEvent(event), [event]) const metadata = useMemo(() => getLiveEventMetadataFromEvent(event), [event])
const liveStatusComponent = const liveStatusComponent =
@@ -39,10 +42,10 @@ export default function LiveEvent({ event, className }: { event: Event; classNam
if (isSmallScreen) { if (isSmallScreen) {
return ( return (
<div className={className}> <div className={className}>
{metadata.image && ( {metadata.image && autoLoadMedia && (
<Image <Image
image={{ url: metadata.image, pubkey: event.pubkey }} image={{ url: metadata.image, pubkey: event.pubkey }}
className="w-full aspect-video object-cover rounded-lg" className="w-full aspect-video"
hideIfError hideIfError
/> />
)} )}
@@ -60,10 +63,10 @@ export default function LiveEvent({ event, className }: { event: Event; classNam
return ( return (
<div className={className}> <div className={className}>
<div className="flex gap-4"> <div className="flex gap-4">
{metadata.image && ( {metadata.image && autoLoadMedia && (
<Image <Image
image={{ url: metadata.image, pubkey: event.pubkey }} image={{ url: metadata.image, pubkey: event.pubkey }}
className="rounded-lg aspect-[4/3] xl:aspect-video object-cover bg-foreground h-44" className="aspect-[4/3] xl:aspect-video bg-foreground h-44"
hideIfError hideIfError
/> />
)} )}

View File

@@ -63,7 +63,16 @@ export default function LongFormArticle({
}, },
p: (props) => <p {...props} className="break-words" />, p: (props) => <p {...props} className="break-words" />,
div: (props) => <div {...props} className="break-words" />, div: (props) => <div {...props} className="break-words" />,
code: (props) => <code {...props} className="break-words whitespace-pre-wrap" /> code: (props) => <code {...props} className="break-words whitespace-pre-wrap" />,
img: (props) => (
<ImageWithLightbox
image={{ url: props.src || '', pubkey: event.pubkey }}
className="max-h-[80vh] sm:max-h-[50vh] object-contain my-0"
classNames={{
wrapper: 'w-fit max-w-full'
}}
/>
)
}) as Components, }) as Components,
[] []
) )
@@ -81,7 +90,7 @@ export default function LongFormArticle({
{metadata.image && ( {metadata.image && (
<ImageWithLightbox <ImageWithLightbox
image={{ url: metadata.image, pubkey: event.pubkey }} image={{ url: metadata.image, pubkey: event.pubkey }}
className="w-full aspect-[3/1] object-cover rounded-lg" className="w-full aspect-[3/1] object-cover my-0"
/> />
)} )}
<Markdown <Markdown

View File

@@ -1,6 +1,7 @@
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata'
import { toNoteList } from '@/lib/link' import { toNoteList } from '@/lib/link'
import { useSecondaryPage } from '@/PageManager' import { useSecondaryPage } from '@/PageManager'
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { Event, kinds } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
import { useMemo } from 'react' import { useMemo } from 'react'
@@ -15,6 +16,7 @@ export default function LongFormArticlePreview({
}) { }) {
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
const { push } = useSecondaryPage() const { push } = useSecondaryPage()
const { autoLoadMedia } = useContentPolicy()
const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event]) const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event])
const titleComponent = <div className="text-xl font-semibold line-clamp-2">{metadata.title}</div> const titleComponent = <div className="text-xl font-semibold line-clamp-2">{metadata.title}</div>
@@ -43,10 +45,10 @@ export default function LongFormArticlePreview({
if (isSmallScreen) { if (isSmallScreen) {
return ( return (
<div className={className}> <div className={className}>
{metadata.image && ( {metadata.image && autoLoadMedia && (
<Image <Image
image={{ url: metadata.image, pubkey: event.pubkey }} image={{ url: metadata.image, pubkey: event.pubkey }}
className="w-full aspect-video object-cover rounded-lg" className="w-full aspect-video"
hideIfError hideIfError
/> />
)} )}
@@ -62,7 +64,7 @@ export default function LongFormArticlePreview({
return ( return (
<div className={className}> <div className={className}>
<div className="flex gap-4"> <div className="flex gap-4">
{metadata.image && ( {metadata.image && autoLoadMedia && (
<Image <Image
image={{ url: metadata.image, pubkey: event.pubkey }} image={{ url: metadata.image, pubkey: event.pubkey }}
className="rounded-lg aspect-[4/3] xl:aspect-video object-cover bg-foreground h-44" className="rounded-lg aspect-[4/3] xl:aspect-video object-cover bg-foreground h-44"

View File

@@ -1,6 +1,7 @@
import { generateImageByPubkey } from '@/lib/pubkey' import { generateImageByPubkey } from '@/lib/pubkey'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import Image from '../Image' import Image from '../Image'
import { cn } from '@/lib/utils'
export default function ProfileBanner({ export default function ProfileBanner({
pubkey, pubkey,
@@ -26,7 +27,7 @@ export default function ProfileBanner({
<Image <Image
image={{ url: bannerUrl, pubkey }} image={{ url: bannerUrl, pubkey }}
alt={`${pubkey} banner`} alt={`${pubkey} banner`}
className={className} className={cn('rounded-none', className)}
onError={() => setBannerUrl(defaultBanner)} onError={() => setBannerUrl(defaultBanner)}
/> />
) )

View File

@@ -1,10 +1,12 @@
import { useFetchWebMetadata } from '@/hooks/useFetchWebMetadata' import { useFetchWebMetadata } from '@/hooks/useFetchWebMetadata'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useMemo } from 'react' import { useMemo } from 'react'
import Image from '../Image' import Image from '../Image'
export default function WebPreview({ url, className }: { url: string; className?: string }) { export default function WebPreview({ url, className }: { url: string; className?: string }) {
const { autoLoadMedia } = useContentPolicy()
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
const { title, description, image } = useFetchWebMetadata(url) const { title, description, image } = useFetchWebMetadata(url)
@@ -16,6 +18,10 @@ export default function WebPreview({ url, className }: { url: string; className?
} }
}, [url]) }, [url])
if (!autoLoadMedia) {
return null
}
if (!title) { if (!title) {
return null return null
} }
@@ -49,7 +55,7 @@ export default function WebPreview({ url, className }: { url: string; className?
{image && ( {image && (
<Image <Image
image={{ url: image }} image={{ url: image }}
className="aspect-[4/3] xl:aspect-video object-cover bg-foreground h-44 rounded-none" className="aspect-[4/3] xl:aspect-video bg-foreground h-44 rounded-none"
hideIfError hideIfError
/> />
)} )}

View File

@@ -1,7 +1,9 @@
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useContentPolicy } 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, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
export default function YoutubeEmbeddedPlayer({ export default function YoutubeEmbeddedPlayer({
url, url,
@@ -10,13 +12,24 @@ export default function YoutubeEmbeddedPlayer({
url: string url: string
className?: string className?: string
}) { }) {
const { t } = useTranslation()
const { autoLoadMedia } = useContentPolicy()
const [display, setDisplay] = useState(autoLoadMedia)
const { videoId, isShort } = useMemo(() => parseYoutubeUrl(url), [url]) const { videoId, isShort } = useMemo(() => parseYoutubeUrl(url), [url])
const [initSuccess, setInitSuccess] = useState(false) const [initSuccess, setInitSuccess] = useState(false)
const playerRef = useRef<YouTubePlayer | null>(null) const playerRef = useRef<YouTubePlayer | null>(null)
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => { useEffect(() => {
if (!videoId || !containerRef.current) return if (autoLoadMedia) {
setDisplay(true)
} else {
setDisplay(false)
}
}, [autoLoadMedia])
useEffect(() => {
if (!videoId || !containerRef.current || !display) return
if (!window.YT) { if (!window.YT) {
const script = document.createElement('script') const script = document.createElement('script')
@@ -62,7 +75,21 @@ export default function YoutubeEmbeddedPlayer({
playerRef.current.destroy() playerRef.current.destroy()
} }
} }
}, [videoId]) }, [videoId, display])
if (!display) {
return (
<div
className="text-primary hover:underline truncate w-fit cursor-pointer"
onClick={(e) => {
e.stopPropagation()
setDisplay(true)
}}
>
[{t('Click to load YouTube video')}]
</div>
)
}
if (!videoId && !initSuccess) { if (!videoId && !initSuccess) {
return ( return (

View File

@@ -42,6 +42,7 @@ export const StorageKey = {
SHOW_KINDS_VERSION: 'showKindsVersion', SHOW_KINDS_VERSION: 'showKindsVersion',
HIDE_CONTENT_MENTIONING_MUTED_USERS: 'hideContentMentioningMutedUsers', HIDE_CONTENT_MENTIONING_MUTED_USERS: 'hideContentMentioningMutedUsers',
NOTIFICATION_LIST_STYLE: 'notificationListStyle', NOTIFICATION_LIST_STYLE: 'notificationListStyle',
MEDIA_AUTO_LOAD_POLICY: 'mediaAutoLoadPolicy',
MEDIA_UPLOAD_SERVICE: 'mediaUploadService', // deprecated MEDIA_UPLOAD_SERVICE: 'mediaUploadService', // deprecated
HIDE_UNTRUSTED_EVENTS: 'hideUntrustedEvents', // deprecated HIDE_UNTRUSTED_EVENTS: 'hideUntrustedEvents', // deprecated
ACCOUNT_RELAY_LIST_EVENT_MAP: 'accountRelayListEventMap', // deprecated ACCOUNT_RELAY_LIST_EVENT_MAP: 'accountRelayListEventMap', // deprecated
@@ -139,3 +140,9 @@ export const NOTIFICATION_LIST_STYLE = {
COMPACT: 'compact', COMPACT: 'compact',
DETAILED: 'detailed' DETAILED: 'detailed'
} as const } as const
export const MEDIA_AUTO_LOAD_POLICY = {
ALWAYS: 'always',
WIFI_ONLY: 'wifi-only',
NEVER: 'never'
} as const

View File

@@ -400,6 +400,12 @@ export default {
'Submit Relay': 'إرسال ريلاي', 'Submit Relay': 'إرسال ريلاي',
Homepage: 'الصفحة الرئيسية', Homepage: 'الصفحة الرئيسية',
'Proof of Work (difficulty {{minPow}})': 'إثبات العمل (الصعوبة {{minPow}})', 'Proof of Work (difficulty {{minPow}})': 'إثبات العمل (الصعوبة {{minPow}})',
'via {{client}}': 'عبر {{client}}' 'via {{client}}': 'عبر {{client}}',
'Auto-load media': 'تحميل الوسائط تلقائياً',
Always: 'دائماً',
'Wi-Fi only': 'Wi-Fi فقط',
Never: 'أبداً',
'Click to load image': 'انقر لتحميل الصورة',
'Click to load media': 'انقر لتحميل الوسائط'
} }
} }

View File

@@ -410,6 +410,12 @@ export default {
'Submit Relay': 'Relay einreichen', 'Submit Relay': 'Relay einreichen',
Homepage: 'Homepage', Homepage: 'Homepage',
'Proof of Work (difficulty {{minPow}})': 'Arbeitsnachweis (Schwierigkeit {{minPow}})', 'Proof of Work (difficulty {{minPow}})': 'Arbeitsnachweis (Schwierigkeit {{minPow}})',
'via {{client}}': 'über {{client}}' 'via {{client}}': 'über {{client}}',
'Auto-load media': 'Medien automatisch laden',
Always: 'Immer',
'Wi-Fi only': 'Nur WLAN',
Never: 'Nie',
'Click to load image': 'Klicken, um Bild zu laden',
'Click to load media': 'Klicken, um Medien zu laden'
} }
} }

View File

@@ -399,6 +399,12 @@ export default {
'Submit Relay': 'Submit Relay', 'Submit Relay': 'Submit Relay',
Homepage: 'Homepage', Homepage: 'Homepage',
'Proof of Work (difficulty {{minPow}})': 'Proof of Work (difficulty {{minPow}})', 'Proof of Work (difficulty {{minPow}})': 'Proof of Work (difficulty {{minPow}})',
'via {{client}}': 'via {{client}}' 'via {{client}}': 'via {{client}}',
'Auto-load media': 'Auto-load media',
Always: 'Always',
'Wi-Fi only': 'Wi-Fi only',
Never: 'Never',
'Click to load image': 'Click to load image',
'Click to load media': 'Click to load media'
} }
} }

View File

@@ -405,6 +405,12 @@ export default {
'Submit Relay': 'Enviar relé', 'Submit Relay': 'Enviar relé',
Homepage: 'Página principal', Homepage: 'Página principal',
'Proof of Work (difficulty {{minPow}})': 'Prueba de Trabajo (dificultad {{minPow}})', 'Proof of Work (difficulty {{minPow}})': 'Prueba de Trabajo (dificultad {{minPow}})',
'via {{client}}': 'vía {{client}}' 'via {{client}}': 'vía {{client}}',
'Auto-load media': 'Cargar medios automáticamente',
Always: 'Siempre',
'Wi-Fi only': 'Solo Wi-Fi',
Never: 'Nunca',
'Click to load image': 'Haz clic para cargar la imagen',
'Click to load media': 'Haz clic para cargar los medios'
} }
} }

View File

@@ -401,6 +401,12 @@ export default {
'Submit Relay': 'ارسال رله', 'Submit Relay': 'ارسال رله',
Homepage: 'صفحه اصلی', Homepage: 'صفحه اصلی',
'Proof of Work (difficulty {{minPow}})': 'اثبات کار (دشواری {{minPow}})', 'Proof of Work (difficulty {{minPow}})': 'اثبات کار (دشواری {{minPow}})',
'via {{client}}': 'از طریق {{client}}' 'via {{client}}': 'از طریق {{client}}',
'Auto-load media': 'بارگذاری خودکار رسانه',
Always: 'همیشه',
'Wi-Fi only': 'فقط Wi-Fi',
Never: 'هرگز',
'Click to load image': 'برای بارگذاری تصویر کلیک کنید',
'Click to load media': 'برای بارگذاری رسانه کلیک کنید'
} }
} }

View File

@@ -410,6 +410,12 @@ export default {
'Submit Relay': 'Soumettre un relais', 'Submit Relay': 'Soumettre un relais',
Homepage: 'Page daccueil', Homepage: 'Page daccueil',
'Proof of Work (difficulty {{minPow}})': 'Preuve de travail (difficulté {{minPow}})', 'Proof of Work (difficulty {{minPow}})': 'Preuve de travail (difficulté {{minPow}})',
'via {{client}}': 'via {{client}}' 'via {{client}}': 'via {{client}}',
'Auto-load media': 'Auto-chargement des médias',
Always: 'Toujours',
'Wi-Fi only': 'Wi-Fi uniquement',
Never: 'Jamais',
'Click to load image': 'Cliquez pour charger limage',
'Click to load media': 'Cliquez pour charger les médias'
} }
} }

View File

@@ -404,6 +404,12 @@ export default {
'Submit Relay': 'रिले सबमिट करें', 'Submit Relay': 'रिले सबमिट करें',
Homepage: 'होमपेज', Homepage: 'होमपेज',
'Proof of Work (difficulty {{minPow}})': 'कार्य प्रमाण (कठिनाई {{minPow}})', 'Proof of Work (difficulty {{minPow}})': 'कार्य प्रमाण (कठिनाई {{minPow}})',
'via {{client}}': 'के माध्यम से {{client}}' 'via {{client}}': 'के माध्यम से {{client}}',
'Auto-load media': 'मीडिया स्वतः लोड करें',
Always: 'हमेशा',
'Wi-Fi only': 'केवल Wi-Fi',
Never: 'कभी नहीं',
'Click to load image': 'इमेज लोड करने के लिए क्लिक करें',
'Click to load media': 'मीडिया लोड करने के लिए क्लिक करें'
} }
} }

View File

@@ -405,6 +405,12 @@ export default {
'Submit Relay': 'Invia Relay', 'Submit Relay': 'Invia Relay',
Homepage: 'Homepage', Homepage: 'Homepage',
'Proof of Work (difficulty {{minPow}})': 'Proof of Work (difficoltà {{minPow}})', 'Proof of Work (difficulty {{minPow}})': 'Proof of Work (difficoltà {{minPow}})',
'via {{client}}': 'tramite {{client}}' 'via {{client}}': 'tramite {{client}}',
'Auto-load media': 'Caricamento automatico media',
Always: 'Sempre',
'Wi-Fi only': 'Solo Wi-Fi',
Never: 'Mai',
'Click to load image': "Clicca per caricare l'immagine",
'Click to load media': 'Clicca per caricare i media'
} }
} }

View File

@@ -402,6 +402,12 @@ export default {
'Submit Relay': 'リレーを提出', 'Submit Relay': 'リレーを提出',
Homepage: 'ホームページ', Homepage: 'ホームページ',
'Proof of Work (difficulty {{minPow}})': 'プルーフオブワーク (難易度 {{minPow}})', 'Proof of Work (difficulty {{minPow}})': 'プルーフオブワーク (難易度 {{minPow}})',
'via {{client}}': '{{client}} 経由' 'via {{client}}': '{{client}} 経由',
'Auto-load media': 'メディアの自動読み込み',
Always: '常に',
'Wi-Fi only': 'Wi-Fiのみ',
Never: 'しない',
'Click to load image': 'クリックして画像を読み込む',
'Click to load media': 'クリックしてメディアを読み込む'
} }
} }

View File

@@ -402,6 +402,12 @@ export default {
'Submit Relay': '릴레이 제출', 'Submit Relay': '릴레이 제출',
Homepage: '홈페이지', Homepage: '홈페이지',
'Proof of Work (difficulty {{minPow}})': '작업 증명 (난이도 {{minPow}})', 'Proof of Work (difficulty {{minPow}})': '작업 증명 (난이도 {{minPow}})',
'via {{client}}': '{{client}} 통해' 'via {{client}}': '{{client}} 통해',
'Auto-load media': '미디어 자동 로드',
Always: '항상',
'Wi-Fi only': 'Wi-Fi만',
Never: '안함',
'Click to load image': '이미지 로드하려면 클릭',
'Click to load media': '미디어 로드하려면 클릭'
} }
} }

View File

@@ -406,6 +406,12 @@ export default {
'Submit Relay': 'Prześlij przekaźnik', 'Submit Relay': 'Prześlij przekaźnik',
Homepage: 'Strona główna', Homepage: 'Strona główna',
'Proof of Work (difficulty {{minPow}})': 'Dowód pracy (trudność {{minPow}})', 'Proof of Work (difficulty {{minPow}})': 'Dowód pracy (trudność {{minPow}})',
'via {{client}}': 'przez {{client}}' 'via {{client}}': 'przez {{client}}',
'Auto-load media': 'Automatyczne ładowanie mediów',
Always: 'Zawsze',
'Wi-Fi only': 'Tylko Wi-Fi',
Never: 'Nigdy',
'Click to load image': 'Kliknij, aby załadować obraz',
'Click to load media': 'Kliknij, aby załadować media'
} }
} }

View File

@@ -402,6 +402,12 @@ export default {
'Submit Relay': 'Enviar Relay', 'Submit Relay': 'Enviar Relay',
Homepage: 'Página inicial', Homepage: 'Página inicial',
'Proof of Work (difficulty {{minPow}})': 'Prova de Trabalho (dificuldade {{minPow}})', 'Proof of Work (difficulty {{minPow}})': 'Prova de Trabalho (dificuldade {{minPow}})',
'via {{client}}': 'via {{client}}' 'via {{client}}': 'via {{client}}',
'Auto-load media': 'Carregamento automático de mídia',
Always: 'Sempre',
'Wi-Fi only': 'Apenas Wi-Fi',
Never: 'Nunca',
'Click to load image': 'Clique para carregar a imagem',
'Click to load media': 'Clique para carregar a mídia'
} }
} }

View File

@@ -405,6 +405,12 @@ export default {
'Submit Relay': 'Enviar Relay', 'Submit Relay': 'Enviar Relay',
Homepage: 'Página inicial', Homepage: 'Página inicial',
'Proof of Work (difficulty {{minPow}})': 'Prova de Trabalho (dificuldade {{minPow}})', 'Proof of Work (difficulty {{minPow}})': 'Prova de Trabalho (dificuldade {{minPow}})',
'via {{client}}': 'via {{client}}' 'via {{client}}': 'via {{client}}',
'Auto-load media': 'Carregamento automático de multimédia',
Always: 'Sempre',
'Wi-Fi only': 'Apenas Wi-Fi',
Never: 'Nunca',
'Click to load image': 'Clique para carregar a imagem',
'Click to load media': 'Clique para carregar a mídia'
} }
} }

View File

@@ -407,6 +407,12 @@ export default {
'Submit Relay': 'Отправить релей', 'Submit Relay': 'Отправить релей',
Homepage: 'Домашняя страница', Homepage: 'Домашняя страница',
'Proof of Work (difficulty {{minPow}})': 'Доказательство работы (сложность {{minPow}})', 'Proof of Work (difficulty {{minPow}})': 'Доказательство работы (сложность {{minPow}})',
'via {{client}}': 'через {{client}}' 'via {{client}}': 'через {{client}}',
'Auto-load media': 'Автозагрузка медиа',
Always: 'Всегда',
'Wi-Fi only': 'Только Wi-Fi',
Never: 'Никогда',
'Click to load image': 'Нажмите, чтобы загрузить изображение',
'Click to load media': 'Нажмите, чтобы загрузить медиа'
} }
} }

View File

@@ -398,6 +398,12 @@ export default {
'Submit Relay': 'ส่งรีเลย์', 'Submit Relay': 'ส่งรีเลย์',
Homepage: 'หน้าแรก', Homepage: 'หน้าแรก',
'Proof of Work (difficulty {{minPow}})': 'หลักฐานการทำงาน (ความยาก {{minPow}})', 'Proof of Work (difficulty {{minPow}})': 'หลักฐานการทำงาน (ความยาก {{minPow}})',
'via {{client}}': 'ผ่าน {{client}}' 'via {{client}}': 'ผ่าน {{client}}',
'Auto-load media': 'โหลดสื่ออัตโนมัติ',
Always: 'เสมอ',
'Wi-Fi only': 'Wi-Fi เท่านั้น',
Never: 'ไม่เลย',
'Click to load image': 'คลิกเพื่อโหลดรูปภาพ',
'Click to load media': 'คลิกเพื่อโหลดสื่อ'
} }
} }

View File

@@ -396,6 +396,12 @@ export default {
'Submit Relay': '提交服务器', 'Submit Relay': '提交服务器',
Homepage: '主页', Homepage: '主页',
'Proof of Work (difficulty {{minPow}})': '工作量证明 (难度 {{minPow}})', 'Proof of Work (difficulty {{minPow}})': '工作量证明 (难度 {{minPow}})',
'via {{client}}': '来自 {{client}}' 'via {{client}}': '来自 {{client}}',
'Auto-load media': '自动加载媒体文件',
Always: '始终',
'Wi-Fi only': '仅WiFi',
Never: '从不',
'Click to load image': '点击加载图片',
'Click to load media': '点击加载音视频'
} }
} }

View File

@@ -61,6 +61,11 @@ export function isPartiallyInViewport(el: HTMLElement) {
) )
} }
export function isSupportCheckConnectionType() {
if (typeof window === 'undefined' || !(navigator as any).connection) return false
return typeof (navigator as any).connection.type === 'string'
}
export function isEmail(email: string) { export function isEmail(email: string) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
} }

View File

@@ -1,14 +1,15 @@
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select' import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select'
import { Switch } from '@/components/ui/switch' import { Switch } from '@/components/ui/switch'
import { NOTIFICATION_LIST_STYLE } from '@/constants' import { MEDIA_AUTO_LOAD_POLICY, NOTIFICATION_LIST_STYLE } from '@/constants'
import { LocalizedLanguageNames, TLanguage } from '@/i18n' import { LocalizedLanguageNames, TLanguage } from '@/i18n'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { cn } from '@/lib/utils' import { cn, isSupportCheckConnectionType } from '@/lib/utils'
import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useTheme } from '@/providers/ThemeProvider' import { useTheme } from '@/providers/ThemeProvider'
import { useUserPreferences } from '@/providers/UserPreferencesProvider' import { useUserPreferences } from '@/providers/UserPreferencesProvider'
import { useUserTrust } from '@/providers/UserTrustProvider' import { useUserTrust } from '@/providers/UserTrustProvider'
import { TMediaAutoLoadPolicy } from '@/types'
import { SelectValue } from '@radix-ui/react-select' import { SelectValue } from '@radix-ui/react-select'
import { ExternalLink } from 'lucide-react' import { ExternalLink } from 'lucide-react'
import { forwardRef, HTMLProps, useState } from 'react' import { forwardRef, HTMLProps, useState } from 'react'
@@ -24,7 +25,9 @@ const GeneralSettingsPage = forwardRef(({ index }: { index?: number }, ref) => {
defaultShowNsfw, defaultShowNsfw,
setDefaultShowNsfw, setDefaultShowNsfw,
hideContentMentioningMutedUsers, hideContentMentioningMutedUsers,
setHideContentMentioningMutedUsers setHideContentMentioningMutedUsers,
mediaAutoLoadPolicy,
setMediaAutoLoadPolicy
} = useContentPolicy() } = useContentPolicy()
const { hideUntrustedNotes, updateHideUntrustedNotes } = useUserTrust() const { hideUntrustedNotes, updateHideUntrustedNotes } = useUserTrust()
const { notificationListStyle, updateNotificationListStyle } = useUserPreferences() const { notificationListStyle, updateNotificationListStyle } = useUserPreferences()
@@ -92,6 +95,29 @@ const GeneralSettingsPage = forwardRef(({ index }: { index?: number }, ref) => {
</SelectContent> </SelectContent>
</Select> </Select>
</SettingItem> </SettingItem>
<SettingItem>
<Label htmlFor="media-auto-load-policy" className="text-base font-normal">
{t('Auto-load media')}
</Label>
<Select
defaultValue="wifi-only"
value={mediaAutoLoadPolicy}
onValueChange={(value: TMediaAutoLoadPolicy) =>
setMediaAutoLoadPolicy(value as TMediaAutoLoadPolicy)
}
>
<SelectTrigger id="media-auto-load-policy" className="w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={MEDIA_AUTO_LOAD_POLICY.ALWAYS}>{t('Always')}</SelectItem>
{isSupportCheckConnectionType() && (
<SelectItem value={MEDIA_AUTO_LOAD_POLICY.WIFI_ONLY}>{t('Wi-Fi only')}</SelectItem>
)}
<SelectItem value={MEDIA_AUTO_LOAD_POLICY.NEVER}>{t('Never')}</SelectItem>
</SelectContent>
</Select>
</SettingItem>
<SettingItem> <SettingItem>
<Label htmlFor="autoplay" className="text-base font-normal"> <Label htmlFor="autoplay" className="text-base font-normal">
<div>{t('Autoplay')}</div> <div>{t('Autoplay')}</div>

View File

@@ -131,11 +131,7 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
onUploadEnd={() => setUploadingBanner(false)} onUploadEnd={() => setUploadingBanner(false)}
className="w-full relative cursor-pointer" className="w-full relative cursor-pointer"
> >
<ProfileBanner <ProfileBanner banner={banner} pubkey={account.pubkey} className="w-full aspect-[3/1]" />
banner={banner}
pubkey={account.pubkey}
className="w-full aspect-[3/1] object-cover"
/>
<div className="absolute top-0 bg-muted/30 w-full h-full flex flex-col justify-center items-center"> <div className="absolute top-0 bg-muted/30 w-full h-full flex flex-col justify-center items-center">
{uploadingBanner ? <Loader size={36} className="animate-spin" /> : <Upload size={36} />} {uploadingBanner ? <Loader size={36} className="animate-spin" /> : <Upload size={36} />}
</div> </div>

View File

@@ -1,5 +1,7 @@
import { MEDIA_AUTO_LOAD_POLICY } from '@/constants'
import storage from '@/services/local-storage.service' import storage from '@/services/local-storage.service'
import { createContext, useContext, useState } from 'react' import { TMediaAutoLoadPolicy } from '@/types'
import { createContext, useContext, useEffect, useMemo, useState } from 'react'
type TContentPolicyContext = { type TContentPolicyContext = {
autoplay: boolean autoplay: boolean
@@ -10,6 +12,10 @@ type TContentPolicyContext = {
hideContentMentioningMutedUsers?: boolean hideContentMentioningMutedUsers?: boolean
setHideContentMentioningMutedUsers?: (hide: boolean) => void setHideContentMentioningMutedUsers?: (hide: boolean) => void
autoLoadMedia: boolean
mediaAutoLoadPolicy: TMediaAutoLoadPolicy
setMediaAutoLoadPolicy: (policy: TMediaAutoLoadPolicy) => void
} }
const ContentPolicyContext = createContext<TContentPolicyContext | undefined>(undefined) const ContentPolicyContext = createContext<TContentPolicyContext | undefined>(undefined)
@@ -23,11 +29,39 @@ export const useContentPolicy = () => {
} }
export function ContentPolicyProvider({ children }: { children: React.ReactNode }) { export function ContentPolicyProvider({ children }: { children: React.ReactNode }) {
const [autoplay, setAutoplay] = useState<boolean>(storage.getAutoplay()) const [autoplay, setAutoplay] = useState(storage.getAutoplay())
const [defaultShowNsfw, setDefaultShowNsfw] = useState<boolean>(storage.getDefaultShowNsfw()) const [defaultShowNsfw, setDefaultShowNsfw] = useState(storage.getDefaultShowNsfw())
const [hideContentMentioningMutedUsers, setHideContentMentioningMutedUsers] = useState<boolean>( const [hideContentMentioningMutedUsers, setHideContentMentioningMutedUsers] = useState(
storage.getHideContentMentioningMutedUsers() storage.getHideContentMentioningMutedUsers()
) )
const [mediaAutoLoadPolicy, setMediaAutoLoadPolicy] = useState(storage.getMediaAutoLoadPolicy())
const [connectionType, setConnectionType] = useState((navigator as any).connection?.type)
useEffect(() => {
const connection = (navigator as any).connection
if (!connection) {
setConnectionType(undefined)
return
}
const handleConnectionChange = () => {
setConnectionType(connection.type)
}
connection.addEventListener('change', handleConnectionChange)
return () => {
connection.removeEventListener('change', handleConnectionChange)
}
}, [])
const autoLoadMedia = useMemo(() => {
if (mediaAutoLoadPolicy === MEDIA_AUTO_LOAD_POLICY.ALWAYS) {
return true
}
if (mediaAutoLoadPolicy === MEDIA_AUTO_LOAD_POLICY.NEVER) {
return false
}
// WIFI_ONLY
return connectionType === 'wifi' || connectionType === 'ethernet'
}, [mediaAutoLoadPolicy, connectionType])
const updateAutoplay = (autoplay: boolean) => { const updateAutoplay = (autoplay: boolean) => {
storage.setAutoplay(autoplay) storage.setAutoplay(autoplay)
@@ -44,6 +78,11 @@ export function ContentPolicyProvider({ children }: { children: React.ReactNode
setHideContentMentioningMutedUsers(hide) setHideContentMentioningMutedUsers(hide)
} }
const updateMediaAutoLoadPolicy = (policy: TMediaAutoLoadPolicy) => {
storage.setMediaAutoLoadPolicy(policy)
setMediaAutoLoadPolicy(policy)
}
return ( return (
<ContentPolicyContext.Provider <ContentPolicyContext.Provider
value={{ value={{
@@ -52,7 +91,10 @@ export function ContentPolicyProvider({ children }: { children: React.ReactNode
defaultShowNsfw, defaultShowNsfw,
setDefaultShowNsfw: updateDefaultShowNsfw, setDefaultShowNsfw: updateDefaultShowNsfw,
hideContentMentioningMutedUsers, hideContentMentioningMutedUsers,
setHideContentMentioningMutedUsers: updateHideContentMentioningMutedUsers setHideContentMentioningMutedUsers: updateHideContentMentioningMutedUsers,
autoLoadMedia,
mediaAutoLoadPolicy,
setMediaAutoLoadPolicy: updateMediaAutoLoadPolicy
}} }}
> >
{children} {children}

View File

@@ -1,6 +1,7 @@
import { import {
DEFAULT_NIP_96_SERVICE, DEFAULT_NIP_96_SERVICE,
ExtendedKind, ExtendedKind,
MEDIA_AUTO_LOAD_POLICY,
NOTIFICATION_LIST_STYLE, NOTIFICATION_LIST_STYLE,
SUPPORTED_KINDS, SUPPORTED_KINDS,
StorageKey StorageKey
@@ -11,6 +12,7 @@ import {
TAccount, TAccount,
TAccountPointer, TAccountPointer,
TFeedInfo, TFeedInfo,
TMediaAutoLoadPolicy,
TMediaUploadServiceConfig, TMediaUploadServiceConfig,
TNoteListMode, TNoteListMode,
TNotificationStyle, TNotificationStyle,
@@ -44,6 +46,7 @@ class LocalStorageService {
private showKinds: number[] = [] private showKinds: number[] = []
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
constructor() { constructor() {
if (!LocalStorageService.instance) { if (!LocalStorageService.instance) {
@@ -174,6 +177,14 @@ class LocalStorageService {
? NOTIFICATION_LIST_STYLE.COMPACT ? NOTIFICATION_LIST_STYLE.COMPACT
: NOTIFICATION_LIST_STYLE.DETAILED : NOTIFICATION_LIST_STYLE.DETAILED
const mediaAutoLoadPolicy = window.localStorage.getItem(StorageKey.MEDIA_AUTO_LOAD_POLICY)
if (
mediaAutoLoadPolicy &&
Object.values(MEDIA_AUTO_LOAD_POLICY).includes(mediaAutoLoadPolicy as TMediaAutoLoadPolicy)
) {
this.mediaAutoLoadPolicy = mediaAutoLoadPolicy as TMediaAutoLoadPolicy
}
// Clean up deprecated data // Clean up deprecated data
window.localStorage.removeItem(StorageKey.ACCOUNT_PROFILE_EVENT_MAP) window.localStorage.removeItem(StorageKey.ACCOUNT_PROFILE_EVENT_MAP)
window.localStorage.removeItem(StorageKey.ACCOUNT_FOLLOW_LIST_EVENT_MAP) window.localStorage.removeItem(StorageKey.ACCOUNT_FOLLOW_LIST_EVENT_MAP)
@@ -433,6 +444,15 @@ class LocalStorageService {
this.notificationListStyle = style this.notificationListStyle = style
window.localStorage.setItem(StorageKey.NOTIFICATION_LIST_STYLE, style) window.localStorage.setItem(StorageKey.NOTIFICATION_LIST_STYLE, style)
} }
getMediaAutoLoadPolicy() {
return this.mediaAutoLoadPolicy
}
setMediaAutoLoadPolicy(policy: TMediaAutoLoadPolicy) {
this.mediaAutoLoadPolicy = policy
window.localStorage.setItem(StorageKey.MEDIA_AUTO_LOAD_POLICY, policy)
}
} }
const instance = new LocalStorageService() const instance = new LocalStorageService()

View File

@@ -1,5 +1,5 @@
import { Event, VerifiedEvent, Filter } from 'nostr-tools' import { Event, Filter, VerifiedEvent } from 'nostr-tools'
import { NOTIFICATION_LIST_STYLE, POLL_TYPE } from '../constants' import { MEDIA_AUTO_LOAD_POLICY, NOTIFICATION_LIST_STYLE, POLL_TYPE } from '../constants'
export type TSubRequestFilter = Omit<Filter, 'since' | 'until'> & { limit: number } export type TSubRequestFilter = Omit<Filter, 'since' | 'until'> & { limit: number }
@@ -186,3 +186,6 @@ export type TAwesomeRelayCollection = {
description: string description: string
relays: string[] relays: string[]
} }
export type TMediaAutoLoadPolicy =
(typeof MEDIA_AUTO_LOAD_POLICY)[keyof typeof MEDIA_AUTO_LOAD_POLICY]