diff --git a/src/components/Image/index.tsx b/src/components/Image/index.tsx index ce329d01..06241ba0 100644 --- a/src/components/Image/index.tsx +++ b/src/components/Image/index.tsx @@ -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() 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({ ) : ( @@ -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 && ( -
- {errorPlaceholder} -
- )} + {hasError && + (typeof errorPlaceholder === 'string' ? ( + {alt} + ) : ( +
+ {errorPlaceholder} +
+ ))} ) } diff --git a/src/components/Profile/index.tsx b/src/components/Profile/index.tsx index 12dbb529..27516b59 100644 --- a/src/components/Profile/index.tsx +++ b/src/components/Profile/index.tsx @@ -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(null) @@ -114,18 +109,16 @@ export default function Profile({ id }: { id?: string }) { } if (!profile) return - const { banner, username, about, avatar, pubkey, website, lightningAddress } = profile + const { banner, username, about, pubkey, website, lightningAddress } = profile return ( <>
- - - - - - +
diff --git a/src/components/ProfileBanner/index.tsx b/src/components/ProfileBanner/index.tsx index bfde5b33..dbf025d6 100644 --- a/src/components/ProfileBanner/index.tsx +++ b/src/components/ProfileBanner/index.tsx @@ -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} /> ) } diff --git a/src/components/ProfileCard/index.tsx b/src/components/ProfileCard/index.tsx index 6d9b6314..d167fb64 100644 --- a/src/components/ProfileCard/index.tsx +++ b/src/components/ProfileCard/index.tsx @@ -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 ( diff --git a/src/components/UserAvatar/index.tsx b/src/components/UserAvatar/index.tsx index 187beb6c..38fa3934 100644 --- a/src/components/UserAvatar/index.tsx +++ b/src/components/UserAvatar/index.tsx @@ -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 ( - - ) - } - const { avatar, pubkey } = profile - return ( - e.stopPropagation()}> - - - - {pubkey} - - + e.stopPropagation()}> + - + ) @@ -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) => void }) { @@ -83,14 +65,17 @@ export function SimpleUserAvatar({ ) } - const { avatar, pubkey } = profile + const { avatar, pubkey } = profile || {} return ( - - - - {pubkey} - - + ) } diff --git a/src/components/Username/index.tsx b/src/components/Username/index.tsx index 918ca7d6..470757e0 100644 --- a/src/components/Username/index.tsx +++ b/src/components/Username/index.tsx @@ -29,24 +29,22 @@ export default function Username({ } if (!profile) return null - const { username, pubkey } = profile - return (
e.stopPropagation()} > {showAt && '@'} - {username} + {profile.username}
- +
) diff --git a/src/services/blossom.service.ts b/src/services/blossom.service.ts new file mode 100644 index 00000000..3717e8c7 --- /dev/null +++ b/src/services/blossom.service.ts @@ -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 + tried: Set + } + >() + + constructor() { + if (!BlossomService.instance) { + BlossomService.instance = this + } + return BlossomService.instance + } + + async getValidUrl(url: string, pubkey: string): Promise { + const cache = this.cacheMap.get(url) + if (cache) { + return cache.promise + } + + let resolveFunc: (url: string) => void + const promise = new Promise((resolve) => { + resolveFunc = resolve + }) + const tried = new Set() + this.cacheMap.set(url, { pubkey, resolve: resolveFunc!, promise, tried }) + + return url + } + + async tryNextUrl(originalUrl: string): Promise { + 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