Browse Source

refactor infinite grid, add channels view (#218)

* refactor infinite grid, add channels view

* fix test
Klaudiusz Dembler 4 năm trước cách đây
mục cha
commit
5427880b9b

+ 1 - 1
package.json

@@ -41,7 +41,7 @@
     ]
   },
   "dependencies": {
-    "@apollo/client": "^3.1.1",
+    "@apollo/client": "^3.3.0",
     "@emotion/babel-preset-css-prop": "^10.0.27",
     "@emotion/core": "^10.0.28",
     "@joystream/eslint-config": "^1.0.0",

+ 1 - 1
src/App.tsx

@@ -2,7 +2,7 @@ import React from 'react'
 import { ApolloProvider } from '@apollo/client'
 
 import { client } from '@/api'
-import { LayoutWithRouting } from '@/components'
+import LayoutWithRouting from '@/views/LayoutWithRouting'
 
 export default function App() {
   return (

+ 1 - 1
src/__tests__/LayoutWithRouting.test.tsx

@@ -1,6 +1,6 @@
 import React from 'react'
 import { shallow } from 'enzyme'
-import { LayoutWithRouting } from '../components'
+import LayoutWithRouting from '../views/LayoutWithRouting'
 
 describe('LayoutWithRouting component', () => {
   const component = shallow(<LayoutWithRouting />)

+ 97 - 0
src/components/InfiniteGrids/InfiniteChannelGrid.tsx

@@ -0,0 +1,97 @@
+import React, { useCallback, useState } from 'react'
+import styled from '@emotion/styled'
+import { css } from '@emotion/core'
+
+import { sizes } from '@/shared/theme'
+import { ChannelPreviewBase, Grid, Text } from '@/shared/components'
+import { GET_NEWEST_CHANNELS } from '@/api/queries'
+import { GetNewestChannels, GetNewestChannelsVariables } from '@/api/queries/__generated__/GetNewestChannels'
+import ChannelPreview from '@/components/ChannelPreviewWithNavigation'
+import useInfiniteGrid from './useInfiniteGrid'
+
+type InfiniteChannelGridProps = {
+  title?: string
+  skipCount?: number
+  ready?: boolean
+  className?: string
+}
+
+const INITIAL_ROWS = 4
+const INITIAL_CHANNELS_PER_ROW = 4
+const QUERY_VARIABLES = {}
+
+const InfiniteChannelGrid: React.FC<InfiniteChannelGridProps> = ({ title, skipCount = 0, ready = true, className }) => {
+  const [channelsPerRow, setChannelsPerRow] = useState(INITIAL_CHANNELS_PER_ROW)
+  const [targetRowsCount, setTargetRowsCount] = useState(INITIAL_ROWS)
+
+  const onScrollToBottom = useCallback(() => {
+    setTargetRowsCount((prevState) => prevState + 2)
+  }, [])
+
+  const { placeholdersCount, displayedItems, error } = useInfiniteGrid<
+    GetNewestChannels,
+    GetNewestChannels['channelsConnection'],
+    GetNewestChannelsVariables
+  >({
+    query: GET_NEWEST_CHANNELS,
+    onScrollToBottom,
+    isReady: ready,
+    skipCount,
+    queryVariables: QUERY_VARIABLES,
+    targetRowsCount,
+    dataAccessor: (rawData) => rawData?.channelsConnection,
+    itemsPerRow: channelsPerRow,
+  })
+
+  if (error) {
+    throw error
+  }
+
+  const gridContent = (
+    <>
+      {displayedItems.map((channel) => (
+        <StyledChannelPreview
+          key={channel.id}
+          id={channel.id}
+          name={channel.handle}
+          avatarURL={channel.avatarPhotoUrl}
+          animated
+        />
+      ))}
+      {Array.from({ length: placeholdersCount }, (_, idx) => (
+        <StyledChannelPreviewBase key={idx} />
+      ))}
+    </>
+  )
+
+  if (displayedItems.length <= 0 && placeholdersCount <= 0) {
+    return null
+  }
+
+  return (
+    <section className={className}>
+      {title && <Title variant="h5">{title}</Title>}
+      <Grid onResize={(sizes) => setChannelsPerRow(sizes.length)} minWidth={200} maxColumns={null}>
+        {gridContent}
+      </Grid>
+    </section>
+  )
+}
+
+const Title = styled(Text)`
+  margin-bottom: ${sizes(4)};
+`
+
+const previewCss = css`
+  margin: 0 auto;
+`
+
+const StyledChannelPreview = styled(ChannelPreview)`
+  ${previewCss};
+`
+
+const StyledChannelPreviewBase = styled(ChannelPreviewBase)`
+  ${previewCss};
+`
+
+export default InfiniteChannelGrid

+ 134 - 0
src/components/InfiniteGrids/InfiniteVideoGrid.tsx

@@ -0,0 +1,134 @@
+import React, { useCallback, useEffect, useState } from 'react'
+import styled from '@emotion/styled'
+
+import { sizes } from '@/shared/theme'
+import { Grid, Text, VideoPreviewBase } from '@/shared/components'
+import VideoPreview from '@/components/VideoPreviewWithNavigation'
+import { GET_NEWEST_VIDEOS } from '@/api/queries'
+import { GetNewestVideos, GetNewestVideosVariables } from '@/api/queries/__generated__/GetNewestVideos'
+import useInfiniteGrid from './useInfiniteGrid'
+
+type InfiniteVideoGridProps = {
+  title?: string
+  categoryId?: string
+  channelId?: string
+  skipCount?: number
+  ready?: boolean
+  showChannel?: boolean
+  className?: string
+}
+
+const INITIAL_ROWS = 4
+const INITIAL_VIDEOS_PER_ROW = 4
+
+const InfiniteVideoGrid: React.FC<InfiniteVideoGridProps> = ({
+  title,
+  categoryId = '',
+  channelId,
+  skipCount = 0,
+  ready = true,
+  showChannel = true,
+  className,
+}) => {
+  const [videosPerRow, setVideosPerRow] = useState(INITIAL_VIDEOS_PER_ROW)
+  const [queryVariables, setQueryVariables] = useState({
+    ...(channelId ? { channelId } : {}),
+    ...(categoryId ? { categoryId } : {}),
+  })
+
+  const [targetRowsCountByCategory, setTargetRowsCountByCategory] = useState<Record<string, number>>({
+    [categoryId]: INITIAL_ROWS,
+  })
+  const [cachedCategoryId, setCachedCategoryId] = useState<string>(categoryId)
+
+  const targetRowsCount = targetRowsCountByCategory[cachedCategoryId]
+
+  const onScrollToBottom = useCallback(() => {
+    setTargetRowsCountByCategory((prevState) => ({
+      ...prevState,
+      [cachedCategoryId]: targetRowsCount + 2,
+    }))
+  }, [cachedCategoryId, targetRowsCount])
+
+  const { placeholdersCount, displayedItems, error } = useInfiniteGrid<
+    GetNewestVideos,
+    GetNewestVideos['videosConnection'],
+    GetNewestVideosVariables
+  >({
+    query: GET_NEWEST_VIDEOS,
+    onScrollToBottom,
+    isReady: ready,
+    skipCount,
+    queryVariables,
+    targetRowsCount,
+    dataAccessor: (rawData) => rawData?.videosConnection,
+    itemsPerRow: videosPerRow,
+  })
+
+  if (error) {
+    throw error
+  }
+
+  // handle category change
+  // TODO potentially move into useInfiniteGrid as a general rule - keep separate targetRowsCount per serialized queryVariables
+  useEffect(() => {
+    if (categoryId === cachedCategoryId) {
+      return
+    }
+
+    setCachedCategoryId(categoryId)
+
+    setQueryVariables({
+      ...(channelId ? { channelId } : {}),
+      ...(categoryId ? { categoryId } : {}),
+    })
+
+    const categoryRowsSet = !!targetRowsCountByCategory[categoryId]
+    const categoryRowsCount = categoryRowsSet ? targetRowsCountByCategory[categoryId] : INITIAL_ROWS
+    if (!categoryRowsSet) {
+      setTargetRowsCountByCategory((prevState) => ({
+        ...prevState,
+        [categoryId]: categoryRowsCount,
+      }))
+    }
+  }, [categoryId, channelId, cachedCategoryId, targetRowsCountByCategory, videosPerRow, skipCount])
+
+  const gridContent = (
+    <>
+      {displayedItems.map((v) => (
+        <VideoPreview
+          id={v.id}
+          channelId={v.channel.id}
+          title={v.title}
+          channelName={v.channel.handle}
+          channelAvatarURL={v.channel.avatarPhotoUrl}
+          createdAt={v.createdAt}
+          views={v.views}
+          posterURL={v.thumbnailUrl}
+          showChannel={showChannel}
+          key={v.id}
+        />
+      ))}
+      {Array.from({ length: placeholdersCount }, (_, idx) => (
+        <VideoPreviewBase key={idx} showChannel={showChannel} />
+      ))}
+    </>
+  )
+
+  if (displayedItems.length <= 0 && placeholdersCount <= 0) {
+    return null
+  }
+
+  return (
+    <section className={className}>
+      {title && <Title variant="h5">{title}</Title>}
+      <Grid onResize={(sizes) => setVideosPerRow(sizes.length)}>{gridContent}</Grid>
+    </section>
+  )
+}
+
+const Title = styled(Text)`
+  margin-bottom: ${sizes(4)};
+`
+
+export default InfiniteVideoGrid

+ 4 - 0
src/components/InfiniteGrids/index.ts

@@ -0,0 +1,4 @@
+import InfiniteVideoGrid from './InfiniteVideoGrid'
+import InfiniteChannelGrid from './InfiniteChannelGrid'
+
+export { InfiniteVideoGrid, InfiniteChannelGrid }

+ 163 - 0
src/components/InfiniteGrids/useInfiniteGrid.ts

@@ -0,0 +1,163 @@
+import { useEffect, useState } from 'react'
+import { ApolloError, useLazyQuery } from '@apollo/client'
+import { debounce } from 'lodash'
+import { DocumentNode } from 'graphql'
+import { TypedDocumentNode } from '@graphql-typed-document-node/core'
+
+type PaginatedData<T> = {
+  edges: {
+    cursor: string
+    node: T
+  }[]
+  pageInfo: {
+    hasNextPage: boolean
+    endCursor: string | null
+  }
+  totalCount: number
+}
+export type PaginatedDataArgs = {
+  first?: number | null
+  after?: string | null
+}
+
+// TODO these types below could be used to get rid of requirement to pass TPaginatedData explicitly
+// however this currently is not possible because of constraints of Typescript and our GraphQL codegen
+// tldr is that our codegen generates interfaces instead of types and a more specific interface cannot be assigned to a generic one
+
+// type RawData<TData> = { [p: string]: PaginatedData<TData> }
+// type PaginatedDataFromRawData<TRawData extends RawData<unknown>> = TRawData[keyof TRawData]
+// type ItemTypeFromRawData<TRawData extends RawData<unknown>> = PaginatedDataFromRawData<TRawData>['edges'][0]['node']
+
+type UseInfiniteGridParams<TRawData, TPaginatedData extends PaginatedData<unknown>, TArgs> = {
+  query: DocumentNode | TypedDocumentNode<TRawData, TArgs>
+  dataAccessor: (rawData?: TRawData) => TPaginatedData | undefined
+  isReady: boolean
+  targetRowsCount: number
+  itemsPerRow: number
+  skipCount: number
+  onScrollToBottom: () => void
+  queryVariables: TArgs
+}
+
+type UseInfiniteGridReturn<TPaginatedData extends PaginatedData<unknown>> = {
+  displayedItems: TPaginatedData['edges'][0]['node'][]
+  placeholdersCount: number
+  error?: ApolloError
+}
+
+const useInfiniteGrid = <TRawData, TPaginatedData extends PaginatedData<unknown>, TArgs extends PaginatedDataArgs>({
+  query,
+  dataAccessor,
+  isReady,
+  targetRowsCount,
+  itemsPerRow,
+  skipCount,
+  onScrollToBottom,
+  queryVariables,
+}: UseInfiniteGridParams<TRawData, TPaginatedData, TArgs>): UseInfiniteGridReturn<TPaginatedData> => {
+  const [cachedQueryVariables, setCachedQueryVariables] = useState(queryVariables)
+  const [refetching, setRefetching] = useState(false)
+
+  const [fetchItems, { loading, data: rawData, error, fetchMore, called, refetch }] = useLazyQuery<TRawData, TArgs>(
+    query,
+    {
+      notifyOnNetworkStatusChange: true,
+    }
+  )
+
+  const targetDisplayedItemsCount = targetRowsCount * itemsPerRow
+  const targetLoadedItemsCount = targetDisplayedItemsCount + skipCount
+
+  const data = dataAccessor(rawData)
+
+  const loadedItemsCount = data?.edges.length ?? 0
+  const allItemsLoaded = data ? !data.pageInfo.hasNextPage : false
+  const endCursor = data?.pageInfo.endCursor
+
+  const queryVariablesChanged = queryVariables !== cachedQueryVariables
+
+  // handle initial data fetch
+  useEffect(() => {
+    if (isReady && !called) {
+      fetchItems({
+        variables: {
+          ...queryVariables,
+          first: targetLoadedItemsCount,
+        },
+      })
+    }
+  }, [isReady, called, fetchItems, queryVariables, targetLoadedItemsCount])
+
+  // handle fetching more items
+  useEffect(() => {
+    if (loading || !fetchMore || allItemsLoaded || refetching || queryVariablesChanged) {
+      return
+    }
+
+    const missingItemsCount = targetLoadedItemsCount - loadedItemsCount
+
+    if (missingItemsCount <= 0) {
+      return
+    }
+
+    fetchMore({
+      variables: { ...queryVariables, first: missingItemsCount, after: endCursor },
+    })
+  }, [
+    loading,
+    fetchMore,
+    allItemsLoaded,
+    refetching,
+    queryVariablesChanged,
+    queryVariables,
+    targetLoadedItemsCount,
+    loadedItemsCount,
+    endCursor,
+  ])
+
+  // handle query vars change
+  useEffect(() => {
+    if (!queryVariablesChanged || !refetch || !isReady) {
+      return
+    }
+
+    setCachedQueryVariables(queryVariables)
+    setRefetching(true)
+
+    const refetchPromise = refetch({ ...queryVariables, first: targetRowsCount * itemsPerRow + skipCount })
+
+    if (refetchPromise) {
+      refetchPromise.then(() => setRefetching(false))
+    }
+  }, [queryVariables, queryVariablesChanged, isReady, refetch, targetRowsCount, itemsPerRow, skipCount])
+
+  // handle scroll to bottom
+  useEffect(() => {
+    const scrollHandler = debounce(() => {
+      const scrolledToBottom =
+        window.innerHeight + document.documentElement.scrollTop >= document.documentElement.offsetHeight
+      if (scrolledToBottom && isReady && !loading && !allItemsLoaded) {
+        onScrollToBottom()
+      }
+    }, 100)
+
+    window.addEventListener('scroll', scrollHandler)
+    return () => window.removeEventListener('scroll', scrollHandler)
+  }, [isReady, loading, allItemsLoaded, onScrollToBottom])
+
+  const displayedEdges = data?.edges.slice(skipCount, targetLoadedItemsCount) ?? []
+  const displayedItems = displayedEdges.map((edge) => edge.node)
+
+  const displayedItemsCount = data
+    ? Math.min(targetDisplayedItemsCount, data.totalCount - skipCount)
+    : targetDisplayedItemsCount
+  const placeholdersCount = displayedItemsCount - displayedItems.length
+
+  return {
+    displayedItems,
+    placeholdersCount,
+    error,
+  }
+}
+
+export default useInfiniteGrid

+ 1 - 1
src/components/index.ts

@@ -1,4 +1,3 @@
-export { default as LayoutWithRouting } from './LayoutWithRouting'
 export { default as VideoGallery } from './VideoGallery'
 export { default as CoverVideo } from './CoverVideo'
 export { default as ChannelGallery } from './ChannelGallery'
@@ -12,3 +11,4 @@ export { default as ViewErrorFallback } from './ViewErrorFallback'
 export { default as ErrorFallback } from './ErrorFallback'
 export { default as ChannelLink } from './ChannelLink'
 export { default as BackgroundPattern } from './BackgroundPattern'
+export { InfiniteVideoGrid, InfiniteChannelGrid } from './InfiniteGrids'

+ 2 - 0
src/config/routes.ts

@@ -1,6 +1,8 @@
 export default {
+  index: () => '/',
   video: (id = ':id') => `/video/${id}`,
   search: (searchStr = ':search') => `/search/${searchStr}`,
   channel: (id = ':id') => `/channel/${id}`,
   browse: () => '/browse',
+  channels: () => '/channels',
 }

+ 23 - 15
src/shared/components/Grid/Grid.tsx

@@ -3,27 +3,14 @@ import styled from '@emotion/styled'
 import useResizeObserver from 'use-resize-observer'
 import { sizes, breakpoints } from '../../theme'
 import { MIN_VIDEO_PREVIEW_WIDTH } from '../VideoPreview'
+import { css } from '@emotion/core'
 
 const toPx = (n: number | string) => (typeof n === 'number' ? `${n}px` : n)
 
-type ContainerProps = Required<Pick<GridProps, 'gap' | 'maxColumns' | 'minWidth' | 'repeat'>>
-
-const Container = styled.div<ContainerProps>`
-  display: grid;
-  gap: ${(props) => toPx(props.gap)};
-  grid-template-columns: repeat(
-    auto-${(props) => props.repeat},
-    minmax(min(${(props) => toPx(props.minWidth)}, 100%), 1fr)
-  );
-  @media (min-width: ${toPx(breakpoints.xlarge)}) {
-    grid-template-columns: repeat(${(props) => props.maxColumns}, 1fr);
-  }
-`
-
 type GridProps = {
   gap?: number | string
   className?: string
-  maxColumns?: number
+  maxColumns?: number | null
   minWidth?: number | string
   repeat?: 'fit' | 'fill'
   onResize?: (sizes: number[]) => void
@@ -61,6 +48,27 @@ const Grid: React.FC<GridProps> = ({
   )
 }
 
+type ContainerProps = Required<Pick<GridProps, 'gap' | 'maxColumns' | 'minWidth' | 'repeat'>>
+
+const maxColumnsCss = ({ maxColumns }: ContainerProps) =>
+  maxColumns
+    ? css`
+        @media (min-width: ${toPx(breakpoints.xlarge)}) {
+          grid-template-columns: repeat(${maxColumns}, 1fr);
+        }
+      `
+    : null
+
+const Container = styled.div<ContainerProps>`
+  display: grid;
+  gap: ${(props) => toPx(props.gap)};
+  grid-template-columns: repeat(
+    auto-${(props) => props.repeat},
+    minmax(min(${(props) => toPx(props.minWidth)}, 100%), 1fr)
+  );
+  ${maxColumnsCss};
+`
+
 type BreakpointsToMatchGridArg = {
   breakpoints: number
   minItemWidth: number

+ 0 - 200
src/shared/components/InfiniteVideoGrid/InfiniteVideoGrid.tsx

@@ -1,200 +0,0 @@
-import React, { useCallback, useEffect, useState } from 'react'
-import styled from '@emotion/styled'
-import { debounce } from 'lodash'
-import { useLazyQuery } from '@apollo/client'
-
-import { sizes } from '../../theme'
-import { VideoPreviewBase } from '../VideoPreview'
-import Grid from '../Grid'
-import Text from '../Text'
-import VideoPreview from '@/components/VideoPreviewWithNavigation'
-import { GET_NEWEST_VIDEOS } from '@/api/queries'
-import { GetNewestVideos, GetNewestVideosVariables } from '@/api/queries/__generated__/GetNewestVideos'
-
-type InfiniteVideoGridProps = {
-  title?: string
-  categoryId?: string
-  channelId?: string
-  skipCount?: number
-  ready?: boolean
-  showChannel?: boolean
-  className?: string
-}
-
-const INITIAL_ROWS = 4
-const INITIAL_VIDEOS_PER_ROW = 4
-
-const InfiniteVideoGrid: React.FC<InfiniteVideoGridProps> = ({
-  title,
-  categoryId = '',
-  channelId,
-  skipCount = 0,
-  ready = true,
-  showChannel = true,
-  className,
-}) => {
-  const [videosPerRow, setVideosPerRow] = useState(INITIAL_VIDEOS_PER_ROW)
-
-  const [targetRowsCountByCategory, setTargetRowsCountByCategory] = useState<Record<string, number>>({
-    [categoryId]: INITIAL_ROWS,
-  })
-  const [cachedCategoryId, setCachedCategoryId] = useState<string>(categoryId)
-
-  const targetRowsCount = targetRowsCountByCategory[cachedCategoryId]
-
-  const targetDisplayedVideosCount = targetRowsCount * videosPerRow
-  const targetLoadedVideosCount = targetDisplayedVideosCount + skipCount
-
-  const [fetchVideos, { loading, data, error, fetchMore, called, refetch }] = useLazyQuery<
-    GetNewestVideos,
-    GetNewestVideosVariables
-  >(GET_NEWEST_VIDEOS, {
-    notifyOnNetworkStatusChange: true,
-  })
-
-  if (error) {
-    throw error
-  }
-
-  const loadedVideosCount = data?.videosConnection.edges.length || 0
-  const allVideosLoaded = data ? !data.videosConnection.pageInfo.hasNextPage : false
-
-  const endCursor = data?.videosConnection.pageInfo.endCursor
-
-  const getFetchVariables = useCallback(
-    (args: GetNewestVideosVariables): GetNewestVideosVariables => ({
-      ...(channelId ? { channelId } : {}),
-      ...(categoryId ? { categoryId } : {}),
-      ...args,
-    }),
-    [channelId, categoryId]
-  )
-
-  useEffect(() => {
-    if (ready && !called) {
-      fetchVideos({
-        variables: getFetchVariables({
-          first: targetLoadedVideosCount,
-        }),
-      })
-    }
-  }, [ready, called, getFetchVariables, targetLoadedVideosCount, fetchVideos])
-
-  useEffect(() => {
-    if (categoryId === cachedCategoryId) {
-      return
-    }
-
-    setCachedCategoryId(categoryId)
-    const categoryRowsSet = !!targetRowsCountByCategory[categoryId]
-    const categoryRowsCount = categoryRowsSet ? targetRowsCountByCategory[categoryId] : INITIAL_ROWS
-    if (!categoryRowsSet) {
-      setTargetRowsCountByCategory((prevState) => ({
-        ...prevState,
-        [categoryId]: categoryRowsCount,
-      }))
-    }
-
-    if (!called || !refetch) {
-      return
-    }
-
-    refetch(getFetchVariables({ first: categoryRowsCount * videosPerRow + skipCount }))
-  }, [
-    categoryId,
-    cachedCategoryId,
-    getFetchVariables,
-    targetRowsCountByCategory,
-    called,
-    refetch,
-    videosPerRow,
-    skipCount,
-  ])
-
-  useEffect(() => {
-    if (loading || !fetchMore || allVideosLoaded) {
-      return
-    }
-
-    if (targetLoadedVideosCount > loadedVideosCount) {
-      const videosToLoadCount = targetLoadedVideosCount - loadedVideosCount
-      fetchMore({
-        variables: getFetchVariables({ first: videosToLoadCount, after: endCursor }),
-      })
-    }
-  }, [loading, loadedVideosCount, targetLoadedVideosCount, allVideosLoaded, fetchMore, endCursor, getFetchVariables])
-
-  useEffect(() => {
-    const scrollHandler = debounce(() => {
-      const scrolledToBottom =
-        window.innerHeight + document.documentElement.scrollTop >= document.documentElement.offsetHeight
-      if (scrolledToBottom && ready && !loading && !allVideosLoaded) {
-        setTargetRowsCountByCategory((prevState) => ({
-          ...prevState,
-          [cachedCategoryId]: targetRowsCount + 2,
-        }))
-      }
-    }, 100)
-    window.addEventListener('scroll', scrollHandler)
-    return () => {
-      window.removeEventListener('scroll', scrollHandler)
-    }
-  }, [targetRowsCount, ready, loading, allVideosLoaded, cachedCategoryId])
-
-  const displayedEdges = data?.videosConnection.edges.slice(skipCount, targetLoadedVideosCount) || []
-  const displayedVideos = displayedEdges.map((edge) => edge.node)
-
-  const targetDisplayedItemsCount = data
-    ? Math.min(targetDisplayedVideosCount, data.videosConnection.totalCount - skipCount)
-    : targetDisplayedVideosCount
-  const placeholdersCount = targetDisplayedItemsCount - displayedVideos.length
-
-  const gridContent = (
-    <>
-      {displayedVideos.map((v) => (
-        <StyledVideoPreview
-          id={v.id}
-          channelId={v.channel.id}
-          title={v.title}
-          channelName={v.channel.handle}
-          channelAvatarURL={v.channel.avatarPhotoUrl}
-          createdAt={v.createdAt}
-          views={v.views}
-          posterURL={v.thumbnailUrl}
-          showChannel={showChannel}
-          key={v.id}
-        />
-      ))}
-      {Array.from({ length: placeholdersCount }, (_, idx) => (
-        <StyledVideoPreviewBase key={idx} showChannel={showChannel} />
-      ))}
-    </>
-  )
-
-  if (displayedVideos.length <= 0 && placeholdersCount <= 0) {
-    return null
-  }
-
-  return (
-    <section className={className}>
-      {title && <Title variant="h5">{title}</Title>}
-      <Grid onResize={(sizes) => setVideosPerRow(sizes.length)}>{gridContent}</Grid>
-    </section>
-  )
-}
-
-const Title = styled(Text)`
-  margin-bottom: ${sizes(4)};
-`
-
-const StyledVideoPreview = styled(VideoPreview)`
-  margin: 0 auto;
-  width: 100%;
-`
-
-const StyledVideoPreviewBase = styled(VideoPreviewBase)`
-  margin: 0 auto;
-  width: 100%;
-`
-
-export default InfiniteVideoGrid

+ 0 - 3
src/shared/components/InfiniteVideoGrid/index.ts

@@ -1,3 +0,0 @@
-import InfiniteVideoGrid from './InfiniteVideoGrid'
-
-export default InfiniteVideoGrid

+ 1 - 1
src/shared/components/SideNavbar/SideNavbar.tsx

@@ -2,7 +2,7 @@ import React, { useState } from 'react'
 import { LinkGetProps } from '@reach/router'
 import useResizeObserver from 'use-resize-observer'
 import HamburgerButton from '../HamburgerButton'
-import { IconType } from '../../icons/index'
+import { IconType } from '../../icons'
 import {
   InactiveIcon,
   ActiveIcon,

+ 0 - 1
src/shared/components/index.ts

@@ -16,7 +16,6 @@ export { default as Gallery } from './Gallery'
 export { default as SideNavbar, SIDENAVBAR_WIDTH, EXPANDED_SIDENAVBAR_WIDTH, NavItem } from './SideNavbar'
 export { default as ChannelAvatar } from './ChannelAvatar'
 export { default as GlobalStyle } from './GlobalStyle'
-export { default as InfiniteVideoGrid } from './InfiniteVideoGrid'
 export { default as ToggleButton } from './ToggleButton'
 export { default as Icon } from './Icon'
 export { default as Searchbar } from './Searchbar'

+ 1 - 0
src/shared/icons/channels.svg

@@ -0,0 +1 @@
+<svg width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M1 21h14v-2.558a2 2 0 00-1.367-1.898L10.1 15.367a.717.717 0 01-.28-1.187c.118-.118.261-.207.42-.26l1.85-.617a1 1 0 00.646-1.224l-.913-3.194a3.977 3.977 0 00-7.648 0l-.913 3.194a1 1 0 00.645 1.224l1.851.617c.159.053.302.142.42.26a.717.717 0 01-.28 1.187l-3.531 1.177A2 2 0 001 18.442V21z" fill="currentColor"/><path fill-rule="evenodd" clip-rule="evenodd" d="M12.206 5.73a5.964 5.964 0 011.541 2.605l.913 3.195a3 3 0 01-.8 2.982l.405.135A4 4 0 0117 18.442V20h7v-3.491a2 2 0 00-1.45-1.923l-3.582-1.024a1.204 1.204 0 01-.52-2.01c.365-.365.672-.792.903-1.255A6.143 6.143 0 0020 7.557V7a4 4 0 00-7.794-1.27zM15 20H8v-3.491a2 2 0 011.45-1.923l.176-.05a.718.718 0 00.475.83l.7.234.304.102.057.019 2.47.823A2 2 0 0115 18.442V20z" fill="currentColor"/></svg>

+ 2 - 0
src/shared/icons/index.ts

@@ -8,6 +8,7 @@ export { ReactComponent as BinocularFill } from './binocular-fill.svg'
 export { ReactComponent as Browse } from './browse.svg'
 export { ReactComponent as Books } from './books.svg'
 export { ReactComponent as Block } from './block.svg'
+export { ReactComponent as Channels } from './channels.svg'
 export { ReactComponent as ChevronDown } from './chevron-down-big.svg'
 export { ReactComponent as ChevronUp } from './chevron-up-big.svg'
 export { ReactComponent as ChevronRight } from './chevron-right-big.svg'
@@ -33,6 +34,7 @@ const icons = [
   'browse',
   'books',
   'block',
+  'channels',
   'chevron-down',
   'chevron-up',
   'chevron-right',

+ 2 - 1
src/views/BrowseView/BrowseView.style.ts

@@ -1,5 +1,6 @@
 import styled from '@emotion/styled'
-import { CategoryPicker, InfiniteVideoGrid, Text } from '@/shared/components'
+import { CategoryPicker, Text } from '@/shared/components'
+import { InfiniteVideoGrid } from '@/components'
 
 import { colors, sizes, transitions, zIndex } from '@/shared/theme'
 import { TOP_NAVBAR_HEIGHT } from '@/components/TopNavbar'

+ 1 - 1
src/views/BrowseView/BrowseView.tsx

@@ -62,7 +62,7 @@ const BrowseView: React.FC<RouteComponentProps> = () => {
         isAtTop={inView}
       />
       <ErrorBoundary fallback={ErrorFallback}>
-        <StyledInfiniteVideoGrid categoryId={selectedCategoryId || undefined} ready={!!selectedCategoryId} />
+        <StyledInfiniteVideoGrid categoryId={selectedCategoryId || undefined} ready={!!categoriesData?.categories} />
       </ErrorBoundary>
     </Container>
   )

+ 2 - 3
src/views/ChannelView/ChannelView.tsx

@@ -19,8 +19,7 @@ import {
   SubTitle,
   SubTitlePlaceholder,
 } from './ChannelView.style'
-import { BackgroundPattern } from '@/components'
-import { InfiniteVideoGrid } from '@/shared/components'
+import { BackgroundPattern, InfiniteVideoGrid } from '@/components'
 import { CSSTransition, TransitionGroup } from 'react-transition-group'
 import { transitions } from '@/shared/theme'
 import { formatNumberShort } from '@/utils/number'
@@ -75,7 +74,7 @@ const ChannelView: React.FC<RouteComponentProps> = () => {
         </TitleSection>
       </Header>
       <VideoSection>
-        <InfiniteVideoGrid channelId={id} />
+        <InfiniteVideoGrid channelId={id} showChannel={false} />
       </VideoSection>
     </>
   )

+ 15 - 0
src/views/ChannelsView/ChannelsView.tsx

@@ -0,0 +1,15 @@
+import React from 'react'
+import { Container, Header } from '@/views/BrowseView/BrowseView.style'
+import { BackgroundPattern, InfiniteChannelGrid } from '@/components'
+
+const ChannelsView: React.FC = () => {
+  return (
+    <Container>
+      <BackgroundPattern />
+      <Header variant="hero">Channels</Header>
+      <InfiniteChannelGrid />
+    </Container>
+  )
+}
+
+export default ChannelsView

+ 3 - 0
src/views/ChannelsView/index.ts

@@ -0,0 +1,3 @@
+import ChannelsView from './ChannelsView'
+
+export default ChannelsView

+ 3 - 22
src/views/HomeView.tsx

@@ -1,18 +1,15 @@
 import React from 'react'
 import styled from '@emotion/styled'
-import { ChannelGallery, ErrorFallback, CoverVideo, VideoGallery } from '@/components'
-
 import { RouteComponentProps } from '@reach/router'
 import { useQuery } from '@apollo/client'
 import { ErrorBoundary } from '@sentry/react'
-import { InfiniteVideoGrid } from '@/shared/components'
-import { GET_FEATURED_VIDEOS, GET_NEWEST_CHANNELS, GET_NEWEST_VIDEOS } from '@/api/queries'
+
+import { ErrorFallback, CoverVideo, InfiniteVideoGrid, VideoGallery } from '@/components'
+import { GET_FEATURED_VIDEOS, GET_NEWEST_VIDEOS } from '@/api/queries'
 import { GetFeaturedVideos } from '@/api/queries/__generated__/GetFeaturedVideos'
 import { GetNewestVideos, GetNewestVideosVariables } from '@/api/queries/__generated__/GetNewestVideos'
-import { GetNewestChannels, GetNewestChannelsVariables } from '@/api/queries/__generated__/GetNewestChannels'
 
 const NEWEST_VIDEOS_COUNT = 8
-const NEWEST_CHANNELS_COUNT = 8
 
 const HomeView: React.FC<RouteComponentProps> = () => {
   const {
@@ -32,23 +29,12 @@ const HomeView: React.FC<RouteComponentProps> = () => {
   } = useQuery<GetFeaturedVideos>(GET_FEATURED_VIDEOS, {
     notifyOnNetworkStatusChange: true,
   })
-  const {
-    loading: newestChannelsLoading,
-    data: newestChannelsData,
-    error: newestChannelsError,
-    refetch: refetchNewestChannels,
-  } = useQuery<GetNewestChannels, GetNewestChannelsVariables>(GET_NEWEST_CHANNELS, {
-    variables: { first: NEWEST_CHANNELS_COUNT },
-    notifyOnNetworkStatusChange: true,
-  })
 
   const newestVideos = videosData?.videosConnection.edges.slice(0, NEWEST_VIDEOS_COUNT).map((e) => e.node)
   const featuredVideos = featuredVideosData?.featuredVideos.map((featuredVideo) => featuredVideo.video)
-  const newestChannels = newestChannelsData?.channelsConnection.edges.map((e) => e.node)
 
   const hasNewestVideosError = newestVideosError && !newestVideosLoading
   const hasFeaturedVideosError = featuredVideosError && !featuredVideosLoading
-  const hasNewestChannelsError = newestChannelsError && !newestChannelsLoading
 
   return (
     <>
@@ -66,11 +52,6 @@ const HomeView: React.FC<RouteComponentProps> = () => {
           <ErrorFallback error={featuredVideosError} resetError={() => refetchFeaturedVideos()} />
         )}
 
-        {!hasNewestChannelsError ? (
-          <ChannelGallery title="Newest channels" loading={newestChannelsLoading} channels={newestChannels} />
-        ) : (
-          <ErrorFallback error={newestChannelsError} resetError={() => refetchNewestChannels()} />
-        )}
         <ErrorBoundary fallback={ErrorFallback}>
           <StyledInfiniteVideoGrid title="More videos" skipCount={NEWEST_VIDEOS_COUNT} />
         </ErrorBoundary>

+ 12 - 5
src/components/LayoutWithRouting.tsx → src/views/LayoutWithRouting.tsx

@@ -5,7 +5,7 @@ import { ErrorBoundary } from '@sentry/react'
 
 import { GlobalStyle, SideNavbar } from '@/shared/components'
 import { TopNavbar, ViewErrorFallback } from '@/components'
-import { HomeView, VideoView, SearchView, ChannelView, BrowseView } from '@/views'
+import { HomeView, VideoView, SearchView, ChannelView, BrowseView, ChannelsView } from '@/views'
 import routes from '@/config/routes'
 import { globalStyles } from '@/styles/global'
 import { breakpoints, sizes } from '@/shared/theme'
@@ -16,13 +16,19 @@ const SIDENAVBAR_ITEMS: NavItemType[] = [
     icon: 'home',
     iconFilled: 'home-fill',
     name: 'Home',
-    to: '/',
+    to: routes.index(),
   },
   {
     icon: 'binocular',
     iconFilled: 'binocular-fill',
     name: 'Discover',
-    to: '/browse',
+    to: routes.browse(),
+  },
+  {
+    icon: 'channels',
+    iconFilled: 'channels',
+    name: 'Channels',
+    to: routes.channels(),
   },
 ]
 
@@ -61,8 +67,9 @@ const LayoutWithRouting: React.FC = () => {
           <Route default Component={HomeView} />
           <Route path={routes.video()} Component={VideoView} />
           <Route path={routes.search()} Component={SearchView} />
-          <Route Component={BrowseView} path={routes.browse()} />
-          <Route Component={ChannelView} path={routes.channel()} />
+          <Route path={routes.browse()} Component={BrowseView} />
+          <Route path={routes.channels()} Component={ChannelsView} />
+          <Route path={routes.channel()} Component={ChannelView} />
         </Router>
       </MainContainer>
     </>

+ 2 - 2
src/views/VideoView/VideoView.tsx

@@ -15,13 +15,13 @@ import {
   LicenseContainer,
   TitleText,
 } from './VideoView.style'
-import { InfiniteVideoGrid, Placeholder, VideoPlayer, Text } from '@/shared/components'
+import { Placeholder, VideoPlayer, Text } from '@/shared/components'
 import { useMutation, useQuery } from '@apollo/client'
 import { ADD_VIDEO_VIEW, GET_VIDEO } from '@/api/queries'
 import { GetVideo, GetVideoVariables } from '@/api/queries/__generated__/GetVideo'
 import { formatVideoViewsAndDate } from '@/utils/video'
 import { AddVideoView, AddVideoViewVariables } from '@/api/queries/__generated__/AddVideoView'
-import { ChannelLink } from '@/components'
+import { ChannelLink, InfiniteVideoGrid } from '@/components'
 
 const VideoView: React.FC<RouteComponentProps> = () => {
   const { id } = useParams()

+ 2 - 1
src/views/index.ts

@@ -3,5 +3,6 @@ import VideoView from './VideoView'
 import SearchView from './SearchView'
 import ChannelView from './ChannelView'
 import BrowseView from './BrowseView'
+import ChannelsView from './ChannelsView'
 
-export { HomeView, VideoView, SearchView, ChannelView, BrowseView }
+export { HomeView, VideoView, SearchView, ChannelView, BrowseView, ChannelsView }

+ 52 - 31
yarn.lock

@@ -2,10 +2,10 @@
 # yarn lockfile v1
 
 
-"@apollo/client@^3.1.1":
-  version "3.2.1"
-  resolved "https://registry.yarnpkg.com/@apollo/client/-/client-3.2.1.tgz#178dfcc1eb3a35052df8f2bd44be195b78f56e93"
-  integrity sha512-w1EdCf3lvSwsxG2zbn8Rm31nPh9gQrB7u61BnU1QCM5BNIfOxiuuldzGNMHi5kI9KleisFvZl/9OA7pEkVg/yw==
+"@apollo/client@^3.1.5":
+  version "3.2.9"
+  resolved "https://registry.yarnpkg.com/@apollo/client/-/client-3.2.9.tgz#a24a7792519adb3af8a74a60d9e83732238d0afd"
+  integrity sha512-AUvYITKhJNfRNU/Cf8t/N628ADdVah1+l9Qtjd09IwScRfDGvbBTkHMAgcb6hl7vuBVqGwQRq6fPKzHgWRlisg==
   dependencies:
     "@graphql-typed-document-node/core" "^3.0.0"
     "@types/zen-observable" "^0.8.0"
@@ -14,30 +14,29 @@
     fast-json-stable-stringify "^2.0.0"
     graphql-tag "^2.11.0"
     hoist-non-react-statics "^3.3.2"
-    optimism "^0.12.1"
+    optimism "^0.13.0"
     prop-types "^15.7.2"
     symbol-observable "^2.0.0"
-    terser "^5.2.0"
-    ts-invariant "^0.4.4"
+    ts-invariant "^0.5.0"
     tslib "^1.10.0"
     zen-observable "^0.8.14"
 
-"@apollo/client@^3.1.5":
-  version "3.2.9"
-  resolved "https://registry.yarnpkg.com/@apollo/client/-/client-3.2.9.tgz#a24a7792519adb3af8a74a60d9e83732238d0afd"
-  integrity sha512-AUvYITKhJNfRNU/Cf8t/N628ADdVah1+l9Qtjd09IwScRfDGvbBTkHMAgcb6hl7vuBVqGwQRq6fPKzHgWRlisg==
+"@apollo/client@^3.3.0":
+  version "3.3.6"
+  resolved "https://registry.yarnpkg.com/@apollo/client/-/client-3.3.6.tgz#f359646308167f38d5bc498dfc2344c888400093"
+  integrity sha512-XSm/STyNS8aHdDigLLACKNMHwI0qaQmEHWHtTP+jHe/E1wZRnn66VZMMgwKLy2V4uHISHfmiZ4KpUKDPeJAKqg==
   dependencies:
     "@graphql-typed-document-node/core" "^3.0.0"
     "@types/zen-observable" "^0.8.0"
     "@wry/context" "^0.5.2"
-    "@wry/equality" "^0.2.0"
+    "@wry/equality" "^0.3.0"
     fast-json-stable-stringify "^2.0.0"
     graphql-tag "^2.11.0"
     hoist-non-react-statics "^3.3.2"
-    optimism "^0.13.0"
+    optimism "^0.13.1"
     prop-types "^15.7.2"
     symbol-observable "^2.0.0"
-    ts-invariant "^0.5.0"
+    ts-invariant "^0.6.0"
     tslib "^1.10.0"
     zen-observable "^0.8.14"
 
@@ -4192,6 +4191,11 @@
   dependencies:
     source-map "^0.6.1"
 
+"@types/ungap__global-this@^0.3.1":
+  version "0.3.1"
+  resolved "https://registry.yarnpkg.com/@types/ungap__global-this/-/ungap__global-this-0.3.1.tgz#18ce9f657da556037a29d50604335614ce703f4c"
+  integrity sha512-+/DsiV4CxXl6ZWefwHZDXSe1Slitz21tom38qPCaG0DYCS1NnDPIQDTKcmQ/tvK/edJUKkmuIDBJbmKDiB0r/g==
+
 "@types/unist@*", "@types/unist@^2.0.0", "@types/unist@^2.0.2", "@types/unist@^2.0.3":
   version "2.0.3"
   resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.3.tgz#9c088679876f374eb5983f150d4787aa6fb32d7e"
@@ -4426,6 +4430,11 @@
     "@typescript-eslint/types" "4.8.2"
     eslint-visitor-keys "^2.0.0"
 
+"@ungap/global-this@^0.4.2":
+  version "0.4.3"
+  resolved "https://registry.yarnpkg.com/@ungap/global-this/-/global-this-0.4.3.tgz#44cb668b03e7c4bc88cb6e6f9329d381131878ee"
+  integrity sha512-MuHEpDBurNVeD6mV9xBcAN2wfTwuaFQhHuhWkJuXmyVJ5P5sBCw+nnFpdfb0tAvgWkfefWCsAoAsh7MTUr3LPg==
+
 "@videojs/http-streaming@1.13.2":
   version "1.13.2"
   resolved "https://registry.yarnpkg.com/@videojs/http-streaming/-/http-streaming-1.13.2.tgz#9e91f9f440ccaf6c8ed640a3614216397bb38558"
@@ -4689,6 +4698,13 @@
   dependencies:
     tslib "^1.9.3"
 
+"@wry/equality@^0.3.0":
+  version "0.3.1"
+  resolved "https://registry.yarnpkg.com/@wry/equality/-/equality-0.3.1.tgz#81080cdc2e0d8265cd303faa0c64b38a77884e06"
+  integrity sha512-8/Ftr3jUZ4EXhACfSwPIfNsE8V6WKesdjp+Dxi78Bej6qlasAxiz0/F8j0miACRj9CL4vC5Y5FsfwwEYAuhWbg==
+  dependencies:
+    tslib "^1.14.1"
+
 "@xtuc/ieee754@^1.2.0":
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/@xtuc/ieee754/-/ieee754-1.2.0.tgz#eef014a3145ae477a1cbc00cd1e552336dceb790"
@@ -13668,13 +13684,6 @@ opn@^5.5.0:
   dependencies:
     is-wsl "^1.1.0"
 
-optimism@^0.12.1:
-  version "0.12.2"
-  resolved "https://registry.yarnpkg.com/optimism/-/optimism-0.12.2.tgz#de9dc3d2c914d7b34e08957a768967c0605beda9"
-  integrity sha512-k7hFhlmfLl6HNThIuuvYMQodC1c+q6Uc6V9cLVsMWyW514QuaxVJH/khPu2vLRIoDTpFdJ5sojlARhg1rzyGbg==
-  dependencies:
-    "@wry/context" "^0.5.2"
-
 optimism@^0.13.0:
   version "0.13.1"
   resolved "https://registry.yarnpkg.com/optimism/-/optimism-0.13.1.tgz#df2e6102c973f870d6071712fffe4866bb240384"
@@ -13682,6 +13691,13 @@ optimism@^0.13.0:
   dependencies:
     "@wry/context" "^0.5.2"
 
+optimism@^0.13.1:
+  version "0.13.2"
+  resolved "https://registry.yarnpkg.com/optimism/-/optimism-0.13.2.tgz#002a438b69652bfe8f8754a4493ed35c2e9d9821"
+  integrity sha512-kJkpDUEs/Rp8HsAYYlDzyvQHlT6YZa95P+2GGNR8p/VvsIkt6NilAk7oeTvMRKCq7BeclB7+bmdIexog2859GQ==
+  dependencies:
+    "@wry/context" "^0.5.2"
+
 optimize-css-assets-webpack-plugin@5.0.4:
   version "5.0.4"
   resolved "https://registry.yarnpkg.com/optimize-css-assets-webpack-plugin/-/optimize-css-assets-webpack-plugin-5.0.4.tgz#85883c6528aaa02e30bbad9908c92926bb52dc90"
@@ -17727,15 +17743,6 @@ terser@^4.1.2, terser@^4.6.12, terser@^4.6.2, terser@^4.6.3:
     source-map "~0.6.1"
     source-map-support "~0.5.12"
 
-terser@^5.2.0:
-  version "5.3.3"
-  resolved "https://registry.yarnpkg.com/terser/-/terser-5.3.3.tgz#2592a1cf079df55101fe2b2cb2330f951863860b"
-  integrity sha512-vRQDIlD+2Pg8YMwVK9kMM3yGylG95EIwzBai1Bw7Ot4OBfn3VP1TZn3EWx4ep2jERN/AmnVaTiGuelZSN7ds/A==
-  dependencies:
-    commander "^2.20.0"
-    source-map "~0.7.2"
-    source-map-support "~0.5.19"
-
 terser@^5.3.4:
   version "5.5.0"
   resolved "https://registry.yarnpkg.com/terser/-/terser-5.5.0.tgz#1406fcb4d4bc517add3b22a9694284c040e33448"
@@ -17942,7 +17949,7 @@ ts-dedent@^1.1.0:
   resolved "https://registry.yarnpkg.com/ts-dedent/-/ts-dedent-1.2.0.tgz#6aa2229d837159bb6d635b6b233002423b91e0b0"
   integrity sha512-6zSJp23uQI+Txyz5LlXMXAHpUhY4Hi0oluXny0OgIR7g/Cromq4vDBnhtbBdyIV34g0pgwxUvnvg+jLJe4c1NA==
 
-ts-invariant@^0.4.0, ts-invariant@^0.4.4:
+ts-invariant@^0.4.0:
   version "0.4.4"
   resolved "https://registry.yarnpkg.com/ts-invariant/-/ts-invariant-0.4.4.tgz#97a523518688f93aafad01b0e80eb803eb2abd86"
   integrity sha512-uEtWkFM/sdZvRNNDL3Ehu4WVpwaulhwQszV8mrtcdeE8nN00BV9mAmQ88RkrBhFgl9gMgvjJLAQcZbnPXI9mlA==
@@ -17956,6 +17963,15 @@ ts-invariant@^0.5.0:
   dependencies:
     tslib "^1.9.3"
 
+ts-invariant@^0.6.0:
+  version "0.6.0"
+  resolved "https://registry.yarnpkg.com/ts-invariant/-/ts-invariant-0.6.0.tgz#44066ecfeb7a806ff1c3b0b283408a337a885412"
+  integrity sha512-caoafsfgb8QxdrKzFfjKt627m4i8KTtfAiji0DYJfWI4A/S9ORNNpzYuD9br64kyKFgxn9UNaLLbSupam84mCA==
+  dependencies:
+    "@types/ungap__global-this" "^0.3.1"
+    "@ungap/global-this" "^0.4.2"
+    tslib "^1.9.3"
+
 ts-loader@^6.2.1:
   version "6.2.2"
   resolved "https://registry.yarnpkg.com/ts-loader/-/ts-loader-6.2.2.tgz#dffa3879b01a1a1e0a4b85e2b8421dc0dfff1c58"
@@ -18003,6 +18019,11 @@ tslib@^1, tslib@^1.10.0, tslib@^1.8.1, tslib@^1.9.0, tslib@^1.9.3:
   resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.13.0.tgz#c881e13cc7015894ed914862d276436fa9a47043"
   integrity sha512-i/6DQjL8Xf3be4K/E6Wgpekn5Qasl1usyw++dAA35Ue5orEn65VIxOA+YvNNl9HV3qv70T7CNwjODHZrLwvd1Q==
 
+tslib@^1.14.1:
+  version "1.14.1"
+  resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
+  integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
+
 tslib@^2.0.0, tslib@^2.0.1:
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.0.1.tgz#410eb0d113e5b6356490eec749603725b021b43e"