Browse Source

feat: add skeleton loaders to improve loading experience

imwald
codytseng 1 year ago
parent
commit
f604bdf4c1
  1. 7
      src/renderer/src/components/AccountButton/ProfileButton.tsx
  2. 1
      src/renderer/src/components/Note/index.tsx
  3. 6
      src/renderer/src/components/NoteCard/RepostNoteCard.tsx
  4. 6
      src/renderer/src/components/PostDialog/Metions.tsx
  5. 7
      src/renderer/src/components/ProfileCard/index.tsx
  6. 1
      src/renderer/src/components/ReplyNote/index.tsx
  7. 6
      src/renderer/src/components/ReplyNoteList/index.tsx
  8. 12
      src/renderer/src/components/UserAvatar/index.tsx
  9. 13
      src/renderer/src/components/Username/index.tsx
  10. 14
      src/renderer/src/hooks/useFetchProfile.tsx
  11. 19
      src/renderer/src/pages/secondary/FollowingListPage/index.tsx
  12. 12
      src/renderer/src/pages/secondary/NotePage/index.tsx
  13. 38
      src/renderer/src/pages/secondary/ProfilePage/index.tsx
  14. 2
      src/renderer/src/types.ts

7
src/renderer/src/components/AccountButton/ProfileButton.tsx

@ -20,10 +20,11 @@ export default function ProfileButton({ @@ -20,10 +20,11 @@ export default function ProfileButton({
variant?: 'titlebar' | 'sidebar'
}) {
const { logout } = useNostr()
const {
profile: { avatar, username }
} = useFetchProfile(pubkey)
const { profile } = useFetchProfile(pubkey)
const { push } = useSecondaryPage()
if (!profile) return null
const { username, avatar } = profile
const defaultAvatar = generateImageByPubkey(pubkey)
let triggerComponent: React.ReactNode

1
src/renderer/src/components/Note/index.tsx

@ -35,6 +35,7 @@ export default function Note({ @@ -35,6 +35,7 @@ export default function Note({
<Username
userId={event.pubkey}
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">
{formatTimestamp(event.created_at)}

6
src/renderer/src/components/NoteCard/RepostNoteCard.tsx

@ -16,7 +16,11 @@ export default function RepostNoteCard({ event, className }: { event: Event; cla @@ -16,7 +16,11 @@ export default function RepostNoteCard({ event, className }: { event: Event; cla
<div className={className}>
<div className="flex gap-1 mb-1 pl-4 text-sm items-center text-muted-foreground">
<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>
<ShortTextNoteCard event={targetEvent} />

6
src/renderer/src/components/PostDialog/Metions.tsx

@ -41,7 +41,11 @@ export default function Mentions({ @@ -41,7 +41,11 @@ export default function Mentions({
{pubkeys.map((pubkey, index) => (
<div key={`${pubkey}-${index}`} className="flex gap-1 items-center">
<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>

7
src/renderer/src/components/ProfileCard/index.tsx

@ -7,11 +7,12 @@ import Nip05 from '../Nip05' @@ -7,11 +7,12 @@ import Nip05 from '../Nip05'
import ProfileAbout from '../ProfileAbout'
export default function ProfileCard({ pubkey }: { pubkey: string }) {
const {
profile: { avatar = '', username, nip05, about }
} = useFetchProfile(pubkey)
const { profile } = useFetchProfile(pubkey)
const defaultImage = useMemo(() => generateImageByPubkey(pubkey), [pubkey])
if (!profile) return null
const { avatar = '', username, nip05, about } = profile
return (
<div className="w-full flex flex-col gap-2">
<div className="flex space-x-2 w-full items-start justify-between">

1
src/renderer/src/components/ReplyNote/index.tsx

@ -27,6 +27,7 @@ export default function ReplyNote({ @@ -27,6 +27,7 @@ export default function ReplyNote({
<Username
userId={event.pubkey}
className="text-sm font-semibold text-muted-foreground hover:text-foreground truncate"
skeletonClassName="h-3"
/>
{parentEvent && (
<ParentNotePreview event={parentEvent} onClick={() => onClickParent(parentEvent.id)} />

6
src/renderer/src/components/ReplyNoteList/index.tsx

@ -67,13 +67,13 @@ export default function ReplyNoteList({ event, className }: { event: Event; clas @@ -67,13 +67,13 @@ export default function ReplyNoteList({ event, className }: { event: Event; clas
return (
<>
<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}
>
{loading ? 'loading...' : hasMore ? 'load more older replies' : null}
</div>
{eventsWithParentIds.length > 0 && (loading || hasMore) && <Separator />}
<div className={cn('mt-2', className)}>
{eventsWithParentIds.length > 0 && (loading || hasMore) && <Separator className="my-4" />}
<div className={cn('mb-4', className)}>
{eventsWithParentIds.map(([event, parentEventId], index) => (
<div ref={(el) => (replyRefs.current[event.id] = el)} key={index}>
<ReplyNote

12
src/renderer/src/components/UserAvatar/index.tsx

@ -25,14 +25,16 @@ export default function UserAvatar({ @@ -25,14 +25,16 @@ export default function UserAvatar({
className?: string
size?: 'large' | 'normal' | 'small' | 'tiny'
}) {
const {
profile: { avatar, pubkey }
} = useFetchProfile(userId)
const defaultAvatar = useMemo(() => (pubkey ? generateImageByPubkey(pubkey) : ''), [pubkey])
const { profile } = useFetchProfile(userId)
const defaultAvatar = useMemo(
() => (profile?.pubkey ? generateImageByPubkey(profile.pubkey) : ''),
[profile]
)
if (!pubkey) {
if (!profile) {
return <Skeleton className={cn(UserAvatarSizeCnMap[size], 'rounded-full', className)} />
}
const { avatar, pubkey } = profile
return (
<HoverCard>

13
src/renderer/src/components/Username/index.tsx

@ -1,4 +1,5 @@ @@ -1,4 +1,5 @@
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@renderer/components/ui/hover-card'
import { Skeleton } from '@renderer/components/ui/skeleton'
import { useFetchProfile } from '@renderer/hooks'
import { toProfile } from '@renderer/lib/link'
import { cn } from '@renderer/lib/utils'
@ -8,16 +9,18 @@ import ProfileCard from '../ProfileCard' @@ -8,16 +9,18 @@ import ProfileCard from '../ProfileCard'
export default function Username({
userId,
showAt = false,
className
className,
skeletonClassName
}: {
userId: string
showAt?: boolean
className?: string
skeletonClassName?: string
}) {
const {
profile: { username, pubkey }
} = useFetchProfile(userId)
if (!pubkey) return null
const { profile } = useFetchProfile(userId)
if (!profile) return <Skeleton className={cn('w-16 my-1', skeletonClassName)} />
const { username, pubkey } = profile
return (
<HoverCard>

14
src/renderer/src/hooks/useFetchProfile.tsx

@ -7,12 +7,11 @@ import { useEffect, useState } from 'react' @@ -7,12 +7,11 @@ import { useEffect, useState } from 'react'
export function useFetchProfile(id?: string) {
const [isFetching, setIsFetching] = useState(true)
const [error, setError] = useState<Error | null>(null)
const [profile, setProfile] = useState<TProfile>({
username: id ? (id.length > 9 ? id.slice(0, 4) + '...' + id.slice(-4) : id) : 'username'
})
const [profile, setProfile] = useState<TProfile | null>(null)
useEffect(() => {
const fetchProfile = async () => {
let pubkey: string | undefined
try {
if (!id) {
setIsFetching(false)
@ -20,8 +19,6 @@ export function useFetchProfile(id?: string) { @@ -20,8 +19,6 @@ export function useFetchProfile(id?: string) {
return
}
let pubkey: string | undefined
if (/^[0-9a-f]{64}$/.test(id)) {
pubkey = id
} else {
@ -41,7 +38,6 @@ export function useFetchProfile(id?: string) { @@ -41,7 +38,6 @@ export function useFetchProfile(id?: string) {
setError(new Error('Invalid id'))
return
}
setProfile({ pubkey, username: formatPubkey(pubkey) })
const profile = await client.fetchProfile(pubkey)
if (profile) {
@ -50,6 +46,12 @@ export function useFetchProfile(id?: string) { @@ -50,6 +46,12 @@ export function useFetchProfile(id?: string) {
} catch (err) {
setError(err as Error)
} finally {
if (pubkey) {
setProfile((pre) => {
if (pre) return pre
return { pubkey, username: formatPubkey(pubkey!) } as TProfile
})
}
setIsFetching(false)
}
}

19
src/renderer/src/pages/secondary/FollowingListPage/index.tsx

@ -7,10 +7,8 @@ import SecondaryPageLayout from '@renderer/layouts/SecondaryPageLayout' @@ -7,10 +7,8 @@ import SecondaryPageLayout from '@renderer/layouts/SecondaryPageLayout'
import { useEffect, useRef, useState } from 'react'
export default function FollowingListPage({ id }: { id?: string }) {
const {
profile: { username, pubkey }
} = useFetchProfile(id)
const { followings } = useFetchFollowings(pubkey)
const { profile } = useFetchProfile(id)
const { followings } = useFetchFollowings(profile?.pubkey)
const [visibleFollowings, setVisibleFollowings] = useState<string[]>([])
const observer = useRef<IntersectionObserver | null>(null)
const bottomRef = useRef<HTMLDivElement>(null)
@ -47,7 +45,9 @@ export default function FollowingListPage({ id }: { id?: string }) { @@ -47,7 +45,9 @@ export default function FollowingListPage({ id }: { id?: string }) {
}, [visibleFollowings])
return (
<SecondaryPageLayout titlebarContent={username ? `${username}'s following` : 'following'}>
<SecondaryPageLayout
titlebarContent={profile?.username ? `${profile.username}'s following` : 'following'}
>
<div className="space-y-2">
{visibleFollowings.map((pubkey, index) => (
<FollowingItem key={`${index}-${pubkey}`} pubkey={pubkey} />
@ -59,15 +59,14 @@ export default function FollowingListPage({ id }: { id?: string }) { @@ -59,15 +59,14 @@ export default function FollowingListPage({ id }: { id?: string }) {
}
function FollowingItem({ pubkey }: { pubkey: string }) {
const {
profile: { about, nip05 }
} = useFetchProfile(pubkey)
const { profile } = useFetchProfile(pubkey)
const { nip05, about } = profile || {}
return (
<div className="flex gap-2 items-start">
<UserAvatar userId={pubkey} />
<UserAvatar userId={pubkey} className="shrink-0" />
<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} />
<div className="truncate text-muted-foreground text-sm">{about}</div>
</div>

12
src/renderer/src/pages/secondary/NotePage/index.tsx

@ -5,12 +5,12 @@ import UserAvatar from '@renderer/components/UserAvatar' @@ -5,12 +5,12 @@ import UserAvatar from '@renderer/components/UserAvatar'
import Username from '@renderer/components/Username'
import { Card } from '@renderer/components/ui/card'
import { Separator } from '@renderer/components/ui/separator'
import { Skeleton } from '@renderer/components/ui/skeleton'
import { useFetchEventById } from '@renderer/hooks'
import SecondaryPageLayout from '@renderer/layouts/SecondaryPageLayout'
import { getParentEventId, getRootEventId } from '@renderer/lib/event'
import { toNote } from '@renderer/lib/link'
import { useMemo } from 'react'
import LoadingPage from '../LoadingPage'
import NotFoundPage from '../NotFoundPage'
export default function NotePage({ id }: { id?: string }) {
@ -18,7 +18,13 @@ 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 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 />
return (
@ -44,7 +50,7 @@ function ParentNote({ eventId }: { eventId?: string }) { @@ -44,7 +50,7 @@ function ParentNote({ eventId }: { eventId?: string }) {
onClick={() => push(toNote(event.id))}
>
<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>
</Card>
<div className="ml-5 w-px h-2 bg-border" />

38
src/renderer/src/pages/secondary/ProfilePage/index.tsx

@ -18,26 +18,40 @@ import PubkeyCopy from './PubkeyCopy' @@ -18,26 +18,40 @@ import PubkeyCopy from './PubkeyCopy'
import QrCodePopover from './QrCodePopover'
import LoadingPage from '../LoadingPage'
import NotFoundPage from '../NotFoundPage'
import { Skeleton } from '@renderer/components/ui/skeleton'
export default function ProfilePage({ id }: { id?: string }) {
const {
profile: { banner, username, nip05, about, avatar, pubkey },
isFetching
} = useFetchProfile(id)
const relayList = useFetchRelayList(pubkey)
const { profile, isFetching } = useFetchProfile(id)
const relayList = useFetchRelayList(profile?.pubkey)
const { pubkey: accountPubkey } = useNostr()
const { followings: selfFollowings } = useFollowList()
const { followings } = useFetchFollowings(pubkey)
const { followings } = useFetchFollowings(profile?.pubkey)
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 isSelf = accountPubkey === pubkey
const defaultImage = useMemo(
() => (profile?.pubkey ? generateImageByPubkey(profile?.pubkey) : ''),
[profile]
)
const isSelf = accountPubkey === profile?.pubkey
if (!pubkey && isFetching) return <LoadingPage title={username} />
if (!pubkey) return <NotFoundPage />
if (!profile && isFetching) {
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 (
<SecondaryPageLayout titlebarContent={username}>
<div className="relative bg-cover bg-center w-full aspect-[21/9] rounded-lg mb-2">

2
src/renderer/src/types.ts

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
export type TProfile = {
username: string
pubkey?: string
pubkey: string
banner?: string
avatar?: string
nip05?: string

Loading…
Cancel
Save