feat: add skeleton loaders to improve loading experience
This commit is contained in:
parent
51095111f5
commit
f604bdf4c1
14 changed files with 91 additions and 53 deletions
|
|
@ -20,10 +20,11 @@ export default function ProfileButton({
|
||||||
variant?: 'titlebar' | 'sidebar'
|
variant?: 'titlebar' | 'sidebar'
|
||||||
}) {
|
}) {
|
||||||
const { logout } = useNostr()
|
const { logout } = useNostr()
|
||||||
const {
|
const { profile } = useFetchProfile(pubkey)
|
||||||
profile: { avatar, username }
|
|
||||||
} = useFetchProfile(pubkey)
|
|
||||||
const { push } = useSecondaryPage()
|
const { push } = useSecondaryPage()
|
||||||
|
if (!profile) return null
|
||||||
|
|
||||||
|
const { username, avatar } = profile
|
||||||
const defaultAvatar = generateImageByPubkey(pubkey)
|
const defaultAvatar = generateImageByPubkey(pubkey)
|
||||||
|
|
||||||
let triggerComponent: React.ReactNode
|
let triggerComponent: React.ReactNode
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ export default function Note({
|
||||||
<Username
|
<Username
|
||||||
userId={event.pubkey}
|
userId={event.pubkey}
|
||||||
className={`font-semibold flex ${size === 'small' ? 'text-sm' : ''}`}
|
className={`font-semibold flex ${size === 'small' ? 'text-sm' : ''}`}
|
||||||
|
skeletonClassName={size === 'small' ? 'h-3' : 'h-4'}
|
||||||
/>
|
/>
|
||||||
<div className="text-xs text-muted-foreground line-clamp-1">
|
<div className="text-xs text-muted-foreground line-clamp-1">
|
||||||
{formatTimestamp(event.created_at)}
|
{formatTimestamp(event.created_at)}
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,11 @@ export default function RepostNoteCard({ event, className }: { event: Event; cla
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
<div className="flex gap-1 mb-1 pl-4 text-sm items-center text-muted-foreground">
|
<div className="flex gap-1 mb-1 pl-4 text-sm items-center text-muted-foreground">
|
||||||
<Repeat2 size={16} className="shrink-0" />
|
<Repeat2 size={16} className="shrink-0" />
|
||||||
<Username userId={event.pubkey} className="font-semibold truncate" />
|
<Username
|
||||||
|
userId={event.pubkey}
|
||||||
|
className="font-semibold truncate"
|
||||||
|
skeletonClassName="h-3"
|
||||||
|
/>
|
||||||
<div>reposted</div>
|
<div>reposted</div>
|
||||||
</div>
|
</div>
|
||||||
<ShortTextNoteCard event={targetEvent} />
|
<ShortTextNoteCard event={targetEvent} />
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,11 @@ export default function Mentions({
|
||||||
{pubkeys.map((pubkey, index) => (
|
{pubkeys.map((pubkey, index) => (
|
||||||
<div key={`${pubkey}-${index}`} className="flex gap-1 items-center">
|
<div key={`${pubkey}-${index}`} className="flex gap-1 items-center">
|
||||||
<UserAvatar userId={pubkey} size="small" />
|
<UserAvatar userId={pubkey} size="small" />
|
||||||
<Username userId={pubkey} className="font-semibold text-sm truncate" />
|
<Username
|
||||||
|
userId={pubkey}
|
||||||
|
className="font-semibold text-sm truncate"
|
||||||
|
skeletonClassName="h-3"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,12 @@ import Nip05 from '../Nip05'
|
||||||
import ProfileAbout from '../ProfileAbout'
|
import ProfileAbout from '../ProfileAbout'
|
||||||
|
|
||||||
export default function ProfileCard({ pubkey }: { pubkey: string }) {
|
export default function ProfileCard({ pubkey }: { pubkey: string }) {
|
||||||
const {
|
const { profile } = useFetchProfile(pubkey)
|
||||||
profile: { avatar = '', username, nip05, about }
|
|
||||||
} = useFetchProfile(pubkey)
|
|
||||||
const defaultImage = useMemo(() => generateImageByPubkey(pubkey), [pubkey])
|
const defaultImage = useMemo(() => generateImageByPubkey(pubkey), [pubkey])
|
||||||
|
|
||||||
|
if (!profile) return null
|
||||||
|
const { avatar = '', username, nip05, about } = profile
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full flex flex-col gap-2">
|
<div className="w-full flex flex-col gap-2">
|
||||||
<div className="flex space-x-2 w-full items-start justify-between">
|
<div className="flex space-x-2 w-full items-start justify-between">
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ export default function ReplyNote({
|
||||||
<Username
|
<Username
|
||||||
userId={event.pubkey}
|
userId={event.pubkey}
|
||||||
className="text-sm font-semibold text-muted-foreground hover:text-foreground truncate"
|
className="text-sm font-semibold text-muted-foreground hover:text-foreground truncate"
|
||||||
|
skeletonClassName="h-3"
|
||||||
/>
|
/>
|
||||||
{parentEvent && (
|
{parentEvent && (
|
||||||
<ParentNotePreview event={parentEvent} onClick={() => onClickParent(parentEvent.id)} />
|
<ParentNotePreview event={parentEvent} onClick={() => onClickParent(parentEvent.id)} />
|
||||||
|
|
|
||||||
|
|
@ -67,13 +67,13 @@ export default function ReplyNoteList({ event, className }: { event: Event; clas
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className={`text-sm text-center my-2 text-muted-foreground ${!loading ? 'hover:text-foreground cursor-pointer' : ''}`}
|
className={`text-sm text-center text-muted-foreground ${!loading ? 'hover:text-foreground cursor-pointer' : ''}`}
|
||||||
onClick={loadMore}
|
onClick={loadMore}
|
||||||
>
|
>
|
||||||
{loading ? 'loading...' : hasMore ? 'load more older replies' : null}
|
{loading ? 'loading...' : hasMore ? 'load more older replies' : null}
|
||||||
</div>
|
</div>
|
||||||
{eventsWithParentIds.length > 0 && (loading || hasMore) && <Separator />}
|
{eventsWithParentIds.length > 0 && (loading || hasMore) && <Separator className="my-4" />}
|
||||||
<div className={cn('mt-2', className)}>
|
<div className={cn('mb-4', className)}>
|
||||||
{eventsWithParentIds.map(([event, parentEventId], index) => (
|
{eventsWithParentIds.map(([event, parentEventId], index) => (
|
||||||
<div ref={(el) => (replyRefs.current[event.id] = el)} key={index}>
|
<div ref={(el) => (replyRefs.current[event.id] = el)} key={index}>
|
||||||
<ReplyNote
|
<ReplyNote
|
||||||
|
|
|
||||||
|
|
@ -25,14 +25,16 @@ export default function UserAvatar({
|
||||||
className?: string
|
className?: string
|
||||||
size?: 'large' | 'normal' | 'small' | 'tiny'
|
size?: 'large' | 'normal' | 'small' | 'tiny'
|
||||||
}) {
|
}) {
|
||||||
const {
|
const { profile } = useFetchProfile(userId)
|
||||||
profile: { avatar, pubkey }
|
const defaultAvatar = useMemo(
|
||||||
} = useFetchProfile(userId)
|
() => (profile?.pubkey ? generateImageByPubkey(profile.pubkey) : ''),
|
||||||
const defaultAvatar = useMemo(() => (pubkey ? generateImageByPubkey(pubkey) : ''), [pubkey])
|
[profile]
|
||||||
|
)
|
||||||
|
|
||||||
if (!pubkey) {
|
if (!profile) {
|
||||||
return <Skeleton className={cn(UserAvatarSizeCnMap[size], 'rounded-full', className)} />
|
return <Skeleton className={cn(UserAvatarSizeCnMap[size], 'rounded-full', className)} />
|
||||||
}
|
}
|
||||||
|
const { avatar, pubkey } = profile
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HoverCard>
|
<HoverCard>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@renderer/components/ui/hover-card'
|
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@renderer/components/ui/hover-card'
|
||||||
|
import { Skeleton } from '@renderer/components/ui/skeleton'
|
||||||
import { useFetchProfile } from '@renderer/hooks'
|
import { useFetchProfile } from '@renderer/hooks'
|
||||||
import { toProfile } from '@renderer/lib/link'
|
import { toProfile } from '@renderer/lib/link'
|
||||||
import { cn } from '@renderer/lib/utils'
|
import { cn } from '@renderer/lib/utils'
|
||||||
|
|
@ -8,16 +9,18 @@ import ProfileCard from '../ProfileCard'
|
||||||
export default function Username({
|
export default function Username({
|
||||||
userId,
|
userId,
|
||||||
showAt = false,
|
showAt = false,
|
||||||
className
|
className,
|
||||||
|
skeletonClassName
|
||||||
}: {
|
}: {
|
||||||
userId: string
|
userId: string
|
||||||
showAt?: boolean
|
showAt?: boolean
|
||||||
className?: string
|
className?: string
|
||||||
|
skeletonClassName?: string
|
||||||
}) {
|
}) {
|
||||||
const {
|
const { profile } = useFetchProfile(userId)
|
||||||
profile: { username, pubkey }
|
if (!profile) return <Skeleton className={cn('w-16 my-1', skeletonClassName)} />
|
||||||
} = useFetchProfile(userId)
|
|
||||||
if (!pubkey) return null
|
const { username, pubkey } = profile
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HoverCard>
|
<HoverCard>
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,11 @@ import { useEffect, useState } from 'react'
|
||||||
export function useFetchProfile(id?: string) {
|
export function useFetchProfile(id?: string) {
|
||||||
const [isFetching, setIsFetching] = useState(true)
|
const [isFetching, setIsFetching] = useState(true)
|
||||||
const [error, setError] = useState<Error | null>(null)
|
const [error, setError] = useState<Error | null>(null)
|
||||||
const [profile, setProfile] = useState<TProfile>({
|
const [profile, setProfile] = useState<TProfile | null>(null)
|
||||||
username: id ? (id.length > 9 ? id.slice(0, 4) + '...' + id.slice(-4) : id) : 'username'
|
|
||||||
})
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchProfile = async () => {
|
const fetchProfile = async () => {
|
||||||
|
let pubkey: string | undefined
|
||||||
try {
|
try {
|
||||||
if (!id) {
|
if (!id) {
|
||||||
setIsFetching(false)
|
setIsFetching(false)
|
||||||
|
|
@ -20,8 +19,6 @@ export function useFetchProfile(id?: string) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let pubkey: string | undefined
|
|
||||||
|
|
||||||
if (/^[0-9a-f]{64}$/.test(id)) {
|
if (/^[0-9a-f]{64}$/.test(id)) {
|
||||||
pubkey = id
|
pubkey = id
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -41,7 +38,6 @@ export function useFetchProfile(id?: string) {
|
||||||
setError(new Error('Invalid id'))
|
setError(new Error('Invalid id'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setProfile({ pubkey, username: formatPubkey(pubkey) })
|
|
||||||
|
|
||||||
const profile = await client.fetchProfile(pubkey)
|
const profile = await client.fetchProfile(pubkey)
|
||||||
if (profile) {
|
if (profile) {
|
||||||
|
|
@ -50,6 +46,12 @@ export function useFetchProfile(id?: string) {
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err as Error)
|
setError(err as Error)
|
||||||
} finally {
|
} finally {
|
||||||
|
if (pubkey) {
|
||||||
|
setProfile((pre) => {
|
||||||
|
if (pre) return pre
|
||||||
|
return { pubkey, username: formatPubkey(pubkey!) } as TProfile
|
||||||
|
})
|
||||||
|
}
|
||||||
setIsFetching(false)
|
setIsFetching(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,8 @@ import SecondaryPageLayout from '@renderer/layouts/SecondaryPageLayout'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
export default function FollowingListPage({ id }: { id?: string }) {
|
export default function FollowingListPage({ id }: { id?: string }) {
|
||||||
const {
|
const { profile } = useFetchProfile(id)
|
||||||
profile: { username, pubkey }
|
const { followings } = useFetchFollowings(profile?.pubkey)
|
||||||
} = useFetchProfile(id)
|
|
||||||
const { followings } = useFetchFollowings(pubkey)
|
|
||||||
const [visibleFollowings, setVisibleFollowings] = useState<string[]>([])
|
const [visibleFollowings, setVisibleFollowings] = useState<string[]>([])
|
||||||
const observer = useRef<IntersectionObserver | null>(null)
|
const observer = useRef<IntersectionObserver | null>(null)
|
||||||
const bottomRef = useRef<HTMLDivElement>(null)
|
const bottomRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
@ -47,7 +45,9 @@ export default function FollowingListPage({ id }: { id?: string }) {
|
||||||
}, [visibleFollowings])
|
}, [visibleFollowings])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SecondaryPageLayout titlebarContent={username ? `${username}'s following` : 'following'}>
|
<SecondaryPageLayout
|
||||||
|
titlebarContent={profile?.username ? `${profile.username}'s following` : 'following'}
|
||||||
|
>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{visibleFollowings.map((pubkey, index) => (
|
{visibleFollowings.map((pubkey, index) => (
|
||||||
<FollowingItem key={`${index}-${pubkey}`} pubkey={pubkey} />
|
<FollowingItem key={`${index}-${pubkey}`} pubkey={pubkey} />
|
||||||
|
|
@ -59,15 +59,14 @@ export default function FollowingListPage({ id }: { id?: string }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function FollowingItem({ pubkey }: { pubkey: string }) {
|
function FollowingItem({ pubkey }: { pubkey: string }) {
|
||||||
const {
|
const { profile } = useFetchProfile(pubkey)
|
||||||
profile: { about, nip05 }
|
const { nip05, about } = profile || {}
|
||||||
} = useFetchProfile(pubkey)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-2 items-start">
|
<div className="flex gap-2 items-start">
|
||||||
<UserAvatar userId={pubkey} />
|
<UserAvatar userId={pubkey} className="shrink-0" />
|
||||||
<div className="w-full overflow-hidden">
|
<div className="w-full overflow-hidden">
|
||||||
<Username userId={pubkey} className="font-semibold truncate" />
|
<Username userId={pubkey} className="font-semibold truncate" skeletonClassName="h-4" />
|
||||||
<Nip05 nip05={nip05} pubkey={pubkey} />
|
<Nip05 nip05={nip05} pubkey={pubkey} />
|
||||||
<div className="truncate text-muted-foreground text-sm">{about}</div>
|
<div className="truncate text-muted-foreground text-sm">{about}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,12 @@ import UserAvatar from '@renderer/components/UserAvatar'
|
||||||
import Username from '@renderer/components/Username'
|
import Username from '@renderer/components/Username'
|
||||||
import { Card } from '@renderer/components/ui/card'
|
import { Card } from '@renderer/components/ui/card'
|
||||||
import { Separator } from '@renderer/components/ui/separator'
|
import { Separator } from '@renderer/components/ui/separator'
|
||||||
|
import { Skeleton } from '@renderer/components/ui/skeleton'
|
||||||
import { useFetchEventById } from '@renderer/hooks'
|
import { useFetchEventById } from '@renderer/hooks'
|
||||||
import SecondaryPageLayout from '@renderer/layouts/SecondaryPageLayout'
|
import SecondaryPageLayout from '@renderer/layouts/SecondaryPageLayout'
|
||||||
import { getParentEventId, getRootEventId } from '@renderer/lib/event'
|
import { getParentEventId, getRootEventId } from '@renderer/lib/event'
|
||||||
import { toNote } from '@renderer/lib/link'
|
import { toNote } from '@renderer/lib/link'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import LoadingPage from '../LoadingPage'
|
|
||||||
import NotFoundPage from '../NotFoundPage'
|
import NotFoundPage from '../NotFoundPage'
|
||||||
|
|
||||||
export default function NotePage({ id }: { id?: string }) {
|
export default function NotePage({ id }: { id?: string }) {
|
||||||
|
|
@ -18,7 +18,13 @@ export default function NotePage({ id }: { id?: string }) {
|
||||||
const parentEventId = useMemo(() => getParentEventId(event), [event])
|
const parentEventId = useMemo(() => getParentEventId(event), [event])
|
||||||
const rootEventId = useMemo(() => getRootEventId(event), [event])
|
const rootEventId = useMemo(() => getRootEventId(event), [event])
|
||||||
|
|
||||||
if (!event && isFetching) return <LoadingPage title="note" />
|
if (!event && isFetching) {
|
||||||
|
return (
|
||||||
|
<SecondaryPageLayout titlebarContent="note">
|
||||||
|
<Skeleton className="w-10 h-10 rounded-full" />
|
||||||
|
</SecondaryPageLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
if (!event) return <NotFoundPage />
|
if (!event) return <NotFoundPage />
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -44,7 +50,7 @@ function ParentNote({ eventId }: { eventId?: string }) {
|
||||||
onClick={() => push(toNote(event.id))}
|
onClick={() => push(toNote(event.id))}
|
||||||
>
|
>
|
||||||
<UserAvatar userId={event.pubkey} size="tiny" />
|
<UserAvatar userId={event.pubkey} size="tiny" />
|
||||||
<Username userId={event.pubkey} className="font-semibold" />
|
<Username userId={event.pubkey} className="font-semibold" skeletonClassName="h-4" />
|
||||||
<div className="truncate">{event.content}</div>
|
<div className="truncate">{event.content}</div>
|
||||||
</Card>
|
</Card>
|
||||||
<div className="ml-5 w-px h-2 bg-border" />
|
<div className="ml-5 w-px h-2 bg-border" />
|
||||||
|
|
|
||||||
|
|
@ -18,26 +18,40 @@ import PubkeyCopy from './PubkeyCopy'
|
||||||
import QrCodePopover from './QrCodePopover'
|
import QrCodePopover from './QrCodePopover'
|
||||||
import LoadingPage from '../LoadingPage'
|
import LoadingPage from '../LoadingPage'
|
||||||
import NotFoundPage from '../NotFoundPage'
|
import NotFoundPage from '../NotFoundPage'
|
||||||
|
import { Skeleton } from '@renderer/components/ui/skeleton'
|
||||||
|
|
||||||
export default function ProfilePage({ id }: { id?: string }) {
|
export default function ProfilePage({ id }: { id?: string }) {
|
||||||
const {
|
const { profile, isFetching } = useFetchProfile(id)
|
||||||
profile: { banner, username, nip05, about, avatar, pubkey },
|
const relayList = useFetchRelayList(profile?.pubkey)
|
||||||
isFetching
|
|
||||||
} = useFetchProfile(id)
|
|
||||||
const relayList = useFetchRelayList(pubkey)
|
|
||||||
const { pubkey: accountPubkey } = useNostr()
|
const { pubkey: accountPubkey } = useNostr()
|
||||||
const { followings: selfFollowings } = useFollowList()
|
const { followings: selfFollowings } = useFollowList()
|
||||||
const { followings } = useFetchFollowings(pubkey)
|
const { followings } = useFetchFollowings(profile?.pubkey)
|
||||||
const isFollowingYou = useMemo(
|
const isFollowingYou = useMemo(
|
||||||
() => !!accountPubkey && accountPubkey !== pubkey && followings.includes(accountPubkey),
|
() =>
|
||||||
[followings, pubkey]
|
!!accountPubkey && accountPubkey !== profile?.pubkey && followings.includes(accountPubkey),
|
||||||
|
[followings, profile]
|
||||||
)
|
)
|
||||||
const defaultImage = useMemo(() => (pubkey ? generateImageByPubkey(pubkey) : ''), [pubkey])
|
const defaultImage = useMemo(
|
||||||
const isSelf = accountPubkey === pubkey
|
() => (profile?.pubkey ? generateImageByPubkey(profile?.pubkey) : ''),
|
||||||
|
[profile]
|
||||||
|
)
|
||||||
|
const isSelf = accountPubkey === profile?.pubkey
|
||||||
|
|
||||||
if (!pubkey && isFetching) return <LoadingPage title={username} />
|
if (!profile && isFetching) {
|
||||||
if (!pubkey) return <NotFoundPage />
|
return (
|
||||||
|
<SecondaryPageLayout>
|
||||||
|
<div className="relative bg-cover bg-center w-full aspect-[21/9] rounded-lg mb-2">
|
||||||
|
<Skeleton className="w-full h-full object-cover rounded-lg" />
|
||||||
|
<Skeleton className="w-24 h-24 absolute bottom-0 left-4 translate-y-1/2 border-4 border-background rounded-full" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-5 w-28 mt-14 mb-1" />
|
||||||
|
<Skeleton className="h-5 w-56 mt-2 my-1 rounded-full" />
|
||||||
|
</SecondaryPageLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!profile) return <NotFoundPage />
|
||||||
|
|
||||||
|
const { banner, username, nip05, about, avatar, pubkey } = profile
|
||||||
return (
|
return (
|
||||||
<SecondaryPageLayout titlebarContent={username}>
|
<SecondaryPageLayout titlebarContent={username}>
|
||||||
<div className="relative bg-cover bg-center w-full aspect-[21/9] rounded-lg mb-2">
|
<div className="relative bg-cover bg-center w-full aspect-[21/9] rounded-lg mb-2">
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
export type TProfile = {
|
export type TProfile = {
|
||||||
username: string
|
username: string
|
||||||
pubkey?: string
|
pubkey: string
|
||||||
banner?: string
|
banner?: string
|
||||||
avatar?: string
|
avatar?: string
|
||||||
nip05?: string
|
nip05?: string
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue