feat: improve 🌸
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
95
src/services/blossom.service.ts
Normal file
95
src/services/blossom.service.ts
Normal 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
|
||||
Reference in New Issue
Block a user