Jelajahi Sumber

Use skeleton loaders for loading images (#1200)

* loaders

* cr

* fix

* better naming

* draft img

* cr

* remove import

* fix

* fix rebase

* cr

* improving placeholders

* loading thumbnail check

* Skeleton loader fixes

Co-authored-by: Klaudiusz Dembler <kdembler@users.noreply.github.com>

* clean up transitions, improve ChannelCard loading

Co-authored-by: Klaudiusz Dembler <kdembler@users.noreply.github.com>
Co-authored-by: Klaudiusz Dembler <dev@kdembler.com>
Diego Cardenas 3 tahun lalu
induk
melakukan
f884f3367b

+ 2 - 1
src/components/ChannelCard.tsx

@@ -13,7 +13,7 @@ export type ChannelCardProps = {
 
 export const ChannelCard: React.FC<ChannelCardProps> = ({ id, className }) => {
   const { channel, loading } = useChannel(id ?? '', { skip: !id })
-  const { url } = useAsset({ entity: channel, assetType: AssetType.AVATAR })
+  const { url, isLoadingAsset } = useAsset({ entity: channel, assetType: AssetType.AVATAR })
 
   const { toggleFollowing, isFollowing } = useHandleFollowChannel(id)
 
@@ -26,6 +26,7 @@ export const ChannelCard: React.FC<ChannelCardProps> = ({ id, className }) => {
     <ChannelCardBase
       className={className}
       isLoading={loading || !channel}
+      isLoadingAvatar={isLoadingAsset}
       id={channel?.id}
       avatarUrl={url}
       follows={channel?.follows}

+ 47 - 20
src/components/VideoTile.tsx

@@ -21,18 +21,24 @@ export type VideoTileProps = {
   Pick<VideoTileBaseProps, 'progress' | 'className'>
 
 export const VideoTile: React.FC<VideoTileProps> = ({ id, onNotFound, ...metaProps }) => {
-  const { video, loading, videoHref } = useVideoSharedLogic({ id, isDraft: false, onNotFound })
-  const { url: thumbnailPhotoUrl } = useAsset({
-    entity: video,
-    assetType: AssetType.THUMBNAIL,
-  })
-  const { url: avatarPhotoUrl } = useAsset({
-    entity: video?.channel,
-    assetType: AssetType.AVATAR,
+  const {
+    video,
+    loading,
+    videoHref,
+    thumbnailPhotoUrl,
+    avatarPhotoUrl,
+    isLoadingThumbnail,
+    isLoadingAvatar,
+  } = useVideoSharedLogic({
+    id,
+    isDraft: false,
+    onNotFound,
   })
 
   return (
     <VideoTileBase
+      isLoadingThumbnail={isLoadingThumbnail}
+      isLoadingAvatar={isLoadingAvatar}
       publisherMode={false}
       title={video?.title}
       channelTitle={video?.channel.title}
@@ -45,7 +51,6 @@ export const VideoTile: React.FC<VideoTileProps> = ({ id, onNotFound, ...metaPro
       onCopyVideoURLClick={() => copyToClipboard(videoHref ? location.origin + videoHref : '')}
       thumbnailUrl={thumbnailPhotoUrl}
       isLoading={loading}
-      contentKey={id}
       {...metaProps}
     />
   )
@@ -54,21 +59,27 @@ export const VideoTile: React.FC<VideoTileProps> = ({ id, onNotFound, ...metaPro
 export type VideoTileWPublisherProps = VideoTileProps &
   Omit<VideoTilePublisherProps, 'publisherMode' | 'videoPublishState'>
 export const VideoTilePublisher: React.FC<VideoTileWPublisherProps> = ({ id, isDraft, onNotFound, ...metaProps }) => {
-  const { video, loading, videoHref } = useVideoSharedLogic({ id, isDraft, onNotFound })
-  const draft = useDraftStore(singleDraftSelector(id ?? ''))
-  const { url: thumbnailPhotoUrl } = useAsset({
-    entity: video,
-    assetType: AssetType.THUMBNAIL,
-  })
-  const { url: avatarPhotoUrl } = useAsset({
-    entity: video?.channel,
-    assetType: AssetType.AVATAR,
+  const {
+    video,
+    loading,
+    videoHref,
+    thumbnailPhotoUrl,
+    avatarPhotoUrl,
+    isLoadingThumbnail,
+    isLoadingAvatar,
+  } = useVideoSharedLogic({
+    id,
+    isDraft,
+    onNotFound,
   })
+  const draft = useDraftStore(singleDraftSelector(id ?? ''))
 
   const hasThumbnailUploadFailed = video?.thumbnailPhotoAvailability === AssetAvailability.Pending
 
   return (
     <VideoTileBase
+      isLoadingThumbnail={isLoadingThumbnail}
+      isLoadingAvatar={isLoadingAvatar}
       publisherMode
       title={isDraft ? draft?.title : video?.title}
       channelTitle={video?.channel.title}
@@ -84,7 +95,6 @@ export const VideoTilePublisher: React.FC<VideoTileWPublisherProps> = ({ id, isD
       onCopyVideoURLClick={isDraft ? undefined : () => copyToClipboard(videoHref ? location.origin + videoHref : '')}
       videoPublishState={video?.isPublic || video?.isPublic === undefined ? 'default' : 'unlisted'}
       isDraft={isDraft}
-      contentKey={id}
       {...metaProps}
     />
   )
@@ -101,7 +111,24 @@ const useVideoSharedLogic = ({ id, isDraft, onNotFound }: UseVideoSharedLogicOpt
     onCompleted: (data) => !data && onNotFound?.(),
     onError: (error) => SentryLogger.error('Failed to fetch video', 'VideoTile', error, { video: { id } }),
   })
+  const { url: thumbnailPhotoUrl, isLoadingAsset: isLoadingThumbnail } = useAsset({
+    entity: video,
+    assetType: AssetType.THUMBNAIL,
+  })
+  const { url: avatarPhotoUrl, isLoadingAsset: isLoadingAvatar } = useAsset({
+    entity: video?.channel,
+    assetType: AssetType.AVATAR,
+  })
+
   const internalIsLoadingState = loading || !id
   const videoHref = id ? absoluteRoutes.viewer.video(id) : undefined
-  return { video, loading: internalIsLoadingState, videoHref }
+  return {
+    video,
+    loading: internalIsLoadingState,
+    isLoadingThumbnail,
+    isLoadingAvatar,
+    thumbnailPhotoUrl,
+    avatarPhotoUrl,
+    videoHref,
+  }
 }

+ 2 - 0
src/providers/assets/assetsManager.tsx

@@ -42,6 +42,8 @@ export const AssetsManager: React.FC = () => {
         if (!assetUrl) {
           ConsoleLogger.warn('Unable to create asset url', resolutionData)
           addAsset(contentId, {})
+          removePendingAsset(contentId)
+          removeAssetBeingResolved(contentId)
           return
         }
 

+ 1 - 5
src/providers/assets/useAsset.tsx

@@ -17,11 +17,7 @@ export const useAsset = ({ entity, assetType }: UseAssetDataArgs) => {
     addPendingAsset(contentId, assetData)
   }, [addPendingAsset, asset, assetData, contentId, pendingAsset])
 
-  if (asset) {
-    return { url: asset.url }
-  }
-
-  return { url: null }
+  return { url: asset?.url, isLoadingAsset: !asset }
 }
 
 export const useRawAsset = (contentId: string | null) => {

+ 32 - 22
src/shared/components/ChannelCardBase/ChannelCardBase.tsx

@@ -1,6 +1,8 @@
 import React from 'react'
+import { CSSTransition, SwitchTransition } from 'react-transition-group'
 
 import { absoluteRoutes } from '@/config/routes'
+import { transitions } from '@/shared/theme'
 import { formatNumberShort } from '@/utils/number'
 
 import {
@@ -21,6 +23,7 @@ export type ChannelCardBaseProps = {
   title?: string | null
   follows?: number | null
   avatarUrl?: string | null
+  isLoadingAvatar?: boolean
   isFollowing?: boolean
   onFollow?: (event: React.MouseEvent) => void
   className?: string
@@ -33,6 +36,7 @@ export const ChannelCardBase: React.FC<ChannelCardBaseProps> = ({
   title,
   follows,
   avatarUrl,
+  isLoadingAvatar,
   isFollowing,
   onFollow,
   className,
@@ -41,28 +45,34 @@ export const ChannelCardBase: React.FC<ChannelCardBaseProps> = ({
   return (
     <ChannelCardArticle className={className}>
       <ChannelCardAnchor onClick={onClick} to={absoluteRoutes.viewer.channel(id || '')}>
-        <StyledAvatar size="channel-card" loading={isLoading} assetUrl={avatarUrl} />
-        <InfoWrapper>
-          {isLoading ? (
-            <SkeletonLoader width="100px" height="20px" bottomSpace="4px" />
-          ) : (
-            <ChannelTitle variant="h6">{title}</ChannelTitle>
-          )}
-          {isLoading ? (
-            <SkeletonLoader width="70px" height="20px" bottomSpace="16px" />
-          ) : (
-            <ChannelFollows variant="body2" secondary>
-              {formatNumberShort(follows || 0)} followers
-            </ChannelFollows>
-          )}
-          {isLoading ? (
-            <SkeletonLoader width="60px" height="30px" />
-          ) : (
-            <FollowButton variant="secondary" size="small" onClick={onFollow}>
-              {isFollowing ? 'Unfollow' : 'Follow'}
-            </FollowButton>
-          )}
-        </InfoWrapper>
+        <StyledAvatar size="channel-card" loading={isLoadingAvatar} assetUrl={avatarUrl} />
+        <SwitchTransition>
+          <CSSTransition
+            key={isLoading ? 'placeholder' : 'content'}
+            timeout={parseInt(transitions.timings.sharp)}
+            classNames={transitions.names.fade}
+          >
+            <InfoWrapper>
+              {isLoading ? (
+                <>
+                  <SkeletonLoader width="100px" height="20px" bottomSpace="4px" />
+                  <SkeletonLoader width="70px" height="20px" bottomSpace="16px" />
+                  <SkeletonLoader width="60px" height="30px" />
+                </>
+              ) : (
+                <>
+                  <ChannelTitle variant="h6">{title}</ChannelTitle>
+                  <ChannelFollows variant="body2" secondary>
+                    {formatNumberShort(follows || 0)} followers
+                  </ChannelFollows>
+                  <FollowButton variant="secondary" size="small" onClick={onFollow}>
+                    {isFollowing ? 'Unfollow' : 'Follow'}
+                  </FollowButton>
+                </>
+              )}
+            </InfoWrapper>
+          </CSSTransition>
+        </SwitchTransition>
       </ChannelCardAnchor>
     </ChannelCardArticle>
   )

+ 70 - 60
src/shared/components/VideoTileBase/VideoTileBase.tsx

@@ -103,10 +103,11 @@ export type VideoTileBaseProps = {
   views?: number | null
   thumbnailUrl?: string | null
   hasThumbnailUploadFailed?: boolean
+  isLoadingThumbnail?: boolean
+  isLoadingAvatar?: boolean
   isLoading?: boolean
   videoHref?: string
   channelHref?: string
-  contentKey?: string
   className?: string
 } & VideoTileBaseMetaProps &
   VideoTilePublisherProps
@@ -134,6 +135,8 @@ export const VideoTileBase: React.FC<VideoTileBaseProps> = ({
   onCoverResize,
   channelHref,
   videoHref,
+  isLoadingThumbnail,
+  isLoadingAvatar,
   isLoading = true,
   showChannel = true,
   showMeta = true,
@@ -146,7 +149,6 @@ export const VideoTileBase: React.FC<VideoTileBaseProps> = ({
   onPullupClick,
   onClick,
   onRemoveButtonClick,
-  contentKey,
   className,
   onOpenInTabClick,
   onEditVideoClick,
@@ -202,17 +204,17 @@ export const VideoTileBase: React.FC<VideoTileBaseProps> = ({
   return (
     <Container main={main} className={className}>
       <CoverWrapper main={main}>
-        <CoverContainer clickable={clickable}>
+        <CoverContainer ref={imgRef} clickable={clickable}>
           <SwitchTransition>
             <CSSTransition
-              key={isLoading ? 'placeholder' : `content-${contentKey}`}
+              key={isLoadingThumbnail ? 'cover-placeholder' : 'cover'}
               timeout={parseInt(transitions.timings.sharp)}
               classNames={transitions.names.fade}
             >
-              {isLoading ? (
+              {isLoadingThumbnail ? (
                 <CoverSkeletonLoader />
               ) : (
-                <CoverImageContainer ref={imgRef}>
+                <CoverImageContainer>
                   <Anchor to={videoHref ?? ''} onClick={createAnchorClickHandler(videoHref)}>
                     {thumbnailUrl && !failedLoadImage ? (
                       <CoverImage
@@ -276,16 +278,16 @@ export const VideoTileBase: React.FC<VideoTileBaseProps> = ({
           <ProgressBar style={{ width: `${progress}%` }} />
         </ProgressOverlay>
       )}
-      <SwitchTransition>
-        <CSSTransition
-          key={isLoading ? 'placeholder' : `content-${contentKey}`}
-          timeout={parseInt(transitions.timings.sharp)}
-          classNames={transitions.names.fade}
-        >
-          <InfoContainer main={main}>
-            {displayChannel && (
+      <InfoContainer main={main}>
+        {displayChannel && (
+          <SwitchTransition>
+            <CSSTransition
+              key={isLoadingAvatar ? 'avatar-placeholder' : 'avatar'}
+              timeout={parseInt(transitions.timings.sharp)}
+              classNames={transitions.names.fade}
+            >
               <AvatarContainer scalingFactor={scalingFactor}>
-                {isLoading ? (
+                {isLoading || isLoadingAvatar ? (
                   <SkeletonLoader rounded />
                 ) : (
                   <Anchor to={channelHref ?? ''} onClick={createAnchorClickHandler(channelHref)}>
@@ -297,7 +299,15 @@ export const VideoTileBase: React.FC<VideoTileBaseProps> = ({
                   </Anchor>
                 )}
               </AvatarContainer>
-            )}
+            </CSSTransition>
+          </SwitchTransition>
+        )}
+        <SwitchTransition>
+          <CSSTransition
+            key={isLoading ? 'text-placeholder' : 'text'}
+            timeout={parseInt(transitions.timings.sharp)}
+            classNames={transitions.names.fade}
+          >
             <TextContainer>
               {isLoading ? (
                 <SkeletonLoader height={main ? 45 : 18} width="60%" />
@@ -344,51 +354,51 @@ export const VideoTileBase: React.FC<VideoTileBaseProps> = ({
                 </MetaContainer>
               )}
             </TextContainer>
-            {!isLoading && (
-              <>
-                <KebabMenuIconContainer
-                  onClick={(event) => openContextMenu(event, 200)}
-                  isActive={contextMenuOpts.isActive}
-                >
-                  <SvgGlyphMore />
-                </KebabMenuIconContainer>
-                <ContextMenu contextMenuOpts={contextMenuOpts}>
-                  {publisherMode ? (
-                    <>
-                      {onOpenInTabClick && (
-                        <ContextMenuItem icon={<SvgGlyphPlay />} onClick={onOpenInTabClick}>
-                          Play in Joystream
-                        </ContextMenuItem>
-                      )}
-                      {onCopyVideoURLClick && (
-                        <ContextMenuItem icon={<SvgGlyphCopy />} onClick={onCopyVideoURLClick}>
-                          Copy video URL
-                        </ContextMenuItem>
-                      )}
-                      {onEditVideoClick && (
-                        <ContextMenuItem icon={<SvgGlyphEdit />} onClick={onEditVideoClick}>
-                          {isDraft ? 'Edit draft' : 'Edit video'}
-                        </ContextMenuItem>
-                      )}
-                      {onDeleteVideoClick && (
-                        <ContextMenuItem icon={<SvgGlyphTrash />} onClick={onDeleteVideoClick}>
-                          {isDraft ? 'Delete draft' : 'Delete video'}
-                        </ContextMenuItem>
-                      )}
-                    </>
-                  ) : (
-                    onCopyVideoURLClick && (
-                      <ContextMenuItem onClick={onCopyVideoURLClick} icon={<SvgGlyphCopy />}>
-                        Copy video URL
-                      </ContextMenuItem>
-                    )
+          </CSSTransition>
+        </SwitchTransition>
+        {!isLoading && (
+          <>
+            <KebabMenuIconContainer
+              onClick={(event) => openContextMenu(event, 200)}
+              isActive={contextMenuOpts.isActive}
+            >
+              <SvgGlyphMore />
+            </KebabMenuIconContainer>
+            <ContextMenu contextMenuOpts={contextMenuOpts}>
+              {publisherMode ? (
+                <>
+                  {onOpenInTabClick && (
+                    <ContextMenuItem icon={<SvgGlyphPlay />} onClick={onOpenInTabClick}>
+                      Play in Joystream
+                    </ContextMenuItem>
+                  )}
+                  {onCopyVideoURLClick && (
+                    <ContextMenuItem icon={<SvgGlyphCopy />} onClick={onCopyVideoURLClick}>
+                      Copy video URL
+                    </ContextMenuItem>
                   )}
-                </ContextMenu>
-              </>
-            )}
-          </InfoContainer>
-        </CSSTransition>
-      </SwitchTransition>
+                  {onEditVideoClick && (
+                    <ContextMenuItem icon={<SvgGlyphEdit />} onClick={onEditVideoClick}>
+                      {isDraft ? 'Edit draft' : 'Edit video'}
+                    </ContextMenuItem>
+                  )}
+                  {onDeleteVideoClick && (
+                    <ContextMenuItem icon={<SvgGlyphTrash />} onClick={onDeleteVideoClick}>
+                      {isDraft ? 'Delete draft' : 'Delete video'}
+                    </ContextMenuItem>
+                  )}
+                </>
+              ) : (
+                onCopyVideoURLClick && (
+                  <ContextMenuItem onClick={onCopyVideoURLClick} icon={<SvgGlyphCopy />}>
+                    Copy video URL
+                  </ContextMenuItem>
+                )
+              )}
+            </ContextMenu>
+          </>
+        )}
+      </InfoContainer>
     </Container>
   )
 }