feat: improve 🌸
This commit is contained in:
@@ -1,8 +1,7 @@
|
|||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import client from '@/services/client.service'
|
import blossomService from '@/services/blossom.service'
|
||||||
import { TImetaInfo } from '@/types'
|
import { TImetaInfo } from '@/types'
|
||||||
import { getHashFromURL } from 'blossom-client-sdk'
|
|
||||||
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'
|
||||||
@@ -28,63 +27,39 @@ export default function Image({
|
|||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
const [displaySkeleton, setDisplaySkeleton] = useState(true)
|
const [displaySkeleton, setDisplaySkeleton] = useState(true)
|
||||||
const [hasError, setHasError] = useState(false)
|
const [hasError, setHasError] = useState(false)
|
||||||
const [imageUrl, setImageUrl] = useState(url)
|
const [imageUrl, setImageUrl] = useState<string>()
|
||||||
const [tried, setTried] = useState(new Set())
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setImageUrl(url)
|
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
setHasError(false)
|
setHasError(false)
|
||||||
setDisplaySkeleton(true)
|
setDisplaySkeleton(true)
|
||||||
setTried(new Set())
|
|
||||||
|
if (pubkey) {
|
||||||
|
blossomService.getValidUrl(url, pubkey).then((validUrl) => {
|
||||||
|
setImageUrl(validUrl)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
setImageUrl(url)
|
||||||
|
}
|
||||||
}, [url])
|
}, [url])
|
||||||
|
|
||||||
if (hideIfError && hasError) return null
|
if (hideIfError && hasError) return null
|
||||||
|
|
||||||
const handleError = async () => {
|
const handleError = async () => {
|
||||||
let oldImageUrl: URL | undefined
|
const nextUrl = await blossomService.tryNextUrl(url)
|
||||||
let hash: string | null = null
|
if (nextUrl) {
|
||||||
try {
|
setImageUrl(nextUrl)
|
||||||
oldImageUrl = new URL(imageUrl)
|
} else {
|
||||||
hash = getHashFromURL(oldImageUrl)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Invalid image URL:', error)
|
|
||||||
}
|
|
||||||
if (!pubkey || !hash || !oldImageUrl) {
|
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
setHasError(true)
|
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 = () => {
|
const handleLoad = () => {
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
setHasError(false)
|
setHasError(false)
|
||||||
setTimeout(() => setDisplaySkeleton(false), 600)
|
setTimeout(() => setDisplaySkeleton(false), 600)
|
||||||
|
blossomService.markAsSuccess(url, imageUrl || url)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -95,14 +70,14 @@ export default function Image({
|
|||||||
<BlurHashCanvas
|
<BlurHashCanvas
|
||||||
blurHash={blurHash}
|
blurHash={blurHash}
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute inset-0 transition-opacity duration-500 rounded-lg',
|
'absolute inset-0 transition-opacity rounded-lg',
|
||||||
isLoading ? 'opacity-100' : 'opacity-0'
|
isLoading ? 'opacity-100' : 'opacity-0'
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Skeleton
|
<Skeleton
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute inset-0 transition-opacity duration-500 rounded-lg',
|
'absolute inset-0 transition-opacity rounded-lg',
|
||||||
isLoading ? 'opacity-100' : 'opacity-0'
|
isLoading ? 'opacity-100' : 'opacity-0'
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -115,28 +90,38 @@ export default function Image({
|
|||||||
alt={alt}
|
alt={alt}
|
||||||
decoding="async"
|
decoding="async"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
|
{...props}
|
||||||
onLoad={handleLoad}
|
onLoad={handleLoad}
|
||||||
onError={handleError}
|
onError={handleError}
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
width={dim?.width}
|
width={dim?.width}
|
||||||
height={dim?.height}
|
height={dim?.height}
|
||||||
{...props}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{hasError && (
|
{hasError &&
|
||||||
<div
|
(typeof errorPlaceholder === 'string' ? (
|
||||||
className={cn(
|
<img
|
||||||
'object-cover flex flex-col items-center justify-center w-full h-full bg-muted',
|
src={errorPlaceholder}
|
||||||
className,
|
alt={alt}
|
||||||
classNames.errorPlaceholder
|
decoding="async"
|
||||||
)}
|
loading="lazy"
|
||||||
>
|
className={cn('object-cover rounded-lg w-full h-full transition-opacity', className)}
|
||||||
{errorPlaceholder}
|
/>
|
||||||
</div>
|
) : (
|
||||||
)}
|
<div
|
||||||
|
className={cn(
|
||||||
|
'object-cover flex flex-col items-center justify-center w-full h-full bg-muted',
|
||||||
|
className,
|
||||||
|
classNames.errorPlaceholder
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{errorPlaceholder}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,12 +7,10 @@ import ProfileBanner from '@/components/ProfileBanner'
|
|||||||
import ProfileOptions from '@/components/ProfileOptions'
|
import ProfileOptions from '@/components/ProfileOptions'
|
||||||
import ProfileZapButton from '@/components/ProfileZapButton'
|
import ProfileZapButton from '@/components/ProfileZapButton'
|
||||||
import PubkeyCopy from '@/components/PubkeyCopy'
|
import PubkeyCopy from '@/components/PubkeyCopy'
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { useFetchFollowings, useFetchProfile } from '@/hooks'
|
import { useFetchFollowings, useFetchProfile } from '@/hooks'
|
||||||
import { toMuteList, toProfileEditor } from '@/lib/link'
|
import { toMuteList, toProfileEditor } from '@/lib/link'
|
||||||
import { generateImageByPubkey } from '@/lib/pubkey'
|
|
||||||
import { SecondaryPageLink, useSecondaryPage } from '@/PageManager'
|
import { SecondaryPageLink, useSecondaryPage } from '@/PageManager'
|
||||||
import { useMuteList } from '@/providers/MuteListProvider'
|
import { useMuteList } from '@/providers/MuteListProvider'
|
||||||
import { useNostr } from '@/providers/NostrProvider'
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
@@ -22,6 +20,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import NotFound from '../NotFound'
|
import NotFound from '../NotFound'
|
||||||
import SearchInput from '../SearchInput'
|
import SearchInput from '../SearchInput'
|
||||||
|
import { SimpleUserAvatar } from '../UserAvatar'
|
||||||
import FollowedBy from './FollowedBy'
|
import FollowedBy from './FollowedBy'
|
||||||
import Followings from './Followings'
|
import Followings from './Followings'
|
||||||
import ProfileFeed from './ProfileFeed'
|
import ProfileFeed from './ProfileFeed'
|
||||||
@@ -41,10 +40,6 @@ export default function Profile({ id }: { id?: string }) {
|
|||||||
!!accountPubkey && accountPubkey !== profile?.pubkey && followings.includes(accountPubkey)
|
!!accountPubkey && accountPubkey !== profile?.pubkey && followings.includes(accountPubkey)
|
||||||
)
|
)
|
||||||
}, [followings, profile, accountPubkey])
|
}, [followings, profile, accountPubkey])
|
||||||
const defaultImage = useMemo(
|
|
||||||
() => (profile?.pubkey ? generateImageByPubkey(profile?.pubkey) : ''),
|
|
||||||
[profile]
|
|
||||||
)
|
|
||||||
const [topContainerHeight, setTopContainerHeight] = useState(0)
|
const [topContainerHeight, setTopContainerHeight] = useState(0)
|
||||||
const isSelf = accountPubkey === profile?.pubkey
|
const isSelf = accountPubkey === profile?.pubkey
|
||||||
const [topContainer, setTopContainer] = useState<HTMLDivElement | null>(null)
|
const [topContainer, setTopContainer] = useState<HTMLDivElement | null>(null)
|
||||||
@@ -114,18 +109,16 @@ export default function Profile({ id }: { id?: string }) {
|
|||||||
}
|
}
|
||||||
if (!profile) return <NotFound />
|
if (!profile) return <NotFound />
|
||||||
|
|
||||||
const { banner, username, about, avatar, pubkey, website, lightningAddress } = profile
|
const { banner, username, about, pubkey, website, lightningAddress } = profile
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div ref={topContainerRef}>
|
<div ref={topContainerRef}>
|
||||||
<div className="relative bg-cover bg-center mb-2">
|
<div className="relative bg-cover bg-center mb-2">
|
||||||
<ProfileBanner banner={banner} pubkey={pubkey} className="w-full aspect-[3/1]" />
|
<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">
|
<SimpleUserAvatar
|
||||||
<AvatarImage src={avatar} className="object-cover object-center" />
|
userId={pubkey}
|
||||||
<AvatarFallback>
|
className="w-24 h-24 absolute left-3 bottom-0 translate-y-1/2 border-4 border-background rounded-full"
|
||||||
<img src={defaultImage} />
|
/>
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="px-4">
|
<div className="px-4">
|
||||||
<div className="flex justify-end h-8 gap-2 items-center">
|
<div className="flex justify-end h-8 gap-2 items-center">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { generateImageByPubkey } from '@/lib/pubkey'
|
import { generateImageByPubkey } from '@/lib/pubkey'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import Image from '../Image'
|
import Image from '../Image'
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
|
|
||||||
export default function ProfileBanner({
|
export default function ProfileBanner({
|
||||||
pubkey,
|
pubkey,
|
||||||
@@ -28,7 +28,7 @@ export default function ProfileBanner({
|
|||||||
image={{ url: bannerUrl, pubkey }}
|
image={{ url: bannerUrl, pubkey }}
|
||||||
alt={`${pubkey} banner`}
|
alt={`${pubkey} banner`}
|
||||||
className={cn('rounded-none', className)}
|
className={cn('rounded-none', className)}
|
||||||
onError={() => setBannerUrl(defaultBanner)}
|
errorPlaceholder={defaultBanner}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { useFetchProfile } from '@/hooks'
|
import { useFetchProfile } from '@/hooks'
|
||||||
|
import { userIdToPubkey } from '@/lib/pubkey'
|
||||||
|
import { useMemo } from 'react'
|
||||||
import FollowButton from '../FollowButton'
|
import FollowButton from '../FollowButton'
|
||||||
import Nip05 from '../Nip05'
|
import Nip05 from '../Nip05'
|
||||||
import ProfileAbout from '../ProfileAbout'
|
import ProfileAbout from '../ProfileAbout'
|
||||||
import { SimpleUserAvatar } from '../UserAvatar'
|
import { SimpleUserAvatar } from '../UserAvatar'
|
||||||
|
|
||||||
export default function ProfileCard({ pubkey }: { pubkey: string }) {
|
export default function ProfileCard({ userId }: { userId: string }) {
|
||||||
const { profile } = useFetchProfile(pubkey)
|
const pubkey = useMemo(() => userIdToPubkey(userId), [userId])
|
||||||
|
const { profile } = useFetchProfile(userId)
|
||||||
const { username, about } = profile || {}
|
const { username, about } = profile || {}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
|
||||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'
|
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { useFetchProfile } from '@/hooks'
|
import { useFetchProfile } from '@/hooks'
|
||||||
@@ -7,6 +6,7 @@ import { generateImageByPubkey } from '@/lib/pubkey'
|
|||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { SecondaryPageLink } from '@/PageManager'
|
import { SecondaryPageLink } from '@/PageManager'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
|
import Image from '../Image'
|
||||||
import ProfileCard from '../ProfileCard'
|
import ProfileCard from '../ProfileCard'
|
||||||
|
|
||||||
const UserAvatarSizeCnMap = {
|
const UserAvatarSizeCnMap = {
|
||||||
@@ -29,33 +29,15 @@ export default function UserAvatar({
|
|||||||
className?: string
|
className?: string
|
||||||
size?: 'large' | 'big' | 'semiBig' | 'normal' | 'medium' | 'small' | 'xSmall' | 'tiny'
|
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 (
|
return (
|
||||||
<HoverCard>
|
<HoverCard>
|
||||||
<HoverCardTrigger>
|
<HoverCardTrigger>
|
||||||
<SecondaryPageLink to={toProfile(pubkey)} onClick={(e) => e.stopPropagation()}>
|
<SecondaryPageLink to={toProfile(userId)} onClick={(e) => e.stopPropagation()}>
|
||||||
<Avatar className={cn('shrink-0', UserAvatarSizeCnMap[size], className)}>
|
<SimpleUserAvatar userId={userId} size={size} className={className} />
|
||||||
<AvatarImage src={avatar} className="object-cover object-center" />
|
|
||||||
<AvatarFallback>
|
|
||||||
<img src={defaultAvatar} alt={pubkey} />
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
</SecondaryPageLink>
|
</SecondaryPageLink>
|
||||||
</HoverCardTrigger>
|
</HoverCardTrigger>
|
||||||
<HoverCardContent className="w-72">
|
<HoverCardContent className="w-72">
|
||||||
<ProfileCard pubkey={pubkey} />
|
<ProfileCard userId={userId} />
|
||||||
</HoverCardContent>
|
</HoverCardContent>
|
||||||
</HoverCard>
|
</HoverCard>
|
||||||
)
|
)
|
||||||
@@ -68,7 +50,7 @@ export function SimpleUserAvatar({
|
|||||||
onClick
|
onClick
|
||||||
}: {
|
}: {
|
||||||
userId: string
|
userId: string
|
||||||
size?: 'large' | 'big' | 'normal' | 'medium' | 'small' | 'xSmall' | 'tiny'
|
size?: 'large' | 'big' | 'semiBig' | 'normal' | 'medium' | 'small' | 'xSmall' | 'tiny'
|
||||||
className?: string
|
className?: string
|
||||||
onClick?: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void
|
onClick?: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void
|
||||||
}) {
|
}) {
|
||||||
@@ -83,14 +65,17 @@ export function SimpleUserAvatar({
|
|||||||
<Skeleton className={cn('shrink-0', UserAvatarSizeCnMap[size], 'rounded-full', className)} />
|
<Skeleton className={cn('shrink-0', UserAvatarSizeCnMap[size], 'rounded-full', className)} />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const { avatar, pubkey } = profile
|
const { avatar, pubkey } = profile || {}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Avatar className={cn('shrink-0', UserAvatarSizeCnMap[size], className)} onClick={onClick}>
|
<Image
|
||||||
<AvatarImage src={avatar} className="object-cover object-center" />
|
image={{ url: avatar ?? defaultAvatar, pubkey }}
|
||||||
<AvatarFallback>
|
errorPlaceholder={defaultAvatar}
|
||||||
<img src={defaultAvatar} alt={pubkey} />
|
className="object-cover object-center"
|
||||||
</AvatarFallback>
|
classNames={{
|
||||||
</Avatar>
|
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
|
if (!profile) return null
|
||||||
|
|
||||||
const { username, pubkey } = profile
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HoverCard>
|
<HoverCard>
|
||||||
<HoverCardTrigger asChild>
|
<HoverCardTrigger asChild>
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
<SecondaryPageLink
|
<SecondaryPageLink
|
||||||
to={toProfile(pubkey)}
|
to={toProfile(userId)}
|
||||||
className="truncate hover:underline"
|
className="truncate hover:underline"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{showAt && '@'}
|
{showAt && '@'}
|
||||||
{username}
|
{profile.username}
|
||||||
</SecondaryPageLink>
|
</SecondaryPageLink>
|
||||||
</div>
|
</div>
|
||||||
</HoverCardTrigger>
|
</HoverCardTrigger>
|
||||||
<HoverCardContent className="w-80">
|
<HoverCardContent className="w-80">
|
||||||
<ProfileCard pubkey={pubkey} />
|
<ProfileCard userId={userId} />
|
||||||
</HoverCardContent>
|
</HoverCardContent>
|
||||||
</HoverCard>
|
</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