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({
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

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

@ -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)}

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

@ -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} />

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

@ -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
src/renderer/src/components/ProfileCard/index.tsx

@ -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">

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

@ -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)} />

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

@ -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

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

@ -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>

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

@ -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>

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

@ -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)
} }
} }

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

@ -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>

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

@ -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" />

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

@ -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">

2
src/renderer/src/types.ts

@ -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…
Cancel
Save