diff --git a/package-lock.json b/package-lock.json index 2ea450f2..63380588 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "@radix-ui/react-slot": "^1.1.1", "@radix-ui/react-switch": "^1.1.2", "@radix-ui/react-toast": "^1.2.4", + "blurhash": "^2.0.5", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.0.0", @@ -4262,6 +4263,11 @@ "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": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", diff --git a/package.json b/package.json index 00cb7180..3a37ad52 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "@radix-ui/react-slot": "^1.1.1", "@radix-ui/react-switch": "^1.1.2", "@radix-ui/react-toast": "^1.2.4", + "blurhash": "^2.0.5", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.0.0", diff --git a/src/components/Content/index.tsx b/src/components/Content/index.tsx index c8ac54a6..2ae76003 100644 --- a/src/components/Content/index.tsx +++ b/src/components/Content/index.tsx @@ -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 diff --git a/src/components/Image/index.tsx b/src/components/Image/index.tsx index d5f2f39f..0316ef18 100644 --- a/src/components/Image/index.tsx +++ b/src/components/Image/index.tsx @@ -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 & { - src: string + image: TImageInfo alt?: string classNames?: { wrapper?: string } }) { const [isLoading, setIsLoading] = useState(true) + const [displayBlurHash, setDisplayBlurHash] = useState(true) + const [blurDataUrl, setBlurDataUrl] = useState(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 (
{isLoading && } {alt} setIsLoading(false)} + onLoad={() => { + setIsLoading(false) + setTimeout(() => setDisplayBlurHash(false), 1000) + }} /> + {displayBlurHash && blurDataUrl && ( + {alt} + )}
) } + +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 + } +} diff --git a/src/components/ImageCarousel/index.tsx b/src/components/ImageCarousel/index.tsx index edf97064..3ca0ff5f 100644 --- a/src/components/ImageCarousel/index.tsx +++ b/src/components/ImageCarousel/index.tsx @@ -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 <> - {images.map((url, index) => ( + {images.map((image, index) => ( - handlePhotoClick(e, index)} /> + handlePhotoClick(e, index)} /> ))} ({ src }))} + slides={images.map(({ url }) => ({ src: url }))} plugins={[Zoom]} open={index >= 0} close={() => setIndex(-1)} diff --git a/src/components/ImageGallery/index.tsx b/src/components/ImageGallery/index.tsx index 0d74b146..1ed375dc 100644 --- a/src/components/ImageGallery/index.tsx +++ b/src/components/ImageGallery/index.tsx @@ -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 = ( handlePhotoClick(e, 0)} /> ) } else if (size === 'small') { imageContent = (
- {images.map((src, i) => ( + {images.map((image, i) => ( handlePhotoClick(e, i)} /> ))} @@ -52,11 +53,11 @@ export default function ImageGallery({ } else if (isSmallScreen && (images.length === 2 || images.length === 4)) { imageContent = (
- {images.map((src, i) => ( + {images.map((image, i) => ( handlePhotoClick(e, i)} /> ))} @@ -65,11 +66,11 @@ export default function ImageGallery({ } else { imageContent = (
- {images.map((src, i) => ( + {images.map((image, i) => ( handlePhotoClick(e, i)} /> ))} @@ -83,7 +84,7 @@ export default function ImageGallery({
e.stopPropagation()}> ({ src }))} + slides={images.map(({ url }) => ({ src: url }))} plugins={[Zoom]} open={index >= 0} close={() => setIndex(-1)} diff --git a/src/components/NotificationList/index.tsx b/src/components/NotificationList/index.tsx index 05a9979a..9aa53352 100644 --- a/src/components/NotificationList/index.tsx +++ b/src/components/NotificationList/index.tsx @@ -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() diff --git a/src/components/NsfwOverlay/index.tsx b/src/components/NsfwOverlay/index.tsx index 68d13a96..895d05de 100644 --- a/src/components/NsfwOverlay/index.tsx +++ b/src/components/NsfwOverlay/index.tsx @@ -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) + }} /> ) ) diff --git a/src/components/PictureContent/index.tsx b/src/components/PictureContent/index.tsx index fef36343..65e4d364 100644 --- a/src/components/PictureContent/index.tsx +++ b/src/components/PictureContent/index.tsx @@ -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) diff --git a/src/components/PictureNoteCard/index.tsx b/src/components/PictureNoteCard/index.tsx index f000cf2f..62384b01 100644 --- a/src/components/PictureNoteCard/index.tsx +++ b/src/components/PictureNoteCard/index.tsx @@ -20,7 +20,7 @@ export default function PictureNoteCard({ return (
push(toNote(event))}> - +
{event.content}
diff --git a/src/components/ProfileBanner/index.tsx b/src/components/ProfileBanner/index.tsx index 10663e3b..33352378 100644 --- a/src/components/ProfileBanner/index.tsx +++ b/src/components/ProfileBanner/index.tsx @@ -24,7 +24,7 @@ export default function ProfileBanner({ return ( {`${pubkey} setBannerUrl(defaultBanner)} diff --git a/src/components/WebPreview/index.tsx b/src/components/WebPreview/index.tsx index c602e16a..4884ee28 100644 --- a/src/components/WebPreview/index.tsx +++ b/src/components/WebPreview/index.tsx @@ -30,7 +30,7 @@ export default function WebPreview({ if (isSmallScreen && image) { return (
- +
{hostname}
{title}
@@ -48,7 +48,10 @@ export default function WebPreview({ }} > {image && ( - + )}
{hostname}
diff --git a/src/lib/event.ts b/src/lib/event.ts index 23059fcb..c1504d7d 100644 --- a/src/lib/event.ts +++ b/src/lib/event.ts @@ -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 diff --git a/src/lib/tag.ts b/src/lib/tag.ts index 23bf9c36..0c5d80e7 100644 --- a/src/lib/tag.ts +++ b/src/lib/tag.ts @@ -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 } diff --git a/src/types.ts b/src/types.ts index 7c7d169a..a861df15 100644 --- a/src/types.ts +++ b/src/types.ts @@ -67,3 +67,5 @@ export type TAccountPointer = Pick export type TFeedType = 'following' | 'relays' | 'temporary' export type TLanguage = 'en' | 'zh' + +export type TImageInfo = { url: string; blurHash?: string; dim?: { width: number; height: number } }