feat: add support for thumbhash

This commit is contained in:
codytseng
2025-12-12 10:23:02 +08:00
parent f6f974adc6
commit 51fc7d4c05
6 changed files with 101 additions and 29 deletions

7
package-lock.json generated
View File

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

View File

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

View File

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

View File

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

View File

@@ -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[][]) {

View File

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