From 3c9b0f107cb51ea9e8e8d2503a659719935f0d90 Mon Sep 17 00:00:00 2001 From: Randy Schott <1815175+schottra@users.noreply.github.com> Date: Tue, 16 May 2023 12:15:58 -0400 Subject: [PATCH] [PAY-1202] Refactor saved collections fetching (#3337) --- packages/common/src/hooks/index.ts | 1 + .../common/src/hooks/useSavedCollections.ts | 84 +++++++++ packages/common/src/store/index.ts | 1 + packages/common/src/store/reducers.ts | 4 + .../src/store/saved-collections/index.ts | 6 + .../src/store/saved-collections/selectors.ts | 58 +++++++ .../src/store/saved-collections/slice.ts | 72 ++++++++ .../src/store/saved-collections/types.ts | 7 + .../web/src/common/store/account/sagas.js | 9 +- .../common/store/cache/collections/sagas.js | 8 +- .../collections/utils/retrieveCollections.ts | 59 ++++--- .../web/src/common/store/cache/users/sagas.js | 4 +- .../parseAndProcessNotifications.ts | 7 +- .../common/store/pages/collection/sagas.js | 8 +- .../src/common/store/pages/explore/sagas.ts | 9 +- .../src/common/store/pages/signon/sagas.js | 20 +-- .../common/store/saved-collections/sagas.ts | 27 +++ .../src/components/actions-tab/ActionsTab.js | 6 +- .../web/src/components/card/desktop/Card.tsx | 31 +++- .../card/desktop/CollectionArtCard.tsx | 7 +- .../components/card/desktop/UserArtCard.tsx | 7 +- packages/web/src/hooks/useGoToRoute.ts | 9 + .../pages/saved-page/SavedPageProvider.tsx | 22 --- .../components/desktop/SavedPage.tsx | 163 +++++++++++------- .../components/mobile/SavedPage.tsx | 158 ++++++++++------- .../src/pages/saved-page/components/utils.ts | 7 + packages/web/src/store/sagas.ts | 2 + 27 files changed, 572 insertions(+), 224 deletions(-) create mode 100644 packages/common/src/hooks/useSavedCollections.ts create mode 100644 packages/common/src/store/saved-collections/index.ts create mode 100644 packages/common/src/store/saved-collections/selectors.ts create mode 100644 packages/common/src/store/saved-collections/slice.ts create mode 100644 packages/common/src/store/saved-collections/types.ts create mode 100644 packages/web/src/common/store/saved-collections/sagas.ts create mode 100644 packages/web/src/hooks/useGoToRoute.ts create mode 100644 packages/web/src/pages/saved-page/components/utils.ts diff --git a/packages/common/src/hooks/index.ts b/packages/common/src/hooks/index.ts index 500570491f8..f09432c08b3 100644 --- a/packages/common/src/hooks/index.ts +++ b/packages/common/src/hooks/index.ts @@ -17,3 +17,4 @@ export * from './useLinkUnfurlMetadata' export * from './useThrottledCallback' export * from './useDebouncedCallback' export * from './useSetInboxPermissions' +export * from './useSavedCollections' diff --git a/packages/common/src/hooks/useSavedCollections.ts b/packages/common/src/hooks/useSavedCollections.ts new file mode 100644 index 00000000000..8375a99724b --- /dev/null +++ b/packages/common/src/hooks/useSavedCollections.ts @@ -0,0 +1,84 @@ +import { useCallback, useEffect, useState } from 'react' + +import { useDispatch, useSelector } from 'react-redux' + +import { Status } from 'models/Status' + +import { accountActions } from '../store/account' +import { + savedCollectionsActions, + savedCollectionsSelectors +} from '../store/saved-collections' + +const { fetchSavedPlaylists } = accountActions +const { fetchCollections } = savedCollectionsActions + +const { + getAccountAlbums, + getSavedAlbumsState, + getFetchedAlbumsWithDetails, + 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 = { + pageSize?: number +} +export function useSavedAlbumsDetails({ + pageSize = DEFAULT_PAGE_SIZE +}: UseSavedAlbumDetailsConfig) { + const dispatch = useDispatch() + const [hasFetched, setHasFetched] = useState(false) + const { unfetched: unfetchedAlbums, fetched: albumsWithDetails } = + useSelector(getFetchedAlbumsWithDetails) + const { status } = useSelector(getSavedAlbumsState) + + const fetchMore = useCallback(() => { + if (status === Status.LOADING || unfetchedAlbums.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() + + useEffect(() => { + dispatch(fetchSavedPlaylists()) + }, [dispatch]) +} diff --git a/packages/common/src/store/index.ts b/packages/common/src/store/index.ts index 4f3a2525ba5..fc77b9dac8f 100644 --- a/packages/common/src/store/index.ts +++ b/packages/common/src/store/index.ts @@ -31,3 +31,4 @@ export * from './premium-content' export * from './collectibles' export * from './solana' export * from './playlist-updates' +export * from './saved-collections' diff --git a/packages/common/src/store/reducers.ts b/packages/common/src/store/reducers.ts index b73220662e7..31a9c380a1a 100644 --- a/packages/common/src/store/reducers.ts +++ b/packages/common/src/store/reducers.ts @@ -68,6 +68,7 @@ import { recoveryEmailReducer, RecoveryEmailState } from './recovery-email' import remixSettingsReducer, { RemixSettingsState } from './remix-settings/slice' +import savedCollectionsReducer from './saved-collections/slice' import solanaReducer from './solana/slice' import stemsUpload from './stems-upload/slice' import tippingReducer from './tipping/slice' @@ -149,6 +150,8 @@ export const reducers = () => ({ // @ts-ignore users: asCache(usersReducer, Kind.USERS), + savedCollections: savedCollectionsReducer, + // Playback queue, player, @@ -268,6 +271,7 @@ export type CommonState = { // TODO: missing types for internally managed api slice state api: any + savedCollections: ReturnType // Playback queue: ReturnType diff --git a/packages/common/src/store/saved-collections/index.ts b/packages/common/src/store/saved-collections/index.ts new file mode 100644 index 00000000000..be27ec74395 --- /dev/null +++ b/packages/common/src/store/saved-collections/index.ts @@ -0,0 +1,6 @@ +export { + default as savedCollectionsReducer, + actions as savedCollectionsActions +} from './slice' +export * as savedCollectionsSelectors from './selectors' +export * from './types' diff --git a/packages/common/src/store/saved-collections/selectors.ts b/packages/common/src/store/saved-collections/selectors.ts new file mode 100644 index 00000000000..6371a771f23 --- /dev/null +++ b/packages/common/src/store/saved-collections/selectors.ts @@ -0,0 +1,58 @@ +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' + +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 getAccountAlbums = createSelector( + [getAccountCollections, getAccountStatus], + (collections, status) => ({ + status, + data: Object.values(collections).filter((c) => c.is_album) + }) +) + +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( + (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) => ({ + status, + data: Object.values(collections).filter((c) => !c.is_album) + }) +) diff --git a/packages/common/src/store/saved-collections/slice.ts b/packages/common/src/store/saved-collections/slice.ts new file mode 100644 index 00000000000..38868316d0f --- /dev/null +++ b/packages/common/src/store/saved-collections/slice.ts @@ -0,0 +1,72 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit' + +import { ID } from '../../models/Identifiers' +import { Status } from '../../models/Status' + +import { CollectionType } from './types' + +type FetchCollectionsPayload = { + type: CollectionType + ids: ID[] +} + +type FetchCollectionsSucceededPayload = { + type: CollectionType +} + +type FetchCollectionsFailedPayload = { + type: CollectionType +} + +export type CollectionListState = { + status: Status +} + +export type SavedCollectionsState = { + albums: CollectionListState + playlists: CollectionListState +} + +const initialState: SavedCollectionsState = { + albums: { + status: Status.IDLE + }, + playlists: { + status: Status.IDLE + } +} + +const slice = createSlice({ + name: 'saved-collections', + initialState, + reducers: { + fetchCollections: ( + state, + action: PayloadAction + ) => { + state[action.payload.type].status = Status.LOADING + }, + fetchCollectionsSucceeded: ( + state, + action: PayloadAction + ) => { + const list = state[action.payload.type] + list.status = Status.SUCCESS + }, + fetchCollectionsFailed: ( + state, + action: PayloadAction + ) => { + state[action.payload.type].status = Status.ERROR + } + } +}) + +export const { + fetchCollections, + fetchCollectionsSucceeded, + fetchCollectionsFailed +} = slice.actions + +export const actions = slice.actions +export default slice.reducer diff --git a/packages/common/src/store/saved-collections/types.ts b/packages/common/src/store/saved-collections/types.ts new file mode 100644 index 00000000000..17451d11349 --- /dev/null +++ b/packages/common/src/store/saved-collections/types.ts @@ -0,0 +1,7 @@ +import { Collection } from '../../models/Collection' + +export type CollectionType = 'albums' | 'playlists' + +export type CollectionWithOwner = Collection & { + ownerHandle: string +} diff --git a/packages/web/src/common/store/account/sagas.js b/packages/web/src/common/store/account/sagas.js index cf39fa741c9..060dddd83d8 100644 --- a/packages/web/src/common/store/account/sagas.js +++ b/packages/web/src/common/store/account/sagas.js @@ -14,11 +14,12 @@ import { import { call, put, fork, select, takeEvery } from 'redux-saga/effects' import { identify } from 'common/store/analytics/actions' -import { retrieveCollections } from 'common/store/cache/collections/utils' import { addPlaylistsNotInLibrary } from 'common/store/playlist-library/sagas' import { updateProfileAsync } from 'common/store/profile/sagas' import { waitForWrite, waitForRead } from 'utils/sagaHelpers' +import { retrieveCollections } from '../cache/collections/utils' + import disconnectedWallets from './disconnected_wallet_fix.json' const { fetchProfile } = profilePageActions @@ -364,7 +365,7 @@ function* fetchSavedAlbumsAsync() { yield waitForRead() const cachedSavedAlbums = yield select(getAccountAlbumIds) if (cachedSavedAlbums.length > 0) { - yield call(retrieveCollections, null, cachedSavedAlbums) + yield call(retrieveCollections, cachedSavedAlbums) } } @@ -375,7 +376,7 @@ function* fetchSavedPlaylistsAsync() { yield fork(function* () { const savedPlaylists = yield select(getAccountSavedPlaylistIds) if (savedPlaylists.length > 0) { - yield call(retrieveCollections, null, savedPlaylists) + yield call(retrieveCollections, savedPlaylists) } }) @@ -383,7 +384,7 @@ function* fetchSavedPlaylistsAsync() { yield fork(function* () { const ownPlaylists = yield select(getAccountOwnedPlaylistIds) if (ownPlaylists.length > 0) { - yield call(retrieveCollections, null, ownPlaylists) + yield call(retrieveCollections, ownPlaylists) } }) } diff --git a/packages/web/src/common/store/cache/collections/sagas.js b/packages/web/src/common/store/cache/collections/sagas.js index 5f3063497f4..f5ecce1f6dc 100644 --- a/packages/web/src/common/store/cache/collections/sagas.js +++ b/packages/web/src/common/store/cache/collections/sagas.js @@ -390,12 +390,10 @@ function* addTrackToPlaylistAsync(action) { // Retrieve tracks with the the collection so we confirm with the // most up-to-date information. - const { collections } = yield call( - retrieveCollections, + const { collections } = yield call(retrieveCollections, [action.playlistId], { userId, - [action.playlistId], - true - ) + fetchTracks: true + }) const playlist = collections[action.playlistId] const trackUid = makeUid( diff --git a/packages/web/src/common/store/cache/collections/utils/retrieveCollections.ts b/packages/web/src/common/store/cache/collections/utils/retrieveCollections.ts index 841113f71e6..e3a54c35eb7 100644 --- a/packages/web/src/common/store/cache/collections/utils/retrieveCollections.ts +++ b/packages/web/src/common/store/cache/collections/utils/retrieveCollections.ts @@ -28,6 +28,11 @@ const { getCollections } = cacheCollectionsSelectors const { setPermalink } = cacheCollectionsActions const getUserId = accountSelectors.getUserId +// Attempting to fetch more than this amount at once could result in a 400 +// due to the URL being too long. +const COLLECTIONS_BATCH_LIMIT = 50 +const TRACKS_BATCH_LIMIT = 200 + function* markCollectionDeleted( collectionMetadatas: CollectionMetadata[] ): Generator { @@ -55,7 +60,7 @@ export function* retrieveTracksForCollections( ...new Set(allTrackIds.filter((id) => !excludedTrackIdSet.has(id))) ] const chunkedTracks = yield* all( - chunk(filteredTrackIds, 200).map((chunkedTrackIds) => + chunk(filteredTrackIds, TRACKS_BATCH_LIMIT).map((chunkedTrackIds) => call(retrieveTracks, { trackIds: chunkedTrackIds }) @@ -182,10 +187,7 @@ export function* retrieveCollectionByPermalink( }) // Process any local deletions on the client - const metadatasWithDeleted = yield* call( - markCollectionDeleted, - metadatas! - ) + const metadatasWithDeleted = yield* call(markCollectionDeleted, metadatas) return metadatasWithDeleted }, @@ -221,21 +223,29 @@ export function* retrieveCollectionByPermalink( return { collections: entries, uids } } -/** - * Retrieves collections from the cache or from source - */ -export function* retrieveCollections( +export type RetrieveCollectionsConfig = { + // whether or not to fetch the tracks inside eachn collection + fetchTracks?: boolean // optional owner of collections to fetch (TODO: to be removed) - userId: ID | null, - // ids to retrieve - collectionIds: ID[], - // whether or not to fetch the tracks inside the playlist - fetchTracks = false, + userId?: ID | null /** whether or not fetching this collection requires it to have all its tracks. * In the case where a collection is already cached with partial tracks, use this flag to refetch from source. */ - requiresAllTracks = false + requiresAllTracks?: boolean +} +/** + * Retrieves collections from the cache or from source. If requesting more than + * `COLLECTIONS_BATCH_LIMIT`, will break API requests up into chunks. + */ +export function* retrieveCollections( + collectionIds: ID[], + config?: RetrieveCollectionsConfig ) { + const { + userId = null, + fetchTracks = false, + requiresAllTracks = false + } = config ?? {} // @ts-ignore retrieve should be refactored to ts first const { entries, uids } = yield* call(retrieve, { ids: collectionIds, @@ -259,20 +269,27 @@ export function* retrieveCollections( getEntriesTimestamp: selectEntriesTimestamp, retrieveFromSource: function* (ids: ID[]) { const audiusBackendInstance = yield* getContext('audiusBackendInstance') - let metadatas + let metadatas: CollectionMetadata[] if (ids.length === 1) { metadatas = yield* call(retrieveCollection, { playlistId: ids[0] }) } else { // TODO: Remove this branch when we have batched endpoints in new V1 api. - metadatas = yield* call(audiusBackendInstance.getPlaylists, userId, ids) + // Request ids in chunks if we're asked for too many + const chunks = yield* all( + chunk(ids, COLLECTIONS_BATCH_LIMIT).map((chunkedCollectionIds) => + call( + audiusBackendInstance.getPlaylists, + userId, + chunkedCollectionIds + ) + ) + ) + metadatas = chunks.flat() } // Process any local deletions on the client - const metadatasWithDeleted = yield* call( - markCollectionDeleted, - metadatas! - ) + const metadatasWithDeleted = yield* call(markCollectionDeleted, metadatas) return metadatasWithDeleted }, diff --git a/packages/web/src/common/store/cache/users/sagas.js b/packages/web/src/common/store/cache/users/sagas.js index deacb2c330c..c90fe8ada4f 100644 --- a/packages/web/src/common/store/cache/users/sagas.js +++ b/packages/web/src/common/store/cache/users/sagas.js @@ -118,7 +118,9 @@ export function* fetchUserCollections(userId) { ]) ) } - const { collections } = yield call(retrieveCollections, userId, playlistIds) + const { collections } = yield call(retrieveCollections, playlistIds, { + userId + }) const cachedCollectionIds = Object.values(collections).map( (c) => c.playlist_id ) diff --git a/packages/web/src/common/store/notifications/parseAndProcessNotifications.ts b/packages/web/src/common/store/notifications/parseAndProcessNotifications.ts index 4e336992c03..84a7690b59e 100644 --- a/packages/web/src/common/store/notifications/parseAndProcessNotifications.ts +++ b/packages/web/src/common/store/notifications/parseAndProcessNotifications.ts @@ -148,12 +148,7 @@ export function* parseAndProcessNotifications( const [tracks] = yield* all([ call(retrieveTracks, { trackIds: trackIdsToFetch }), - call( - retrieveCollections, - null, // userId - collectionIdsToFetch, // collection ids - false // fetchTracks - ), + call(retrieveCollections, collectionIdsToFetch), call( fetchUsers, userIdsToFetch, // userIds diff --git a/packages/web/src/common/store/pages/collection/sagas.js b/packages/web/src/common/store/pages/collection/sagas.js index c4859cd0f2d..de7f403d6d3 100644 --- a/packages/web/src/common/store/pages/collection/sagas.js +++ b/packages/web/src/common/store/pages/collection/sagas.js @@ -36,13 +36,7 @@ function* watchFetchCollection() { /* requiresAllTracks */ true ) } else { - retrievedCollections = yield call( - retrieveCollections, - null, - [collectionId], - /* fetchTracks */ false, - /* requiresAllTracks */ true - ) + retrievedCollections = yield call(retrieveCollections, [collectionId]) } const { collections, uids: collectionUids } = retrievedCollections diff --git a/packages/web/src/common/store/pages/explore/sagas.ts b/packages/web/src/common/store/pages/explore/sagas.ts index 55d39c99b9a..17fdb7dcf67 100644 --- a/packages/web/src/common/store/pages/explore/sagas.ts +++ b/packages/web/src/common/store/pages/explore/sagas.ts @@ -47,12 +47,7 @@ function* watchFetchExplore() { EXPLORE_CONTENT_URL ?? STATIC_EXPLORE_CONTENT_URL ) if (!isNativeMobile) { - yield* call( - retrieveCollections, - null, - exploreContent.featuredPlaylists, - false - ) + yield* call(retrieveCollections, exploreContent.featuredPlaylists) yield* call(fetchUsers, exploreContent.featuredProfiles) } @@ -67,7 +62,7 @@ function* watchFetchExplore() { function* watchFetchPlaylists() { yield* takeEvery(fetchPlaylists.type, function* fetchPlaylistsAsync() { const featuredPlaylistIds = yield* select(getPlaylistIds) - yield* call(retrieveCollections, null, featuredPlaylistIds, false) + yield* call(retrieveCollections, featuredPlaylistIds) yield* put(fetchPlaylistsSucceded()) }) } diff --git a/packages/web/src/common/store/pages/signon/sagas.js b/packages/web/src/common/store/pages/signon/sagas.js index fd65ec46c0e..e39403d2270 100644 --- a/packages/web/src/common/store/pages/signon/sagas.js +++ b/packages/web/src/common/store/pages/signon/sagas.js @@ -1,21 +1,21 @@ import { + ELECTRONIC_SUBGENRES, FavoriteSource, - Name, FeatureFlags, - ELECTRONIC_SUBGENRES, Genre, - accountSelectors, + MAX_HANDLE_LENGTH, + Name, + PushNotificationSetting, accountActions, + accountSelectors, cacheUsersSelectors, collectionsSocialActions, - solanaSelectors, - usersSocialActions as socialActions, - getContext, - settingsPageActions, - MAX_HANDLE_LENGTH, - PushNotificationSetting, getCityAndRegion, + getContext, processAndCacheUsers, + settingsPageActions, + usersSocialActions as socialActions, + solanaSelectors, toastActions } from '@audius/common' import { push as pushRoute } from 'connected-react-router' @@ -591,7 +591,7 @@ function* signIn(action) { function* followCollections(collectionIds, favoriteSource) { yield call(waitForWrite) try { - const result = yield retrieveCollections(null, collectionIds) + const result = yield* call(retrieveCollections, collectionIds) for (let i = 0; i < collectionIds.length; i++) { const id = collectionIds[i] diff --git a/packages/web/src/common/store/saved-collections/sagas.ts b/packages/web/src/common/store/saved-collections/sagas.ts new file mode 100644 index 00000000000..f55e064ddeb --- /dev/null +++ b/packages/web/src/common/store/saved-collections/sagas.ts @@ -0,0 +1,27 @@ +import { savedCollectionsActions, waitForRead } from '@audius/common' +import { call, put, takeEvery } from 'typed-redux-saga' + +import { retrieveCollections } from '../cache/collections/utils' + +const { fetchCollections, fetchCollectionsSucceeded } = savedCollectionsActions + +function* fetchCollectionsAsync(action: ReturnType) { + const { type, ids } = action.payload + yield waitForRead() + + yield* call(retrieveCollections, ids) + + yield put( + fetchCollectionsSucceeded({ + type + }) + ) +} + +function* watchFetchCollections() { + yield takeEvery(fetchCollections.type, fetchCollectionsAsync) +} + +export default function sagas() { + return [watchFetchCollections] +} diff --git a/packages/web/src/components/actions-tab/ActionsTab.js b/packages/web/src/components/actions-tab/ActionsTab.js index 054e7553089..73fc7d5b7cc 100644 --- a/packages/web/src/components/actions-tab/ActionsTab.js +++ b/packages/web/src/components/actions-tab/ActionsTab.js @@ -248,14 +248,14 @@ ActionsTab.propTypes = { isHidden: PropTypes.bool, minimized: PropTypes.bool, standalone: PropTypes.bool, - isPublic: PropTypes.boolean, + isPublic: PropTypes.bool, isDisabled: PropTypes.bool, includeEdit: PropTypes.bool, direction: PropTypes.oneOf(['vertical', 'horizontal']), variant: PropTypes.oneOf(['track', 'playlist', 'album']), containerStyles: PropTypes.string, - currentUserReposted: PropTypes.boolean, - currentUserSaved: PropTypes.boolean, + currentUserReposted: PropTypes.bool, + currentUserSaved: PropTypes.bool, handle: PropTypes.string, trackTitle: PropTypes.string, trackId: PropTypes.number, diff --git a/packages/web/src/components/card/desktop/Card.tsx b/packages/web/src/components/card/desktop/Card.tsx index 5c50fa913d1..4a071d6648e 100644 --- a/packages/web/src/components/card/desktop/Card.tsx +++ b/packages/web/src/components/card/desktop/Card.tsx @@ -33,6 +33,8 @@ import { isDescendantElementOf } from 'utils/domUtils' import styles from './Card.module.css' +const ARTWORK_LOAD_TIMEOUT_MS = 500 + const cardSizeStyles = { small: { cardContainer: styles.smallContainer, @@ -54,7 +56,7 @@ const cardSizeStyles = { } } -type CardProps = { +export type CardProps = { className?: string id: ID userId?: ID @@ -92,12 +94,15 @@ const UserImage = (props: { isLoading?: boolean callback?: () => void }) => { + const { callback } = props const image = useUserProfilePicture( props.id, props.imageSize, SquareSizes.SIZE_480_BY_480 ) - if (image && props.callback) props.callback() + useEffect(() => { + if (image && callback) callback() + }, [image, callback]) return ( void }) => { + const { callback } = props const image = useCollectionCoverArt( props.id, props.imageSize, @@ -120,7 +126,9 @@ const CollectionImage = (props: { placeholderArt ) - if (image && props.callback) props.callback() + useEffect(() => { + if (image && callback) callback() + }, [image, callback]) return ( { if (artworkLoaded && setDidLoad) { - setDidLoad(index!) + setTimeout(() => setDidLoad(index!)) } - }, [artworkLoaded, setDidLoad, index, isLoading]) + }, [artworkLoaded, setDidLoad, index]) + + useEffect(() => { + // Force a transition if we take too long to signal + if (!artworkLoaded) { + const timerId = setTimeout( + () => setArtworkLoaded(true), + ARTWORK_LOAD_TIMEOUT_MS + ) + return () => clearTimeout(timerId) + } + }, [artworkLoaded, setArtworkLoaded]) const artworkCallback = useCallback(() => { setArtworkLoaded(true) @@ -246,7 +265,7 @@ const Card = ({ ) : ( setArtworkLoaded(true)} + callback={artworkCallback} id={id} imageSize={imageSize as CoverArtSizes} /> diff --git a/packages/web/src/components/card/desktop/CollectionArtCard.tsx b/packages/web/src/components/card/desktop/CollectionArtCard.tsx index ce5ce5096eb..8db20720661 100644 --- a/packages/web/src/components/card/desktop/CollectionArtCard.tsx +++ b/packages/web/src/components/card/desktop/CollectionArtCard.tsx @@ -1,4 +1,4 @@ -import { useCallback, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { ID, @@ -126,7 +126,10 @@ const CollectionArtCard = g( SquareSizes.SIZE_480_BY_480, placeholderArt ) - if (image && setDidLoad) setDidLoad(index) + + useEffect(() => { + if (image && setDidLoad) setDidLoad(index) + }, [image, setDidLoad, index]) const menu = { type: (is_album ? 'album' : 'playlist') as MenuType, diff --git a/packages/web/src/components/card/desktop/UserArtCard.tsx b/packages/web/src/components/card/desktop/UserArtCard.tsx index 77ea1aeb6b1..d9232b579da 100644 --- a/packages/web/src/components/card/desktop/UserArtCard.tsx +++ b/packages/web/src/components/card/desktop/UserArtCard.tsx @@ -1,4 +1,4 @@ -import { useCallback } from 'react' +import { useCallback, useEffect } from 'react' import { ID, @@ -82,7 +82,10 @@ const UserArtCard = g( SquareSizes.SIZE_480_BY_480, placeholderArt ) - if (image && setDidLoad) setDidLoad(index) + + useEffect(() => { + if (image && setDidLoad) setDidLoad(index) + }, [image, setDidLoad, index]) return (
diff --git a/packages/web/src/hooks/useGoToRoute.ts b/packages/web/src/hooks/useGoToRoute.ts new file mode 100644 index 00000000000..2fb54c4059a --- /dev/null +++ b/packages/web/src/hooks/useGoToRoute.ts @@ -0,0 +1,9 @@ +import { useCallback } from 'react' + +import { push as pushRoute } from 'connected-react-router' +import { useDispatch } from 'react-redux' + +export function useGoToRoute() { + const dispatch = useDispatch() + return useCallback((route: string) => dispatch(pushRoute(route)), [dispatch]) +} diff --git a/packages/web/src/pages/saved-page/SavedPageProvider.tsx b/packages/web/src/pages/saved-page/SavedPageProvider.tsx index 6a278625039..0fe0fdf2025 100644 --- a/packages/web/src/pages/saved-page/SavedPageProvider.tsx +++ b/packages/web/src/pages/saved-page/SavedPageProvider.tsx @@ -7,7 +7,6 @@ import { FavoriteSource, PlaybackSource, Name, - formatCount, accountSelectors, accountActions, lineupSelectors, @@ -122,7 +121,6 @@ class SavedPage extends PureComponent { this.state.sortMethod, this.state.sortDirection ) - this.props.fetchSavedAlbums() if (isMobile()) { this.props.fetchSavedPlaylists() } @@ -239,18 +237,6 @@ class SavedPage extends PureComponent { return [filteredMetadata, filteredIndex] } - getFilteredAlbums = ( - albums: SavedPageCollection[] - ): SavedPageCollection[] => { - const filterText = this.state.filterText - return albums.filter( - (item: SavedPageCollection) => - item.playlist_name.toLowerCase().indexOf(filterText.toLowerCase()) > - -1 || - item.ownerHandle.toLowerCase().indexOf(filterText.toLowerCase()) > -1 - ) - } - getFilteredPlaylists = ( playlists: SavedPageCollection[] ): SavedPageCollection[] => { @@ -430,12 +416,6 @@ class SavedPage extends PureComponent { }) } - formatCardSecondaryText = (saves: number, tracks: number) => { - const savesText = saves === 1 ? 'Favorite' : 'Favorites' - const tracksText = tracks === 1 ? 'Track' : 'Tracks' - return `${formatCount(saves)} ${savesText} • ${tracks} ${tracksText}` - } - render() { const isQueued = this.isQueued() const playingUid = this.getPlayingUid() @@ -488,7 +468,6 @@ class SavedPage extends PureComponent { onPlay: this.onPlay, onSortTracks: this.onSortTracks, onChangeTab: this.onChangeTab, - formatCardSecondaryText: this.formatCardSecondaryText, onClickRemove: null } @@ -498,7 +477,6 @@ class SavedPage extends PureComponent { onSave: this.onSave, onTogglePlay: this.onTogglePlay, - getFilteredAlbums: this.getFilteredAlbums, getFilteredPlaylists: this.getFilteredPlaylists } diff --git a/packages/web/src/pages/saved-page/components/desktop/SavedPage.tsx b/packages/web/src/pages/saved-page/components/desktop/SavedPage.tsx index 5e1caabd9c1..80f888c7925 100644 --- a/packages/web/src/pages/saved-page/components/desktop/SavedPage.tsx +++ b/packages/web/src/pages/saved-page/components/desktop/SavedPage.tsx @@ -1,24 +1,26 @@ -import { useContext } from 'react' +import { useCallback, useContext } from 'react' import { + CollectionWithOwner, ID, - UID, Lineup, - Status, - User, SavedPageTabs as ProfileTabs, + QueueItem, + SavedPageCollection, + SavedPageTrack, + Status, TrackRecord, + UID, + User, savedPageSelectors, - SavedPageTrack, - SavedPageCollection, - QueueItem + useSavedAlbumsDetails } from '@audius/common' import { Button, ButtonType, IconPause, IconPlay } from '@audius/stems' import { useSelector } from 'react-redux' import { ReactComponent as IconAlbum } from 'assets/img/iconAlbum.svg' import { ReactComponent as IconNote } from 'assets/img/iconNote.svg' -import Card from 'components/card/desktop/Card' +import Card, { CardProps } from 'components/card/desktop/Card' import FilterInput from 'components/filter-input/FilterInput' import Header from 'components/header/desktop/Header' import CardLineup from 'components/lineup/CardLineup' @@ -26,17 +28,99 @@ import Page from 'components/page/Page' import { dateSorter } from 'components/table' import { TracksTable, TracksTableColumn } from 'components/tracks-table' import EmptyTable from 'components/tracks-table/EmptyTable' +import { useGoToRoute } from 'hooks/useGoToRoute' import { useOrderedLoad } from 'hooks/useOrderedLoad' import useTabs from 'hooks/useTabs/useTabs' import { MainContentContext } from 'pages/MainContentContext' import { albumPage } from 'utils/route' +import { formatCardSecondaryText } from '../utils' + import styles from './SavedPage.module.css' const { getInitialFetchStatus } = savedPageSelectors const messages = { - filterPlaceholder: 'Filter Tracks' + filterPlaceholder: 'Filter Tracks', + emptyAlbumsHeader: 'You haven’t favorited any albums yet.', + emptyAlbumsBody: 'Once you have, this is where you’ll find them!', + emptyTracksHeader: 'You haven’t favorited any tracks yet.', + emptyTracksBody: 'Once you have, this is where you’ll find them!', + goToTrending: 'Go to Trending' +} + +type AlbumCardProps = Pick & { + album: CollectionWithOwner +} + +const AlbumCard = ({ album, index, isLoading, setDidLoad }: AlbumCardProps) => { + const goToRoute = useGoToRoute() + + const handleClick = useCallback(() => { + if (album.ownerHandle) { + goToRoute( + albumPage(album.ownerHandle, album.playlist_name, album.playlist_id) + ) + } + }, [album.playlist_name, album.playlist_id, album.ownerHandle, goToRoute]) + + return ( + + ) +} + +const AlbumsTabContent = () => { + const goToRoute = useGoToRoute() + + // Temporarily requesting large page size to ensure we get all albums + // until the list is updated to use `InfinteScroll` + const { data: albums } = useSavedAlbumsDetails({ pageSize: 9999 }) + const { isLoading, setDidLoad } = useOrderedLoad(albums.length) + const cards = albums.map((album, i) => { + return ( + + ) + }) + + return cards.length > 0 ? ( + + ) : ( + goToRoute('/trending')} + /> + ) } const tableColumns: TracksTableColumn[] = [ @@ -70,7 +154,6 @@ export type SavedPageProps = { onPlay: () => void onSortTracks: (sorters: any) => void onChangeTab: (tab: ProfileTabs) => void - formatCardSecondaryText: (saves: number, tracks: number) => string allTracksFetched: boolean filterText: string initialOrder: UID[] | null @@ -111,7 +194,6 @@ const SavedPage = ({ onSortChange, allTracksFetched, filterText, - formatCardSecondaryText, onChangeTab, onClickRow, onClickSave, @@ -128,8 +210,7 @@ const SavedPage = ({ status === Status.SUCCESS || entries.length ? getFilteredData(entries) : [[], -1] - const { isLoading: isLoadingAlbums, setDidLoad: setDidLoadAlbums } = - useOrderedLoad(account ? account.albums.length : 0) + const isEmpty = entries.length === 0 || !entries.some((entry: SavedPageTrack) => Boolean(entry.track_id)) @@ -177,45 +258,6 @@ const SavedPage = ({
) - const cards = account - ? account.albums.map((album, i) => { - return ( - - goToRoute( - albumPage( - album.ownerHandle, - album.playlist_name, - album.playlist_id - ) - ) - } - /> - ) - }) - : [] - const { tabs, body } = useTabs({ isMobile: false, didChangeTabsFrom: (_, to) => { @@ -238,9 +280,9 @@ const SavedPage = ({ elements: [ isEmpty && !tracksLoading ? ( goToRoute('/trending')} /> ) : ( @@ -270,16 +312,7 @@ const SavedPage = ({ /> ),
- {account && account.albums.length > 0 ? ( - - ) : ( - goToRoute('/trending')} - /> - )} +
] }) diff --git a/packages/web/src/pages/saved-page/components/mobile/SavedPage.tsx b/packages/web/src/pages/saved-page/components/mobile/SavedPage.tsx index 85f9264ed69..a2855a0a7a3 100644 --- a/packages/web/src/pages/saved-page/components/mobile/SavedPage.tsx +++ b/packages/web/src/pages/saved-page/components/mobile/SavedPage.tsx @@ -1,17 +1,26 @@ -import { ReactNode, useCallback, useEffect, useContext } from 'react' +import { + ReactNode, + useCallback, + useContext, + useEffect, + useMemo, + useState +} from 'react' import { + CollectionWithOwner, ID, - UID, - Name, Lineup, - Status, - User, + Name, + QueueItem, + SavedPageCollection, SavedPageTabs, SavedPageTrack, - SavedPageCollection, - QueueItem, - usePremiumContentAccessMap + Status, + UID, + User, + usePremiumContentAccessMap, + useSavedAlbumsDetails } from '@audius/common' import { Button, ButtonType } from '@audius/stems' import cn from 'classnames' @@ -30,8 +39,11 @@ import MobilePageContainer from 'components/mobile-page-container/MobilePageCont import { useMainPageHeader } from 'components/nav/store/context' import TrackList from 'components/track/mobile/TrackList' import { TrackItemAction } from 'components/track/mobile/TrackListItem' +import { useGoToRoute } from 'hooks/useGoToRoute' import useTabs from 'hooks/useTabs/useTabs' -import { albumPage, TRENDING_PAGE, playlistPage } from 'utils/route' +import { TRENDING_PAGE, albumPage, playlistPage } from 'utils/route' + +import { formatCardSecondaryText } from '../utils' import NewPlaylistButton from './NewPlaylistButton' import styles from './SavedPage.module.css' @@ -176,43 +188,78 @@ const TracksLineup = ({ ) } -const AlbumCardLineup = ({ - albums, - goToTrending, - onFilterChange, - filterText, - goToRoute, - getFilteredAlbums, - formatCardSecondaryText -}: { - albums: SavedPageCollection[] - goToTrending: () => void - onFilterChange: (e: any) => void - filterText: string - formatCardSecondaryText: (saves: number, tracks: number) => string - getFilteredAlbums: (albums: SavedPageCollection[]) => SavedPageCollection[] - goToRoute: (route: string) => void -}) => { - const filteredAlbums = getFilteredAlbums(albums || []) +type FilterCollectionsOptions = { + filterText?: string +} +const filterCollections = ( + collections: CollectionWithOwner[], + { filterText = '' }: FilterCollectionsOptions +) => { + return collections.filter((item: CollectionWithOwner) => { + if (filterText) { + const matchesPlaylistName = + item.playlist_name.toLowerCase().indexOf(filterText.toLowerCase()) > -1 + const matchesOwnerName = + item.ownerHandle.toLowerCase().indexOf(filterText.toLowerCase()) > -1 + + return matchesPlaylistName || matchesOwnerName + } + return true + }) +} + +type AlbumCardProps = { + album: CollectionWithOwner +} + +const AlbumCard = ({ album }: AlbumCardProps) => { + const goToRoute = useGoToRoute() + + const handleClick = useCallback(() => { + if (album.ownerHandle) { + goToRoute( + albumPage(album.ownerHandle, album.playlist_name, album.playlist_id) + ) + } + }, [album.playlist_name, album.playlist_id, album.ownerHandle, goToRoute]) + + return ( + + ) +} + +const AlbumCardLineup = () => { + const goToRoute = useGoToRoute() + // Temporarily requesting large page size to ensure we get all albums + // until the list is updated to use `InfinteScroll` + const { data: albums } = useSavedAlbumsDetails({ pageSize: 9999 }) + const [filterText, setFilterText] = useState('') + const filteredAlbums = useMemo( + () => filterCollections(albums, { filterText }), + [albums, filterText] + ) + + const handleGoToTrending = useCallback( + () => goToRoute(TRENDING_PAGE), + [goToRoute] + ) + const handleFilterChange = ({ + target: { value } + }: React.ChangeEvent) => setFilterText(value) + const albumCards = filteredAlbums.map((album) => { - return ( - - goToRoute( - albumPage(album.ownerHandle, album.playlist_name, album.playlist_id) - ) - } - /> - ) + return }) const contentRefCallback = useOffsetScroll() @@ -227,7 +274,7 @@ const AlbumCardLineup = ({ } - onClick={goToTrending} + onClick={handleGoToTrending} /> ) : (
@@ -235,7 +282,7 @@ const AlbumCardLineup = ({
@@ -262,7 +309,6 @@ const PlaylistCardLineup = ({ filterText, goToRoute, getFilteredPlaylists, - formatCardSecondaryText, playlistUpdates, updatePlaylistLastViewedAt }: { @@ -270,7 +316,6 @@ const PlaylistCardLineup = ({ goToTrending: () => void onFilterChange: (e: any) => void filterText: string - formatCardSecondaryText: (saves: number, tracks: number) => string getFilteredPlaylists: ( playlists: SavedPageCollection[] ) => SavedPageCollection[] @@ -409,7 +454,6 @@ export type SavedPageProps = { fetchSavedTracks: () => void resetSavedTracks: () => void updateLineupOrder: (updatedOrderIndices: UID[]) => void - getFilteredAlbums: (albums: SavedPageCollection[]) => SavedPageCollection[] getFilteredPlaylists: ( playlists: SavedPageCollection[] ) => SavedPageCollection[] @@ -437,11 +481,9 @@ const SavedPage = ({ isQueued, onTogglePlay, getFilteredData, - getFilteredAlbums, getFilteredPlaylists, onFilterChange, filterText, - formatCardSecondaryText, onSave, playlistUpdates, updatePlaylistLastViewedAt @@ -464,16 +506,7 @@ const SavedPage = ({ onSave={onSave} onTogglePlay={onTogglePlay} />, - , + , diff --git a/packages/web/src/pages/saved-page/components/utils.ts b/packages/web/src/pages/saved-page/components/utils.ts new file mode 100644 index 00000000000..aa852b21278 --- /dev/null +++ b/packages/web/src/pages/saved-page/components/utils.ts @@ -0,0 +1,7 @@ +import { formatCount } from '@audius/common' + +export const formatCardSecondaryText = (saves: number, tracks: number) => { + const savesText = saves === 1 ? 'Favorite' : 'Favorites' + const tracksText = tracks === 1 ? 'Track' : 'Tracks' + return `${formatCount(saves)} ${savesText} • ${tracks} ${tracksText}` +} diff --git a/packages/web/src/store/sagas.ts b/packages/web/src/store/sagas.ts index 5272ec30ac3..cc418bf8747 100644 --- a/packages/web/src/store/sagas.ts +++ b/packages/web/src/store/sagas.ts @@ -52,6 +52,7 @@ import profileSagas from 'common/store/profile/sagas' import queueSagas from 'common/store/queue/sagas' import recoveryEmailSagas from 'common/store/recovery-email/sagas' import remixSettingsSagas from 'common/store/remix-settings/sagas' +import savedCollectionsSagas from 'common/store/saved-collections/sagas' import searchAiBarSagas from 'common/store/search-ai-bar/sagas' import searchBarSagas from 'common/store/search-bar/sagas' import smartCollectionPageSagas from 'common/store/smart-collection/sagas' @@ -145,6 +146,7 @@ export default function* rootSaga() { collectionsSagas(), tracksSagas(), usersSagas(), + savedCollectionsSagas(), // Playback playerSagas(),