Skip to content

Commit

Permalink
[PAY-1136][PAY-1129] Add infinite loading to albums on desktop / mobi…
Browse files Browse the repository at this point in the history
…le web (#3371)
  • Loading branch information
schottra authored May 22, 2023
1 parent 35678eb commit ecdfb7e
Show file tree
Hide file tree
Showing 11 changed files with 278 additions and 176 deletions.
118 changes: 63 additions & 55 deletions packages/common/src/hooks/useSavedCollections.ts
Original file line number Diff line number Diff line change
@@ -1,84 +1,92 @@
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useMemo } from 'react'

import { useDispatch, useSelector } from 'react-redux'

import { ID } from 'models/Identifiers'
import { Status } from 'models/Status'
import { CommonState } from 'store/index'
import { getFetchedCollectionIds } from 'store/saved-collections/selectors'

import { accountActions } from '../store/account'
import {
CollectionType,
savedCollectionsActions,
savedCollectionsSelectors
} from '../store/saved-collections'

const { fetchSavedPlaylists } = accountActions
const { fetchCollections } = savedCollectionsActions

const {
getAccountAlbums,
getSavedAlbumsState,
getFetchedAlbumsWithDetails,
getAccountPlaylists
} = savedCollectionsSelectors
const { getAccountAlbums, getSavedCollectionsState, getAccountPlaylists } =
savedCollectionsSelectors

const DEFAULT_PAGE_SIZE = 50

export function useSavedAlbums() {
return useSelector(getAccountAlbums)
}

/* TODO: Handle filtering
* Option 1: This hook takes the list of album ids to fetch and computes the unfetched
* based on that.
* Option 2: Bake filter into selectors which drive this. Downside: Can't use this in multiple places...
*/
type UseSavedAlbumDetailsConfig = {
export function useSavedPlaylists() {
return useSelector(getAccountPlaylists)
}

type UseFetchedCollectionsConfig = {
collectionIds: ID[]
type: CollectionType
pageSize?: number
}
export function useSavedAlbumsDetails({

type UseFetchedSavedCollectionsResult = {
/** A list of IDs representing the subset of requested collections which have been fetched */
data: ID[]
/** The current fetching state of the list of collections requested */
status: Status
/** Whether any items remain unfetched */
hasMore: boolean
/** Triggers fetching of the next page of items */
fetchMore: () => void
}
/** Given a list of collectionIds and a type ('albums' or 'playlists'), returns state
* necessary to display a list of fully-fetched collections of that type, as well as
* load any remaining items which haven't been fetched.
*/
export function useFetchedSavedCollections({
collectionIds,
type,
pageSize = DEFAULT_PAGE_SIZE
}: UseSavedAlbumDetailsConfig) {
}: UseFetchedCollectionsConfig): UseFetchedSavedCollectionsResult {
const dispatch = useDispatch()
const [hasFetched, setHasFetched] = useState(false)
const { unfetched: unfetchedAlbums, fetched: albumsWithDetails } =
useSelector(getFetchedAlbumsWithDetails)
const { status } = useSelector(getSavedAlbumsState)

const { status } = useSelector((state: CommonState) =>
getSavedCollectionsState(state, type)
)
const fetchedCollectionIDs = useSelector(getFetchedCollectionIds)

const { unfetched, fetched } = useMemo(() => {
const fetchedSet = new Set(fetchedCollectionIDs)
return collectionIds.reduce<{ fetched: ID[]; unfetched: ID[] }>(
(accum, id) => {
if (fetchedSet.has(id)) {
accum.fetched.push(id)
} else {
accum.unfetched.push(id)
}
return accum
},
{ fetched: [], unfetched: [] }
)
}, [collectionIds, fetchedCollectionIDs])

const fetchMore = useCallback(() => {
if (status === Status.LOADING || unfetchedAlbums.length === 0) {
if (status === Status.LOADING || unfetched.length === 0) {
return
}
const ids = unfetchedAlbums
.slice(0, Math.min(pageSize, unfetchedAlbums.length))
.map((c) => c.id)
dispatch(fetchCollections({ type: 'albums', ids }))
setHasFetched(true)
}, [status, unfetchedAlbums, pageSize, dispatch, setHasFetched])

// Fetch first page if we don't have any items fetched yet
// Needs to wait for at least some albums to be fetchable
useEffect(() => {
if (
!hasFetched &&
// TODO: This check should change once InfiniteScroll is implemented
status !== Status.LOADING /* &&
unfetchedAlbums.length > 0 &&
albumsWithDetails.length === 0 */
) {
fetchMore()
}
}, [albumsWithDetails, status, hasFetched, unfetchedAlbums, fetchMore])

return { data: albumsWithDetails, status, fetchMore }
}

export function useSavedPlaylists() {
return useSelector(getAccountPlaylists)
}

export function useSavedPlaylistsDetails() {
const dispatch = useDispatch()
const ids = unfetched.slice(0, Math.min(pageSize, unfetched.length))
dispatch(fetchCollections({ type, ids }))
}, [status, unfetched, pageSize, type, dispatch])

useEffect(() => {
dispatch(fetchSavedPlaylists())
}, [dispatch])
return {
data: fetched,
status,
hasMore: unfetched.length > 0,
fetchMore
}
}
47 changes: 13 additions & 34 deletions packages/common/src/store/saved-collections/selectors.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,24 @@
import { createSelector } from '@reduxjs/toolkit'

import { getUsers } from 'store/cache/users/selectors'

import { AccountCollection } from '../account'
import { getAccountStatus } from '../account/selectors'
import { getCollections } from '../cache/collections/selectors'
import { CommonState } from '../commonStore'

import { CollectionWithOwner } from './types'
import { CollectionType } from './types'

const getAccountCollections = (state: CommonState) => state.account.collections
export const getSavedAlbumsState = (state: CommonState) =>
state.savedCollections.albums
export const getSavedPlaylistsState = (state: CommonState) =>
state.savedCollections.playlists
export const getSavedCollectionsState = (
state: CommonState,
type: CollectionType
) =>
type === 'albums'
? state.savedCollections.albums
: state.savedCollections.playlists

export const getFetchedCollectionIds = createSelector(
[getCollections],
(collections) => Object.values(collections).map((c) => c.playlist_id)
)

export const getAccountAlbums = createSelector(
[getAccountCollections, getAccountStatus],
Expand All @@ -23,32 +28,6 @@ export const getAccountAlbums = createSelector(
})
)

