feat: blurhash

This commit is contained in:
codytseng
2025-01-08 16:54:10 +08:00
parent 91977d6495
commit dacaa4a75d
15 changed files with 155 additions and 50 deletions

View File

@@ -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

View File

@@ -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
}
}

View File

@@ -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)}

View File

@@ -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)}

View File

@@ -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()

View File

@@ -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)
}}
/>
)
)

View File

@@ -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)

View File

@@ -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" />

View File

@@ -24,7 +24,7 @@ export default function ProfileBanner({
return (
<Image
src={bannerUrl}
image={{ url: bannerUrl }}
alt={`${pubkey} banner`}
className={className}
onError={() => setBannerUrl(defaultBanner)}

View File

@@ -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>

View File

@@ -1,7 +1,7 @@
import { COMMENT_EVENT_KIND, PICTURE_EVENT_KIND } from '@/constants'
import client from '@/services/client.service'
import { Event, kinds, nip19 } from 'nostr-tools'
import { extractImetaUrlFromTag, isReplyETag, isRootETag, tagNameEquals } from './tag'
import { extractImageInfoFromTag, isReplyETag, isRootETag, tagNameEquals } from './tag'
export function isNsfwEvent(event: Event) {
return event.tags.some(
@@ -200,7 +200,7 @@ export function extractHashtags(content: string) {
export function extractFirstPictureFromPictureEvent(event: Event) {
if (!isPictureEvent(event)) return null
for (const tag of event.tags) {
const url = extractImetaUrlFromTag(tag)
const url = extractImageInfoFromTag(tag)
if (url) return url
}
return null

View File

@@ -1,3 +1,6 @@
import { TImageInfo } from '@/types'
import { isBlurhashValid } from 'blurhash'
export function tagNameEquals(tagName: string) {
return (tag: string[]) => tag[0] === tagName
}
@@ -14,9 +17,28 @@ export function isMentionETag([tagName, , , marker]: string[]) {
return tagName === 'e' && marker === 'mention'
}
export function extractImetaUrlFromTag(tag: string[]) {
export function extractImageInfoFromTag(tag: string[]): TImageInfo | null {
if (tag[0] !== 'imeta') return null
const urlItem = tag.find((item) => item.startsWith('url '))
const url = urlItem?.slice(4)
return url || null
if (!url) return null
const image: TImageInfo = { url }
const blurHashItem = tag.find((item) => item.startsWith('blurhash '))
const blurHash = blurHashItem?.slice(9)
if (blurHash) {
const validRes = isBlurhashValid(blurHash)
if (validRes.result) {
image.blurHash = blurHash
}
}
const dimItem = tag.find((item) => item.startsWith('dim '))
const dim = dimItem?.slice(4)
if (dim) {
const [width, height] = dim.split('x').map(Number)
if (width && height) {
image.dim = { width, height }
}
}
return image
}

View File

@@ -67,3 +67,5 @@ export type TAccountPointer = Pick<TAccount, 'pubkey' | 'signerType'>
export type TFeedType = 'following' | 'relays' | 'temporary'
export type TLanguage = 'en' | 'zh'
export type TImageInfo = { url: string; blurHash?: string; dim?: { width: number; height: number } }