feat: blurhash
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
import { URL_REGEX } from '@/constants'
|
||||
import { isNsfwEvent, isPictureEvent } from '@/lib/event'
|
||||
import { extractImetaUrlFromTag } from '@/lib/tag'
|
||||
import { extractImageInfoFromTag } from '@/lib/tag'
|
||||
import { isImage, isVideo } from '@/lib/url'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { TImageInfo } from '@/types'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { memo } from 'react'
|
||||
import {
|
||||
@@ -16,7 +18,6 @@ import {
|
||||
import ImageGallery from '../ImageGallery'
|
||||
import VideoPlayer from '../VideoPlayer'
|
||||
import WebPreview from '../WebPreview'
|
||||
import { URL_REGEX } from '@/constants'
|
||||
|
||||
const Content = memo(
|
||||
({
|
||||
@@ -104,13 +105,13 @@ function preprocess(event: Event) {
|
||||
let lastNonMediaUrl: string | undefined
|
||||
|
||||
let c = content
|
||||
const images: string[] = []
|
||||
const imageUrls: string[] = []
|
||||
const videos: string[] = []
|
||||
|
||||
urls.forEach((url) => {
|
||||
if (isImage(url)) {
|
||||
c = c.replace(url, '').trim()
|
||||
images.push(url)
|
||||
imageUrls.push(url)
|
||||
} else if (isVideo(url)) {
|
||||
c = c.replace(url, '').trim()
|
||||
videos.push(url)
|
||||
@@ -119,14 +120,15 @@ function preprocess(event: Event) {
|
||||
}
|
||||
})
|
||||
|
||||
if (isPictureEvent(event)) {
|
||||
event.tags.forEach((tag) => {
|
||||
const imageUrl = extractImetaUrlFromTag(tag)
|
||||
if (imageUrl) {
|
||||
images.push(imageUrl)
|
||||
}
|
||||
})
|
||||
}
|
||||
const imageInfos = event.tags
|
||||
.map((tag) => extractImageInfoFromTag(tag))
|
||||
.filter(Boolean) as TImageInfo[]
|
||||
const images = isPictureEvent(event)
|
||||
? imageInfos
|
||||
: imageUrls.map((url) => {
|
||||
const imageInfo = imageInfos.find((info) => info.url === url)
|
||||
return imageInfo ?? { url }
|
||||
})
|
||||
|
||||
const embeddedNotes: string[] = []
|
||||
const embeddedNoteRegex = /nostr:(note1[a-z0-9]{58}|nevent1[a-z0-9]+|naddr1[a-z0-9]+)/g
|
||||
|
||||
@@ -1,35 +1,92 @@
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { HTMLAttributes, useState } from 'react'
|
||||
import { TImageInfo } from '@/types'
|
||||
import { decode } from 'blurhash'
|
||||
import { HTMLAttributes, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
export default function Image({
|
||||
src,
|
||||
image: { url, blurHash, dim },
|
||||
alt,
|
||||
className = '',
|
||||
classNames = {},
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement> & {
|
||||
src: string
|
||||
image: TImageInfo
|
||||
alt?: string
|
||||
classNames?: {
|
||||
wrapper?: string
|
||||
}
|
||||
}) {
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [displayBlurHash, setDisplayBlurHash] = useState(true)
|
||||
const [blurDataUrl, setBlurDataUrl] = useState<string | null>(null)
|
||||
const { width, height } = useMemo<{ width?: number; height?: number }>(() => {
|
||||
if (dim) {
|
||||
return dim
|
||||
}
|
||||
if (blurHash) {
|
||||
const { numX, numY } = decodeBlurHashSize(blurHash)
|
||||
return { width: numX * 10, height: numY * 10 }
|
||||
}
|
||||
return {}
|
||||
}, [dim])
|
||||
|
||||
useEffect(() => {
|
||||
if (blurHash) {
|
||||
const pixels = decode(blurHash, 32, 32)
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = 32
|
||||
canvas.height = 32
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (ctx) {
|
||||
const imageData = ctx.createImageData(32, 32)
|
||||
imageData.data.set(pixels)
|
||||
ctx.putImageData(imageData, 0, 0)
|
||||
setBlurDataUrl(canvas.toDataURL())
|
||||
}
|
||||
}
|
||||
}, [blurHash])
|
||||
|
||||
return (
|
||||
<div className={cn('relative', classNames.wrapper ?? '')} {...props}>
|
||||
{isLoading && <Skeleton className={cn('absolute inset-0', className)} />}
|
||||
<img
|
||||
src={src}
|
||||
src={url}
|
||||
alt={alt}
|
||||
className={cn(
|
||||
'object-cover transition-opacity duration-700',
|
||||
isLoading ? 'opacity-0' : 'opacity-100',
|
||||
className
|
||||
)}
|
||||
onLoad={() => setIsLoading(false)}
|
||||
onLoad={() => {
|
||||
setIsLoading(false)
|
||||
setTimeout(() => setDisplayBlurHash(false), 1000)
|
||||
}}
|
||||
/>
|
||||
{displayBlurHash && blurDataUrl && (
|
||||
<img
|
||||
src={blurDataUrl}
|
||||
className={cn('absolute inset-0 object-cover -z-10', className)}
|
||||
alt={alt}
|
||||
width={width}
|
||||
height={height}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const DIGITS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~'
|
||||
function decodeBlurHashSize(blurHash: string) {
|
||||
const sizeFlag = blurHash.charAt(0)
|
||||
|
||||
const sizeValue = DIGITS.indexOf(sizeFlag)
|
||||
|
||||
const numY = Math.floor(sizeValue / 9) + 1
|
||||
const numX = (sizeValue % 9) + 1
|
||||
|
||||
return {
|
||||
numX,
|
||||
numY
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import { Carousel, CarouselContent, CarouselItem } from '@/components/ui/carousel'
|
||||
import { TImageInfo } from '@/types'
|
||||
import { useState } from 'react'
|
||||
import Lightbox from 'yet-another-react-lightbox'
|
||||
import Zoom from 'yet-another-react-lightbox/plugins/zoom'
|
||||
import Image from '../Image'
|
||||
import NsfwOverlay from '../NsfwOverlay'
|
||||
|
||||
export function ImageCarousel({ images, isNsfw = false }: { images: string[]; isNsfw?: boolean }) {
|
||||
export function ImageCarousel({
|
||||
images,
|
||||
isNsfw = false
|
||||
}: {
|
||||
images: TImageInfo[]
|
||||
isNsfw?: boolean
|
||||
}) {
|
||||
const [index, setIndex] = useState(-1)
|
||||
|
||||
const handlePhotoClick = (event: React.MouseEvent, current: number) => {
|
||||
@@ -17,16 +24,16 @@ export function ImageCarousel({ images, isNsfw = false }: { images: string[]; is
|
||||
<>
|
||||
<Carousel className="w-full">
|
||||
<CarouselContent>
|
||||
{images.map((url, index) => (
|
||||
{images.map((image, index) => (
|
||||
<CarouselItem key={index}>
|
||||
<Image src={url} onClick={(e) => handlePhotoClick(e, index)} />
|
||||
<Image image={image} onClick={(e) => handlePhotoClick(e, index)} />
|
||||
</CarouselItem>
|
||||
))}
|
||||
</CarouselContent>
|
||||
</Carousel>
|
||||
<Lightbox
|
||||
index={index}
|
||||
slides={images.map((src) => ({ src }))}
|
||||
slides={images.map(({ url }) => ({ src: url }))}
|
||||
plugins={[Zoom]}
|
||||
open={index >= 0}
|
||||
close={() => setIndex(-1)}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import { TImageInfo } from '@/types'
|
||||
import { ReactNode, useState } from 'react'
|
||||
import Lightbox from 'yet-another-react-lightbox'
|
||||
import Zoom from 'yet-another-react-lightbox/plugins/zoom'
|
||||
@@ -13,7 +14,7 @@ export default function ImageGallery({
|
||||
size = 'normal'
|
||||
}: {
|
||||
className?: string
|
||||
images: string[]
|
||||
images: TImageInfo[]
|
||||
isNsfw?: boolean
|
||||
size?: 'normal' | 'small'
|
||||
}) {
|
||||
@@ -30,20 +31,20 @@ export default function ImageGallery({
|
||||
if (images.length === 1) {
|
||||
imageContent = (
|
||||
<Image
|
||||
key={index}
|
||||
key={0}
|
||||
className={cn('rounded-lg cursor-pointer', size === 'small' ? 'h-[15vh]' : 'h-[30vh]')}
|
||||
src={images[0]}
|
||||
image={images[0]}
|
||||
onClick={(e) => handlePhotoClick(e, 0)}
|
||||
/>
|
||||
)
|
||||
} else if (size === 'small') {
|
||||
imageContent = (
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{images.map((src, i) => (
|
||||
{images.map((image, i) => (
|
||||
<Image
|
||||
key={i}
|
||||
className="rounded-lg cursor-pointer aspect-square"
|
||||
src={src}
|
||||
className="rounded-lg cursor-pointer aspect-square w-full"
|
||||
image={image}
|
||||
onClick={(e) => handlePhotoClick(e, i)}
|
||||
/>
|
||||
))}
|
||||
@@ -52,11 +53,11 @@ export default function ImageGallery({
|
||||
} else if (isSmallScreen && (images.length === 2 || images.length === 4)) {
|
||||
imageContent = (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{images.map((src, i) => (
|
||||
{images.map((image, i) => (
|
||||
<Image
|
||||
key={i}
|
||||
className="rounded-lg cursor-pointer aspect-square"
|
||||
src={src}
|
||||
className="rounded-lg cursor-pointer aspect-square w-full"
|
||||
image={image}
|
||||
onClick={(e) => handlePhotoClick(e, i)}
|
||||
/>
|
||||
))}
|
||||
@@ -65,11 +66,11 @@ export default function ImageGallery({
|
||||
} else {
|
||||
imageContent = (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{images.map((src, i) => (
|
||||
{images.map((image, i) => (
|
||||
<Image
|
||||
key={i}
|
||||
className="rounded-lg cursor-pointer aspect-square"
|
||||
src={src}
|
||||
className="rounded-lg cursor-pointer aspect-square w-full"
|
||||
image={image}
|
||||
onClick={(e) => handlePhotoClick(e, i)}
|
||||
/>
|
||||
))}
|
||||
@@ -83,7 +84,7 @@ export default function ImageGallery({
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<Lightbox
|
||||
index={index}
|
||||
slides={images.map((src) => ({ src }))}
|
||||
slides={images.map(({ url }) => ({ src: url }))}
|
||||
plugins={[Zoom]}
|
||||
open={index >= 0}
|
||||
close={() => setIndex(-1)}
|
||||
|
||||
@@ -14,7 +14,7 @@ import PullToRefresh from 'react-simple-pull-to-refresh'
|
||||
import { FormattedTimestamp } from '../FormattedTimestamp'
|
||||
import UserAvatar from '../UserAvatar'
|
||||
|
||||
const LIMIT = 50
|
||||
const LIMIT = 100
|
||||
|
||||
export default function NotificationList() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
@@ -11,7 +11,10 @@ export default function NsfwOverlay({ className }: { className?: string }) {
|
||||
'absolute top-0 left-0 backdrop-blur-3xl w-full h-full cursor-pointer',
|
||||
className
|
||||
)}
|
||||
onClick={() => setIsHidden(false)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setIsHidden(false)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { extractImetaUrlFromTag } from '@/lib/tag'
|
||||
import { isNsfwEvent } from '@/lib/event'
|
||||
import { extractImageInfoFromTag } from '@/lib/tag'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { TImageInfo } from '@/types'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { memo, ReactNode } from 'react'
|
||||
import {
|
||||
@@ -11,14 +13,13 @@ import {
|
||||
embeddedWebsocketUrlRenderer
|
||||
} from '../Embedded'
|
||||
import { ImageCarousel } from '../ImageCarousel'
|
||||
import { isNsfwEvent } from '@/lib/event'
|
||||
|
||||
const PictureContent = memo(({ event, className }: { event: Event; className?: string }) => {
|
||||
const images: string[] = []
|
||||
const images: TImageInfo[] = []
|
||||
event.tags.forEach((tag) => {
|
||||
const imageUrl = extractImetaUrlFromTag(tag)
|
||||
if (imageUrl) {
|
||||
images.push(imageUrl)
|
||||
const imageInfo = extractImageInfoFromTag(tag)
|
||||
if (imageInfo) {
|
||||
images.push(imageInfo)
|
||||
}
|
||||
})
|
||||
const isNsfw = isNsfwEvent(event)
|
||||
|
||||
@@ -20,7 +20,7 @@ export default function PictureNoteCard({
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-1 cursor-pointer', className)} onClick={() => push(toNote(event))}>
|
||||
<Image className="rounded-lg w-full aspect-[6/8]" src={firstImage} />
|
||||
<Image className="rounded-lg w-full aspect-[6/8]" image={firstImage} />
|
||||
<div className="line-clamp-2 px-2">{event.content}</div>
|
||||
<div className="flex items-center gap-2 px-2">
|
||||
<UserAvatar userId={event.pubkey} size="xSmall" />
|
||||
|
||||
@@ -24,7 +24,7 @@ export default function ProfileBanner({
|
||||
|
||||
return (
|
||||
<Image
|
||||
src={bannerUrl}
|
||||
image={{ url: bannerUrl }}
|
||||
alt={`${pubkey} banner`}
|
||||
className={className}
|
||||
onError={() => setBannerUrl(defaultBanner)}
|
||||
|
||||
@@ -30,7 +30,7 @@ export default function WebPreview({
|
||||
if (isSmallScreen && image) {
|
||||
return (
|
||||
<div className="rounded-lg border mt-2">
|
||||
<Image src={image} className="rounded-t-lg w-full h-44" />
|
||||
<Image image={{ url: image }} className="rounded-t-lg w-full h-44" />
|
||||
<div className="bg-muted p-2 w-full rounded-b-lg">
|
||||
<div className="text-xs text-muted-foreground">{hostname}</div>
|
||||
<div className="font-semibold line-clamp-1">{title}</div>
|
||||
@@ -48,7 +48,10 @@ export default function WebPreview({
|
||||
}}
|
||||
>
|
||||
{image && (
|
||||
<Image src={image} className={`rounded-l-lg ${size === 'normal' ? 'h-44' : 'h-24'}`} />
|
||||
<Image
|
||||
image={{ url: image }}
|
||||
className={`rounded-l-lg ${size === 'normal' ? 'h-44' : 'h-24'}`}
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 w-0 p-2">
|
||||
<div className="text-xs text-muted-foreground">{hostname}</div>
|
||||
|
||||
Reference in New Issue
Block a user