type GetAlbumsWithDetailsResult = {
fetched: CollectionWithOwner[]
unfetched: AccountCollection[]
}
/** Returns a mapped list of albums for which we have fetched full details */
export const getFetchedAlbumsWithDetails = createSelector(
[getAccountAlbums, getCollections, getUsers],
(albums, collections, users) => {
// TODO: Might want to read status, what happens for failed loads of parts of the collection?
return albums.data.reduce<GetAlbumsWithDetailsResult>(
(acc, cur) => {
const collectionMetadata = collections[cur.id]
if (collectionMetadata) {
const ownerHandle =
users[collectionMetadata.playlist_owner_id]?.handle ?? ''
acc.fetched.push({ ...collections[cur.id], ownerHandle })
} else {
acc.unfetched.push(cur)
}
return acc
},
{ fetched: [], unfetched: [] }
)
}
)

export const getAccountPlaylists = createSelector(
[getAccountCollections, getAccountStatus],
(collections, status) => ({
Expand Down
6 changes: 0 additions & 6 deletions packages/common/src/store/saved-collections/types.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1 @@
import { Collection } from '../../models/Collection'

export type CollectionType = 'albums' | 'playlists'

export type CollectionWithOwner = Collection & {
ownerHandle: string
}
29 changes: 12 additions & 17 deletions packages/web/src/components/lineup/CardLineup.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import React from 'react'

import cn from 'classnames'
import { connect } from 'react-redux'

import { EmptyCard } from 'components/card/mobile/Card'
import { Draggable } from 'components/dragndrop'
import CategoryHeader from 'components/header/desktop/CategoryHeader'
import { AppState } from 'store/types'
import { isMobile } from 'utils/clientUtil'
import { useIsMobile } from 'utils/clientUtil'

import styles from './CardLineup.module.css'

type OwnProps = {
export type CardLineupProps = {
categoryName?: string
cards: JSX.Element[]
containerClassName?: string
Expand All @@ -23,7 +23,7 @@ const DesktopCardContainer = ({
containerClassName,
cardsClassName,
onMore
}: OwnProps) => {
}: CardLineupProps) => {
return (
<div className={cn(containerClassName)}>
{categoryName && (
Expand Down Expand Up @@ -74,7 +74,10 @@ const renderEmptyCards = (cardsLength: number) => {
return null
}

const MobileCardContainer = ({ cards, containerClassName }: OwnProps) => {
const MobileCardContainer = ({
cards,
containerClassName
}: CardLineupProps) => {
return (
<div className={cn(styles.mobileContainer, containerClassName)}>
{cards.map((card, index) => (
Expand All @@ -87,19 +90,11 @@ const MobileCardContainer = ({ cards, containerClassName }: OwnProps) => {
)
}

type CardLineupProps = OwnProps & ReturnType<typeof mapStateToProps>

const CardLineup = (props: CardLineupProps) => {
const { isMobile, ...containerProps } = props
const isMobile = useIsMobile()
const Container = isMobile ? MobileCardContainer : DesktopCardContainer

return <Container {...containerProps} />
}

function mapStateToProps(state: AppState) {
return {
isMobile: isMobile()
}
return React.createElement(Container, props)
}

export default connect(mapStateToProps)(CardLineup)
export default CardLineup
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.spinner {
margin: auto;
width: var(--unit-8);
height: 100px;
}
49 changes: 49 additions & 0 deletions packages/web/src/components/lineup/InfiniteCardLineup.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import React, { useCallback, useRef } from 'react'

import InfiniteScroll from 'react-infinite-scroller'

import LoadingSpinner from 'components/loading-spinner/LoadingSpinner'
import { getScrollParent } from 'utils/scrollParent'

import CardLineup, { CardLineupProps } from './CardLineup'
import styles from './InfiniteCardLineup.module.css'

type InfiniteLoadingProps = {
hasMore: boolean
loadMore: () => void
}

export type InfiniteCardLineupProps = CardLineupProps & InfiniteLoadingProps

const InfiniteCardLineup = (props: InfiniteCardLineupProps) => {
const { hasMore, loadMore, ...lineupProps } = props
const scrollRef = useRef(null)

const getNearestScrollParent = useCallback(() => {
if (!scrollRef.current) {
return null
}
return (
(getScrollParent(scrollRef.current) as unknown as HTMLElement) ?? null
)
}, [])

return (
<>
<InfiniteScroll
hasMore={hasMore}
getScrollParent={getNearestScrollParent}
loadMore={loadMore}
loader={
<LoadingSpinner key='loading-spinner' className={styles.spinner} />
}
useWindow={false}
>
{React.createElement(CardLineup, lineupProps)}
</InfiniteScroll>
<div ref={scrollRef} />
</>
)
}

export default InfiniteCardLineup
2 changes: 0 additions & 2 deletions packages/web/src/pages/saved-page/SavedPageProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -443,7 +443,6 @@ class SavedPage extends PureComponent<SavedPageProps, SavedPageState> {
fetchSavedTracks: this.props.fetchSavedTracks,
resetSavedTracks: this.props.resetSavedTracks,
updateLineupOrder: this.props.updateLineupOrder,
fetchSavedAlbums: this.props.fetchSavedAlbums,
goToRoute: this.props.goToRoute,
play: this.props.play,
pause: this.props.pause,
Expand Down Expand Up @@ -559,7 +558,6 @@ function mapDispatchToProps(dispatch: Dispatch) {
resetSavedTracks: () => dispatch(tracksActions.reset()),
updateLineupOrder: (updatedOrderIndices: UID[]) =>
dispatch(tracksActions.updateLineupOrder(updatedOrderIndices)),
fetchSavedAlbums: () => dispatch(accountActions.fetchSavedAlbums()),
fetchSavedPlaylists: () => dispatch(accountActions.fetchSavedPlaylists()),
updatePlaylistLastViewedAt: (playlistId: number) =>
dispatch(updatedPlaylistViewed({ playlistId })),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,11 +77,8 @@
}

/* Albums */
.cardsContainer > * {
margin-top: 8px;
margin-left: 8px;
margin-right: 8px;
margin-bottom: 16px;
.cardsContainer {
gap: var(--unit-6) var(--unit-4);
}

.playButtonContainer {
Expand Down
Loading

0 comments on commit ecdfb7e

Please sign in to comment.