feat: add support for thumbhash
This commit is contained in:
7
package-lock.json
generated
7
package-lock.json
generated
@@ -74,6 +74,7 @@
|
|||||||
"sonner": "^2.0.5",
|
"sonner": "^2.0.5",
|
||||||
"tailwind-merge": "^2.5.5",
|
"tailwind-merge": "^2.5.5",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"thumbhash": "^0.1.1",
|
||||||
"tippy.js": "^6.3.7",
|
"tippy.js": "^6.3.7",
|
||||||
"uri-templates": "^0.2.0",
|
"uri-templates": "^0.2.0",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
@@ -11813,6 +11814,12 @@
|
|||||||
"node": ">=0.8"
|
"node": ">=0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/thumbhash": {
|
||||||
|
"version": "0.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/thumbhash/-/thumbhash-0.1.1.tgz",
|
||||||
|
"integrity": "sha512-kH5pKeIIBPQXAOni2AiY/Cu/NKdkFREdpH+TLdM0g6WA7RriCv0kPLgP731ady67MhTAqrVG/4mnEeibVuCJcg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.10",
|
"version": "0.2.10",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.10.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.10.tgz",
|
||||||
|
|||||||
@@ -84,6 +84,7 @@
|
|||||||
"sonner": "^2.0.5",
|
"sonner": "^2.0.5",
|
||||||
"tailwind-merge": "^2.5.5",
|
"tailwind-merge": "^2.5.5",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"thumbhash": "^0.1.1",
|
||||||
"tippy.js": "^6.3.7",
|
"tippy.js": "^6.3.7",
|
||||||
"uri-templates": "^0.2.0",
|
"uri-templates": "^0.2.0",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
|
|||||||
@@ -57,8 +57,8 @@ export default function Collapsible({
|
|||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
{shouldCollapse && !expanded && (
|
{shouldCollapse && !expanded && (
|
||||||
<div className="absolute bottom-0 h-40 w-full bg-gradient-to-b from-transparent to-background/90 flex items-end justify-center pb-4">
|
<div className="absolute bottom-0 h-40 w-full z-10 bg-gradient-to-b from-transparent to-background/90 flex items-end justify-center pb-4">
|
||||||
<div className="bg-background rounded-md">
|
<div className="bg-background rounded-lg">
|
||||||
<Button
|
<Button
|
||||||
className="bg-foreground hover:bg-foreground/80"
|
className="bg-foreground hover:bg-foreground/80"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ import { TImetaInfo } from '@/types'
|
|||||||
import { decode } from 'blurhash'
|
import { decode } from 'blurhash'
|
||||||
import { ImageOff } from 'lucide-react'
|
import { ImageOff } from 'lucide-react'
|
||||||
import { HTMLAttributes, useEffect, useMemo, useRef, useState } from 'react'
|
import { HTMLAttributes, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { thumbHashToDataURL } from 'thumbhash'
|
||||||
|
|
||||||
export default function Image({
|
export default function Image({
|
||||||
image: { url, blurHash, pubkey, dim },
|
image: { url, blurHash, thumbHash, pubkey, dim },
|
||||||
alt,
|
alt,
|
||||||
className = '',
|
className = '',
|
||||||
classNames = {},
|
classNames = {},
|
||||||
@@ -73,20 +74,39 @@ export default function Image({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('relative overflow-hidden', classNames.wrapper)} {...props}>
|
<div className={cn('relative overflow-hidden', classNames.wrapper)} {...props}>
|
||||||
|
{/* Spacer: transparent image to maintain dimensions when image is loading */}
|
||||||
|
{isLoading && dim?.width && dim?.height && (
|
||||||
|
<img
|
||||||
|
src={`data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='${dim.width}' height='${dim.height}'%3E%3C/svg%3E`}
|
||||||
|
className={cn(
|
||||||
|
'object-cover rounded-xl transition-opacity pointer-events-none w-full h-full',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{displaySkeleton && (
|
{displaySkeleton && (
|
||||||
<div className="absolute inset-0 z-10">
|
<div className="absolute inset-0 z-10">
|
||||||
{blurHash ? (
|
{thumbHash ? (
|
||||||
|
<ThumbHashPlaceholder
|
||||||
|
thumbHash={thumbHash}
|
||||||
|
className={cn(
|
||||||
|
'w-full h-full transition-opacity rounded-xl',
|
||||||
|
isLoading ? 'opacity-100' : 'opacity-0'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : blurHash ? (
|
||||||
<BlurHashCanvas
|
<BlurHashCanvas
|
||||||
blurHash={blurHash}
|
blurHash={blurHash}
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute inset-0 transition-opacity rounded-xl',
|
'w-full h-full transition-opacity rounded-xl',
|
||||||
isLoading ? 'opacity-100' : 'opacity-0'
|
isLoading ? 'opacity-100' : 'opacity-0'
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Skeleton
|
<Skeleton
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute inset-0 transition-opacity rounded-xl',
|
'w-full h-full transition-opacity rounded-xl',
|
||||||
isLoading ? 'opacity-100' : 'opacity-0',
|
isLoading ? 'opacity-100' : 'opacity-0',
|
||||||
classNames.skeleton
|
classNames.skeleton
|
||||||
)}
|
)}
|
||||||
@@ -104,12 +124,10 @@ export default function Image({
|
|||||||
onLoad={handleLoad}
|
onLoad={handleLoad}
|
||||||
onError={handleError}
|
onError={handleError}
|
||||||
className={cn(
|
className={cn(
|
||||||
'object-cover rounded-xl w-full h-full transition-opacity pointer-events-none',
|
'object-cover rounded-xl transition-opacity pointer-events-none w-full h-full',
|
||||||
isLoading ? 'opacity-0' : 'opacity-100',
|
isLoading ? 'opacity-0 absolute inset-0' : '',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
width={dim?.width}
|
|
||||||
height={dim?.height}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{hasError &&
|
{hasError &&
|
||||||
@@ -178,3 +196,35 @@ function BlurHashCanvas({ blurHash, className = '' }: { blurHash: string; classN
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ThumbHashPlaceholder({
|
||||||
|
thumbHash,
|
||||||
|
className = ''
|
||||||
|
}: {
|
||||||
|
thumbHash: Uint8Array
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
const dataUrl = useMemo(() => {
|
||||||
|
if (!thumbHash) return null
|
||||||
|
try {
|
||||||
|
return thumbHashToDataURL(thumbHash)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('failed to decode thumbhash:', error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}, [thumbHash])
|
||||||
|
|
||||||
|
if (!dataUrl) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn('w-full h-full object-cover rounded-lg', className)}
|
||||||
|
style={{
|
||||||
|
backgroundImage: `url(${dataUrl})`,
|
||||||
|
backgroundSize: 'cover',
|
||||||
|
backgroundPosition: 'center',
|
||||||
|
filter: 'blur(1px)'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { TEmoji, TImetaInfo } from '@/types'
|
import { TEmoji, TImetaInfo } from '@/types'
|
||||||
|
import { base64 } from '@scure/base'
|
||||||
import { isBlurhashValid } from 'blurhash'
|
import { isBlurhashValid } from 'blurhash'
|
||||||
import { nip19 } from 'nostr-tools'
|
import { nip19 } from 'nostr-tools'
|
||||||
import { isValidPubkey } from './pubkey'
|
import { isValidPubkey } from './pubkey'
|
||||||
@@ -49,28 +50,40 @@ export function generateBech32IdFromATag(tag: string[]) {
|
|||||||
|
|
||||||
export function getImetaInfoFromImetaTag(tag: string[], pubkey?: string): TImetaInfo | null {
|
export function getImetaInfoFromImetaTag(tag: string[], pubkey?: string): TImetaInfo | null {
|
||||||
if (tag[0] !== 'imeta') return null
|
if (tag[0] !== 'imeta') return null
|
||||||
const urlItem = tag.find((item) => item.startsWith('url '))
|
const imeta: Partial<TImetaInfo> = { pubkey }
|
||||||
const url = urlItem?.slice(4)
|
|
||||||
if (!url) return null
|
|
||||||
|
|
||||||
const imeta: TImetaInfo = { url, pubkey }
|
for (let i = 1; i < tag.length; i++) {
|
||||||
const blurHashItem = tag.find((item) => item.startsWith('blurhash '))
|
const [k, v] = tag[i].split(' ')
|
||||||
const blurHash = blurHashItem?.slice(9)
|
switch (k) {
|
||||||
if (blurHash) {
|
case 'url':
|
||||||
const validRes = isBlurhashValid(blurHash)
|
imeta.url = v
|
||||||
|
break
|
||||||
|
case 'thumbhash':
|
||||||
|
try {
|
||||||
|
imeta.thumbHash = base64.decode(v)
|
||||||
|
} catch {
|
||||||
|
/***/
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'blurhash': {
|
||||||
|
const validRes = isBlurhashValid(v)
|
||||||
if (validRes.result) {
|
if (validRes.result) {
|
||||||
imeta.blurHash = blurHash
|
imeta.blurHash = v
|
||||||
}
|
}
|
||||||
|
break
|
||||||
}
|
}
|
||||||
const dimItem = tag.find((item) => item.startsWith('dim '))
|
case 'dim': {
|
||||||
const dim = dimItem?.slice(4)
|
const [width, height] = v.split('x').map(Number)
|
||||||
if (dim) {
|
|
||||||
const [width, height] = dim.split('x').map(Number)
|
|
||||||
if (width && height) {
|
if (width && height) {
|
||||||
imeta.dim = { width, height }
|
imeta.dim = { width, height }
|
||||||
}
|
}
|
||||||
|
break
|
||||||
}
|
}
|
||||||
return imeta
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!imeta.url) return null
|
||||||
|
return imeta as TImetaInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getPubkeysFromPTags(tags: string[][]) {
|
export function getPubkeysFromPTags(tags: string[][]) {
|
||||||
|
|||||||
1
src/types/index.d.ts
vendored
1
src/types/index.d.ts
vendored
@@ -115,6 +115,7 @@ export type TLanguage = 'en' | 'zh' | 'pl'
|
|||||||
export type TImetaInfo = {
|
export type TImetaInfo = {
|
||||||
url: string
|
url: string
|
||||||
blurHash?: string
|
blurHash?: string
|
||||||
|
thumbHash?: Uint8Array
|
||||||
dim?: { width: number; height: number }
|
dim?: { width: number; height: number }
|
||||||
pubkey?: string
|
pubkey?: string
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user