feat: improve 🌸

This commit is contained in:
codytseng
2025-10-24 22:41:16 +08:00
parent 36c9796ea1
commit 1274942f64
7 changed files with 166 additions and 107 deletions

View File

@@ -1,8 +1,7 @@
import { Skeleton } from '@/components/ui/skeleton'
import { cn } from '@/lib/utils'
import client from '@/services/client.service'
import blossomService from '@/services/blossom.service'
import { TImetaInfo } from '@/types'
import { getHashFromURL } from 'blossom-client-sdk'
import { decode } from 'blurhash'
import { ImageOff } from 'lucide-react'
import { HTMLAttributes, useEffect, useMemo, useRef, useState } from 'react'
@@ -28,63 +27,39 @@ export default function Image({
const [isLoading, setIsLoading] = useState(true)
const [displaySkeleton, setDisplaySkeleton] = useState(true)
const [hasError, setHasError] = useState(false)
const [imageUrl, setImageUrl] = useState(url)
const [tried, setTried] = useState(new Set())
const [imageUrl, setImageUrl] = useState<string>()
useEffect(() => {
setImageUrl(url)
setIsLoading(true)
setHasError(false)
setDisplaySkeleton(true)
setTried(new Set())
if (pubkey) {
blossomService.getValidUrl(url, pubkey).then((validUrl) => {
setImageUrl(validUrl)
})
} else {
setImageUrl(url)
}
}, [url])
if (hideIfError && hasError) return null
const handleError = async () => {
let oldImageUrl: URL | undefined
let hash: string | null = null
try {
oldImageUrl = new URL(imageUrl)
hash = getHashFromURL(oldImageUrl)
} catch (error) {
console.error('Invalid image URL:', error)
}
if (!pubkey || !hash || !oldImageUrl) {
const nextUrl = await blossomService.tryNextUrl(url)
if (nextUrl) {
setImageUrl(nextUrl)
} else {
setIsLoading(false)
setHasError(true)
return
}
const ext = oldImageUrl.pathname.match(/\.\w+$/i)
setTried((prev) => new Set(prev.add(oldImageUrl.hostname)))
const blossomServerList = await client.fetchBlossomServerList(pubkey)
const urls = blossomServerList
.map((server) => {
try {
return new URL(server)
} catch (error) {
console.error('Invalid Blossom server URL:', server, error)
return undefined
}
})
.filter((url) => !!url && !tried.has(url.hostname))
const nextUrl = urls[0]
if (!nextUrl) {
setIsLoading(false)
setHasError(true)
return
}
nextUrl.pathname = '/' + hash + ext
setImageUrl(nextUrl.toString())
}
const handleLoad = () => {
setIsLoading(false)
setHasError(false)
setTimeout(() => setDisplaySkeleton(false), 600)
blossomService.markAsSuccess(url, imageUrl || url)
}
return (
@@ -95,14 +70,14 @@ export default function Image({
<BlurHashCanvas
blurHash={blurHash}
className={cn(
'absolute inset-0 transition-opacity duration-500 rounded-lg',
'absolute inset-0 transition-opacity rounded-lg',
isLoading ? 'opacity-100' : 'opacity-0'
)}
/>
) : (
<Skeleton
className={cn(
'absolute inset-0 transition-opacity duration-500 rounded-lg',
'absolute inset-0 transition-opacity rounded-lg',
isLoading ? 'opacity-100' : 'opacity-0'
)}
/>
@@ -115,28 +90,38 @@ export default function Image({
alt={alt}
decoding="async"
loading="lazy"
{...props}
onLoad={handleLoad}
onError={handleError}
className={cn(
'object-cover rounded-lg w-full h-full transition-opacity duration-500',
'object-cover rounded-lg w-full h-full transition-opacity',
isLoading ? 'opacity-0' : 'opacity-100',
className
)}
width={dim?.width}
height={dim?.height}
{...props}
/>
)}
{hasError && (
<div
className={cn(
'object-cover flex flex-col items-center justify-center w-full h-full bg-muted',
className,
classNames.errorPlaceholder
)}
>
{errorPlaceholder}
</div>
)}
{hasError &&
(typeof errorPlaceholder === 'string' ? (
<img
src={errorPlaceholder}
alt={alt}
decoding="async"
loading="lazy"
className={cn('object-cover rounded-lg w-full h-full transition-opacity', className)}
/>
) : (
<div
className={cn(
'object-cover flex flex-col items-center justify-center w-full h-full bg-muted',
className,
classNames.errorPlaceholder
)}
>
{errorPlaceholder}
</div>
))}
</div>
)
}

View File

@@ -7,12 +7,10 @@ import ProfileBanner from '@/components/ProfileBanner'
import ProfileOptions from '@/components/ProfileOptions'
import ProfileZapButton from '@/components/ProfileZapButton'
import PubkeyCopy from '@/components/PubkeyCopy'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { useFetchFollowings, useFetchProfile } from '@/hooks'
import { toMuteList, toProfileEditor } from '@/lib/link'
import { generateImageByPubkey } from '@/lib/pubkey'
import { SecondaryPageLink, useSecondaryPage } from '@/PageManager'
import { useMuteList } from '@/providers/MuteListProvider'
import { useNostr } from '@/providers/NostrProvider'
@@ -22,6 +20,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import NotFound from '../NotFound'
import SearchInput from '../SearchInput'
import { SimpleUserAvatar } from '../UserAvatar'
import FollowedBy from './FollowedBy'
import Followings from './Followings'
import ProfileFeed from './ProfileFeed'
@@ -41,10 +40,6 @@ export default function Profile({ id }: { id?: string }) {
!!accountPubkey && accountPubkey !== profile?.pubkey && followings.includes(accountPubkey)
)
}, [followings, profile, accountPubkey])
const defaultImage = useMemo(
() => (profile?.pubkey ? generateImageByPubkey(profile?.pubkey) : ''),
[profile]
)
const [topContainerHeight, setTopContainerHeight] = useState(0)
const isSelf = accountPubkey === profile?.pubkey
const [topContainer, setTopContainer] = useState<HTMLDivElement | null>(null)
@@ -114,18 +109,16 @@ export default function Profile({ id }: { id?: string }) {
}
if (!profile) return <NotFound />
const { banner, username, about, avatar, pubkey, website, lightningAddress } = profile
const { banner, username, about, pubkey, website, lightningAddress } = profile
return (
<>
<div ref={topContainerRef}>
<div className="relative bg-cover bg-center mb-2">
<ProfileBanner banner={banner} pubkey={pubkey} className="w-full aspect-[3/1]" />
<Avatar className="w-24 h-24 absolute left-3 bottom-0 translate-y-1/2 border-4 border-background">
<AvatarImage src={avatar} className="object-cover object-center" />
<AvatarFallback>
<img src={defaultImage} />
</AvatarFallback>
</Avatar>
<SimpleUserAvatar
userId={pubkey}
className="w-24 h-24 absolute left-3 bottom-0 translate-y-1/2 border-4 border-background rounded-full"
/>
</div>
<div className="px-4">
<div className="flex justify-end h-8 gap-2 items-center">

View File

@@ -1,7 +1,7 @@
import { generateImageByPubkey } from '@/lib/pubkey'
import { cn } from '@/lib/utils'
import { useEffect, useMemo, useState } from 'react'
import Image from '../Image'
import { cn } from '@/lib/utils'
export default function ProfileBanner({
pubkey,
@@ -28,7 +28,7 @@ export default function ProfileBanner({
image={{ url: bannerUrl, pubkey }}
alt={`${pubkey} banner`}
className={cn('rounded-none', className)}
onError={() => setBannerUrl(defaultBanner)}
errorPlaceholder={defaultBanner}
/>
)
}

View File

@@ -1,11 +1,14 @@
import { useFetchProfile } from '@/hooks'
import { userIdToPubkey } from '@/lib/pubkey'
import { useMemo } from 'react'
import FollowButton from '../FollowButton'
import Nip05 from '../Nip05'
import ProfileAbout from '../ProfileAbout'
import { SimpleUserAvatar } from '../UserAvatar'
export default function ProfileCard({ pubkey }: { pubkey: string }) {
const { profile } = useFetchProfile(pubkey)
export default function ProfileCard({ userId }: { userId: string }) {
const pubkey = useMemo(() => userIdToPubkey(userId), [userId])
const { profile } = useFetchProfile(userId)
const { username, about } = profile || {}
return (

View File

@@ -1,4 +1,3 @@
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'
import { Skeleton } from '@/components/ui/skeleton'
import { useFetchProfile } from '@/hooks'
@@ -7,6 +6,7 @@ import { generateImageByPubkey } from '@/lib/pubkey'
import { cn } from '@/lib/utils'
import { SecondaryPageLink } from '@/PageManager'
import { useMemo } from 'react'
import Image from '../Image'
import ProfileCard from '../ProfileCard'
const UserAvatarSizeCnMap = {
@@ -29,33 +29,15 @@ export default function UserAvatar({
className?: string
size?: 'large' | 'big' | 'semiBig' | 'normal' | 'medium' | 'small' | 'xSmall' | 'tiny'
}) {
const { profile } = useFetchProfile(userId)
const defaultAvatar = useMemo(
() => (profile?.pubkey ? generateImageByPubkey(profile.pubkey) : ''),
[profile]
)
if (!profile) {
return (
<Skeleton className={cn('shrink-0', UserAvatarSizeCnMap[size], 'rounded-full', className)} />
)
}
const { avatar, pubkey } = profile
return (
<HoverCard>
<HoverCardTrigger>
<SecondaryPageLink to={toProfile(pubkey)} onClick={(e) => e.stopPropagation()}>
<Avatar className={cn('shrink-0', UserAvatarSizeCnMap[size], className)}>
<AvatarImage src={avatar} className="object-cover object-center" />
<AvatarFallback>
<img src={defaultAvatar} alt={pubkey} />
</AvatarFallback>
</Avatar>
<SecondaryPageLink to={toProfile(userId)} onClick={(e) => e.stopPropagation()}>
<SimpleUserAvatar userId={userId} size={size} className={className} />
</SecondaryPageLink>
</HoverCardTrigger>
<HoverCardContent className="w-72">
<ProfileCard pubkey={pubkey} />
<ProfileCard userId={userId} />
</HoverCardContent>
</HoverCard>
)
@@ -68,7 +50,7 @@ export function SimpleUserAvatar({
onClick
}: {
userId: string
size?: 'large' | 'big' | 'normal' | 'medium' | 'small' | 'xSmall' | 'tiny'
size?: 'large' | 'big' | 'semiBig' | 'normal' | 'medium' | 'small' | 'xSmall' | 'tiny'
className?: string
onClick?: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void
}) {
@@ -83,14 +65,17 @@ export function SimpleUserAvatar({
<Skeleton className={cn('shrink-0', UserAvatarSizeCnMap[size], 'rounded-full', className)} />
)
}
const { avatar, pubkey } = profile
const { avatar, pubkey } = profile || {}
return (
<Avatar className={cn('shrink-0', UserAvatarSizeCnMap[size], className)} onClick={onClick}>
<AvatarImage src={avatar} className="object-cover object-center" />
<AvatarFallback>
<img src={defaultAvatar} alt={pubkey} />
</AvatarFallback>
</Avatar>
<Image
image={{ url: avatar ?? defaultAvatar, pubkey }}
errorPlaceholder={defaultAvatar}
className="object-cover object-center"
classNames={{
wrapper: cn('shrink-0 rounded-full bg-background', UserAvatarSizeCnMap[size], className)
}}
onClick={onClick}
/>
)
}

View File

@@ -29,24 +29,22 @@ export default function Username({
}
if (!profile) return null
const { username, pubkey } = profile
return (
<HoverCard>
<HoverCardTrigger asChild>
<div className={className}>
<SecondaryPageLink
to={toProfile(pubkey)}
to={toProfile(userId)}
className="truncate hover:underline"
onClick={(e) => e.stopPropagation()}
>
{showAt && '@'}
{username}
{profile.username}
</SecondaryPageLink>
</div>
</HoverCardTrigger>
<HoverCardContent className="w-80">
<ProfileCard pubkey={pubkey} />
<ProfileCard userId={userId} />
</HoverCardContent>
</HoverCard>
)

View File

@@ -0,0 +1,95 @@
import client from '@/services/client.service'
import { getHashFromURL } from 'blossom-client-sdk'
class BlossomService {
static instance: BlossomService
private cacheMap = new Map<
string,
{
pubkey: string
resolve: (url: string) => void
promise: Promise<string>
tried: Set<string>
}
>()
constructor() {
if (!BlossomService.instance) {
BlossomService.instance = this
}
return BlossomService.instance
}
async getValidUrl(url: string, pubkey: string): Promise<string> {
const cache = this.cacheMap.get(url)
if (cache) {
return cache.promise
}
let resolveFunc: (url: string) => void
const promise = new Promise<string>((resolve) => {
resolveFunc = resolve
})
const tried = new Set<string>()
this.cacheMap.set(url, { pubkey, resolve: resolveFunc!, promise, tried })
return url
}
async tryNextUrl(originalUrl: string): Promise<string | null> {
const entry = this.cacheMap.get(originalUrl)
if (!entry) {
return null
}
const { pubkey, tried, resolve } = entry
let oldImageUrl: URL | undefined
let hash: string | null = null
try {
oldImageUrl = new URL(originalUrl)
hash = getHashFromURL(oldImageUrl)
} catch (error) {
console.error('Invalid image URL:', error)
}
if (!pubkey || !hash || !oldImageUrl) {
resolve(originalUrl)
return null
}
const ext = oldImageUrl.pathname.match(/\.\w+$/i)
const blossomServerList = await client.fetchBlossomServerList(pubkey)
const urls = blossomServerList
.map((server) => {
try {
return new URL(server)
} catch (error) {
console.error('Invalid Blossom server URL:', server, error)
return undefined
}
})
.filter((url) => !!url && !tried.has(url.hostname))
const nextUrl = urls[0]
if (!nextUrl) {
resolve(originalUrl)
return null
}
tried.add(nextUrl.hostname)
nextUrl.pathname = '/' + hash + ext
return nextUrl.toString()
}
markAsSuccess(originalUrl: string, successUrl: string) {
const entry = this.cacheMap.get(originalUrl)
if (!entry) return
entry.resolve(successUrl)
if (originalUrl === successUrl) {
this.cacheMap.delete(originalUrl)
}
}
}
const instance = new BlossomService()
export default instance