feat: blurhash
This commit is contained in:
6
package-lock.json
generated
6
package-lock.json
generated
@@ -23,6 +23,7 @@
|
|||||||
"@radix-ui/react-slot": "^1.1.1",
|
"@radix-ui/react-slot": "^1.1.1",
|
||||||
"@radix-ui/react-switch": "^1.1.2",
|
"@radix-ui/react-switch": "^1.1.2",
|
||||||
"@radix-ui/react-toast": "^1.2.4",
|
"@radix-ui/react-toast": "^1.2.4",
|
||||||
|
"blurhash": "^2.0.5",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.0.0",
|
"cmdk": "^1.0.0",
|
||||||
@@ -4262,6 +4263,11 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/blurhash": {
|
||||||
|
"version": "2.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/blurhash/-/blurhash-2.0.5.tgz",
|
||||||
|
"integrity": "sha512-cRygWd7kGBQO3VEhPiTgq4Wc43ctsM+o46urrmPOiuAe+07fzlSB9OJVdpgDL0jPqXUVQ9ht7aq7kxOeJHRK+w=="
|
||||||
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "1.1.11",
|
"version": "1.1.11",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
"@radix-ui/react-slot": "^1.1.1",
|
"@radix-ui/react-slot": "^1.1.1",
|
||||||
"@radix-ui/react-switch": "^1.1.2",
|
"@radix-ui/react-switch": "^1.1.2",
|
||||||
"@radix-ui/react-toast": "^1.2.4",
|
"@radix-ui/react-toast": "^1.2.4",
|
||||||
|
"blurhash": "^2.0.5",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.0.0",
|
"cmdk": "^1.0.0",
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import { URL_REGEX } from '@/constants'
|
||||||
import { isNsfwEvent, isPictureEvent } from '@/lib/event'
|
import { isNsfwEvent, isPictureEvent } from '@/lib/event'
|
||||||
import { extractImetaUrlFromTag } from '@/lib/tag'
|
import { extractImageInfoFromTag } from '@/lib/tag'
|
||||||
import { isImage, isVideo } from '@/lib/url'
|
import { isImage, isVideo } from '@/lib/url'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import { TImageInfo } from '@/types'
|
||||||
import { Event } from 'nostr-tools'
|
import { Event } from 'nostr-tools'
|
||||||
import { memo } from 'react'
|
import { memo } from 'react'
|
||||||
import {
|
import {
|
||||||
@@ -16,7 +18,6 @@ import {
|
|||||||
import ImageGallery from '../ImageGallery'
|
import ImageGallery from '../ImageGallery'
|
||||||
import VideoPlayer from '../VideoPlayer'
|
import VideoPlayer from '../VideoPlayer'
|
||||||
import WebPreview from '../WebPreview'
|
import WebPreview from '../WebPreview'
|
||||||
import { URL_REGEX } from '@/constants'
|
|
||||||
|
|
||||||
const Content = memo(
|
const Content = memo(
|
||||||
({
|
({
|
||||||
@@ -104,13 +105,13 @@ function preprocess(event: Event) {
|
|||||||
let lastNonMediaUrl: string | undefined
|
let lastNonMediaUrl: string | undefined
|
||||||
|
|
||||||
let c = content
|
let c = content
|
||||||
const images: string[] = []
|
const imageUrls: string[] = []
|
||||||
const videos: string[] = []
|
const videos: string[] = []
|
||||||
|
|
||||||
urls.forEach((url) => {
|
urls.forEach((url) => {
|
||||||
if (isImage(url)) {
|
if (isImage(url)) {
|
||||||
c = c.replace(url, '').trim()
|
c = c.replace(url, '').trim()
|
||||||
images.push(url)
|
imageUrls.push(url)
|
||||||
} else if (isVideo(url)) {
|
} else if (isVideo(url)) {
|
||||||
c = c.replace(url, '').trim()
|
c = c.replace(url, '').trim()
|
||||||
videos.push(url)
|
videos.push(url)
|
||||||
@@ -119,14 +120,15 @@ function preprocess(event: Event) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
if (isPictureEvent(event)) {
|
const imageInfos = event.tags
|
||||||
event.tags.forEach((tag) => {
|
.map((tag) => extractImageInfoFromTag(tag))
|
||||||
const imageUrl = extractImetaUrlFromTag(tag)
|
.filter(Boolean) as TImageInfo[]
|
||||||
if (imageUrl) {
|
const images = isPictureEvent(event)
|
||||||
images.push(imageUrl)
|
? imageInfos
|
||||||
}
|
: imageUrls.map((url) => {
|
||||||
|
const imageInfo = imageInfos.find((info) => info.url === url)
|
||||||
|
return imageInfo ?? { url }
|
||||||
})
|
})
|
||||||
}
|
|
||||||
|
|
||||||
const embeddedNotes: string[] = []
|
const embeddedNotes: string[] = []
|
||||||
const embeddedNoteRegex = /nostr:(note1[a-z0-9]{58}|nevent1[a-z0-9]+|naddr1[a-z0-9]+)/g
|
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 { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { cn } from '@/lib/utils'
|
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({
|
export default function Image({
|
||||||
src,
|
image: { url, blurHash, dim },
|
||||||
alt,
|
alt,
|
||||||
className = '',
|
className = '',
|
||||||
classNames = {},
|
classNames = {},
|
||||||
...props
|
...props
|
||||||
}: HTMLAttributes<HTMLDivElement> & {
|
}: HTMLAttributes<HTMLDivElement> & {
|
||||||
src: string
|
image: TImageInfo
|
||||||
alt?: string
|
alt?: string
|
||||||
classNames?: {
|
classNames?: {
|
||||||
wrapper?: string
|
wrapper?: string
|
||||||
}
|
}
|
||||||
}) {
|
}) {
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
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 (
|
return (
|
||||||
<div className={cn('relative', classNames.wrapper ?? '')} {...props}>
|
<div className={cn('relative', classNames.wrapper ?? '')} {...props}>
|
||||||
{isLoading && <Skeleton className={cn('absolute inset-0', className)} />}
|
{isLoading && <Skeleton className={cn('absolute inset-0', className)} />}
|
||||||
<img
|
<img
|
||||||
src={src}
|
src={url}
|
||||||
alt={alt}
|
alt={alt}
|
||||||
className={cn(
|
className={cn(
|
||||||
'object-cover transition-opacity duration-700',
|
'object-cover transition-opacity duration-700',
|
||||||
isLoading ? 'opacity-0' : 'opacity-100',
|
isLoading ? 'opacity-0' : 'opacity-100',
|
||||||
className
|
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>
|
</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 { Carousel, CarouselContent, CarouselItem } from '@/components/ui/carousel'
|
||||||
|
import { TImageInfo } from '@/types'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
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 NsfwOverlay from '../NsfwOverlay'
|
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 [index, setIndex] = useState(-1)
|
||||||
|
|
||||||
const handlePhotoClick = (event: React.MouseEvent, current: number) => {
|
const handlePhotoClick = (event: React.MouseEvent, current: number) => {
|
||||||
@@ -17,16 +24,16 @@ export function ImageCarousel({ images, isNsfw = false }: { images: string[]; is
|
|||||||
<>
|
<>
|
||||||
<Carousel className="w-full">
|
<Carousel className="w-full">
|
||||||
<CarouselContent>
|
<CarouselContent>
|
||||||
{images.map((url, index) => (
|
{images.map((image, index) => (
|
||||||
<CarouselItem key={index}>
|
<CarouselItem key={index}>
|
||||||
<Image src={url} onClick={(e) => handlePhotoClick(e, index)} />
|
<Image image={image} onClick={(e) => handlePhotoClick(e, index)} />
|
||||||
</CarouselItem>
|
</CarouselItem>
|
||||||
))}
|
))}
|
||||||
</CarouselContent>
|
</CarouselContent>
|
||||||
</Carousel>
|
</Carousel>
|
||||||
<Lightbox
|
<Lightbox
|
||||||
index={index}
|
index={index}
|
||||||
slides={images.map((src) => ({ src }))}
|
slides={images.map(({ url }) => ({ src: url }))}
|
||||||
plugins={[Zoom]}
|
plugins={[Zoom]}
|
||||||
open={index >= 0}
|
open={index >= 0}
|
||||||
close={() => setIndex(-1)}
|
close={() => setIndex(-1)}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||||
|
import { TImageInfo } from '@/types'
|
||||||
import { ReactNode, useState } from 'react'
|
import { ReactNode, useState } from 'react'
|
||||||
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'
|
||||||
@@ -13,7 +14,7 @@ export default function ImageGallery({
|
|||||||
size = 'normal'
|
size = 'normal'
|
||||||
}: {
|
}: {
|
||||||
className?: string
|
className?: string
|
||||||
images: string[]
|
images: TImageInfo[]
|
||||||
isNsfw?: boolean
|
isNsfw?: boolean
|
||||||
size?: 'normal' | 'small'
|
size?: 'normal' | 'small'
|
||||||
}) {
|
}) {
|
||||||
@@ -30,20 +31,20 @@ export default function ImageGallery({
|
|||||||
if (images.length === 1) {
|
if (images.length === 1) {
|
||||||
imageContent = (
|
imageContent = (
|
||||||
<Image
|
<Image
|
||||||
key={index}
|
key={0}
|
||||||
className={cn('rounded-lg cursor-pointer', size === 'small' ? 'h-[15vh]' : 'h-[30vh]')}
|
className={cn('rounded-lg cursor-pointer', size === 'small' ? 'h-[15vh]' : 'h-[30vh]')}
|
||||||
src={images[0]}
|
image={images[0]}
|
||||||
onClick={(e) => handlePhotoClick(e, 0)}
|
onClick={(e) => handlePhotoClick(e, 0)}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
} else if (size === 'small') {
|
} else if (size === 'small') {
|
||||||
imageContent = (
|
imageContent = (
|
||||||
<div className="grid grid-cols-4 gap-2">
|
<div className="grid grid-cols-4 gap-2">
|
||||||
{images.map((src, i) => (
|
{images.map((image, i) => (
|
||||||
<Image
|
<Image
|
||||||
key={i}
|
key={i}
|
||||||
className="rounded-lg cursor-pointer aspect-square"
|
className="rounded-lg cursor-pointer aspect-square w-full"
|
||||||
src={src}
|
image={image}
|
||||||
onClick={(e) => handlePhotoClick(e, i)}
|
onClick={(e) => handlePhotoClick(e, i)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -52,11 +53,11 @@ export default function ImageGallery({
|
|||||||
} else if (isSmallScreen && (images.length === 2 || images.length === 4)) {
|
} else if (isSmallScreen && (images.length === 2 || images.length === 4)) {
|
||||||
imageContent = (
|
imageContent = (
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
{images.map((src, i) => (
|
{images.map((image, i) => (
|
||||||
<Image
|
<Image
|
||||||
key={i}
|
key={i}
|
||||||
className="rounded-lg cursor-pointer aspect-square"
|
className="rounded-lg cursor-pointer aspect-square w-full"
|
||||||
src={src}
|
image={image}
|
||||||
onClick={(e) => handlePhotoClick(e, i)}
|
onClick={(e) => handlePhotoClick(e, i)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -65,11 +66,11 @@ export default function ImageGallery({
|
|||||||
} else {
|
} else {
|
||||||
imageContent = (
|
imageContent = (
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<div className="grid grid-cols-3 gap-2">
|
||||||
{images.map((src, i) => (
|
{images.map((image, i) => (
|
||||||
<Image
|
<Image
|
||||||
key={i}
|
key={i}
|
||||||
className="rounded-lg cursor-pointer aspect-square"
|
className="rounded-lg cursor-pointer aspect-square w-full"
|
||||||
src={src}
|
image={image}
|
||||||
onClick={(e) => handlePhotoClick(e, i)}
|
onClick={(e) => handlePhotoClick(e, i)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -83,7 +84,7 @@ export default function ImageGallery({
|
|||||||
<div onClick={(e) => e.stopPropagation()}>
|
<div onClick={(e) => e.stopPropagation()}>
|
||||||
<Lightbox
|
<Lightbox
|
||||||
index={index}
|
index={index}
|
||||||
slides={images.map((src) => ({ src }))}
|
slides={images.map(({ url }) => ({ src: url }))}
|
||||||
plugins={[Zoom]}
|
plugins={[Zoom]}
|
||||||
open={index >= 0}
|
open={index >= 0}
|
||||||
close={() => setIndex(-1)}
|
close={() => setIndex(-1)}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import PullToRefresh from 'react-simple-pull-to-refresh'
|
|||||||
import { FormattedTimestamp } from '../FormattedTimestamp'
|
import { FormattedTimestamp } from '../FormattedTimestamp'
|
||||||
import UserAvatar from '../UserAvatar'
|
import UserAvatar from '../UserAvatar'
|
||||||
|
|
||||||
const LIMIT = 50
|
const LIMIT = 100
|
||||||
|
|
||||||
export default function NotificationList() {
|
export default function NotificationList() {
|
||||||
const { t } = useTranslation()
|
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',
|
'absolute top-0 left-0 backdrop-blur-3xl w-full h-full cursor-pointer',
|
||||||
className
|
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 { cn } from '@/lib/utils'
|
||||||
|
import { TImageInfo } from '@/types'
|
||||||
import { Event } from 'nostr-tools'
|
import { Event } from 'nostr-tools'
|
||||||
import { memo, ReactNode } from 'react'
|
import { memo, ReactNode } from 'react'
|
||||||
import {
|
import {
|
||||||
@@ -11,14 +13,13 @@ import {
|
|||||||
embeddedWebsocketUrlRenderer
|
embeddedWebsocketUrlRenderer
|
||||||
} from '../Embedded'
|
} from '../Embedded'
|
||||||
import { ImageCarousel } from '../ImageCarousel'
|
import { ImageCarousel } from '../ImageCarousel'
|
||||||
import { isNsfwEvent } from '@/lib/event'
|
|
||||||
|
|
||||||
const PictureContent = memo(({ event, className }: { event: Event; className?: string }) => {
|
const PictureContent = memo(({ event, className }: { event: Event; className?: string }) => {
|
||||||
const images: string[] = []
|
const images: TImageInfo[] = []
|
||||||
event.tags.forEach((tag) => {
|
event.tags.forEach((tag) => {
|
||||||
const imageUrl = extractImetaUrlFromTag(tag)
|
const imageInfo = extractImageInfoFromTag(tag)
|
||||||
if (imageUrl) {
|
if (imageInfo) {
|
||||||
images.push(imageUrl)
|
images.push(imageInfo)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
const isNsfw = isNsfwEvent(event)
|
const isNsfw = isNsfwEvent(event)
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export default function PictureNoteCard({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('space-y-1 cursor-pointer', className)} onClick={() => push(toNote(event))}>
|
<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="line-clamp-2 px-2">{event.content}</div>
|
||||||
<div className="flex items-center gap-2 px-2">
|
<div className="flex items-center gap-2 px-2">
|
||||||
<UserAvatar userId={event.pubkey} size="xSmall" />
|
<UserAvatar userId={event.pubkey} size="xSmall" />
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export default function ProfileBanner({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Image
|
<Image
|
||||||
src={bannerUrl}
|
image={{ url: bannerUrl }}
|
||||||
alt={`${pubkey} banner`}
|
alt={`${pubkey} banner`}
|
||||||
className={className}
|
className={className}
|
||||||
onError={() => setBannerUrl(defaultBanner)}
|
onError={() => setBannerUrl(defaultBanner)}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export default function WebPreview({
|
|||||||
if (isSmallScreen && image) {
|
if (isSmallScreen && image) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border mt-2">
|
<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="bg-muted p-2 w-full rounded-b-lg">
|
||||||
<div className="text-xs text-muted-foreground">{hostname}</div>
|
<div className="text-xs text-muted-foreground">{hostname}</div>
|
||||||
<div className="font-semibold line-clamp-1">{title}</div>
|
<div className="font-semibold line-clamp-1">{title}</div>
|
||||||
@@ -48,7 +48,10 @@ export default function WebPreview({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{image && (
|
{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="flex-1 w-0 p-2">
|
||||||
<div className="text-xs text-muted-foreground">{hostname}</div>
|
<div className="text-xs text-muted-foreground">{hostname}</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { COMMENT_EVENT_KIND, PICTURE_EVENT_KIND } from '@/constants'
|
import { COMMENT_EVENT_KIND, PICTURE_EVENT_KIND } from '@/constants'
|
||||||
import client from '@/services/client.service'
|
import client from '@/services/client.service'
|
||||||
import { Event, kinds, nip19 } from 'nostr-tools'
|
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) {
|
export function isNsfwEvent(event: Event) {
|
||||||
return event.tags.some(
|
return event.tags.some(
|
||||||
@@ -200,7 +200,7 @@ export function extractHashtags(content: string) {
|
|||||||
export function extractFirstPictureFromPictureEvent(event: Event) {
|
export function extractFirstPictureFromPictureEvent(event: Event) {
|
||||||
if (!isPictureEvent(event)) return null
|
if (!isPictureEvent(event)) return null
|
||||||
for (const tag of event.tags) {
|
for (const tag of event.tags) {
|
||||||
const url = extractImetaUrlFromTag(tag)
|
const url = extractImageInfoFromTag(tag)
|
||||||
if (url) return url
|
if (url) return url
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import { TImageInfo } from '@/types'
|
||||||
|
import { isBlurhashValid } from 'blurhash'
|
||||||
|
|
||||||
export function tagNameEquals(tagName: string) {
|
export function tagNameEquals(tagName: string) {
|
||||||
return (tag: string[]) => tag[0] === tagName
|
return (tag: string[]) => tag[0] === tagName
|
||||||
}
|
}
|
||||||
@@ -14,9 +17,28 @@ export function isMentionETag([tagName, , , marker]: string[]) {
|
|||||||
return tagName === 'e' && marker === 'mention'
|
return tagName === 'e' && marker === 'mention'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function extractImetaUrlFromTag(tag: string[]) {
|
export function extractImageInfoFromTag(tag: string[]): TImageInfo | null {
|
||||||
if (tag[0] !== 'imeta') return null
|
if (tag[0] !== 'imeta') return null
|
||||||
const urlItem = tag.find((item) => item.startsWith('url '))
|
const urlItem = tag.find((item) => item.startsWith('url '))
|
||||||
const url = urlItem?.slice(4)
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,3 +67,5 @@ export type TAccountPointer = Pick<TAccount, 'pubkey' | 'signerType'>
|
|||||||
export type TFeedType = 'following' | 'relays' | 'temporary'
|
export type TFeedType = 'following' | 'relays' | 'temporary'
|
||||||
|
|
||||||
export type TLanguage = 'en' | 'zh'
|
export type TLanguage = 'en' | 'zh'
|
||||||
|
|
||||||
|
export type TImageInfo = { url: string; blurHash?: string; dim?: { width: number; height: number } }
|
||||||
|
|||||||
Reference in New Issue
Block a user