diff --git a/package-lock.json b/package-lock.json index 33940d7127..c5a864747d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13632,6 +13632,11 @@ "minimalistic-assert": "^1.0.1" } }, + "hashids": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/hashids/-/hashids-2.2.1.tgz", + "integrity": "sha512-+hQeKWwpSDiWFeu/3jKUvwboE4Z035gR6FnpscbHPOEEjCbgv2px9/Mlb3O0nOTRyZOw4MMFRYfVL3zctOV6OQ==" + }, "he": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/he/-/he-1.1.1.tgz", diff --git a/package.json b/package.json index 7cf4cd13a6..a93e4f93d9 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "glsl-noise": "0.0.0", "glsl-random": "0.0.4", "glslify-loader": "^1.0.2", + "hashids": "^2.2.1", "hex-rgb": "^1.0.0", "history": "^4.7.2", "hls.js": "^0.13.2", diff --git a/src/__mocks__/Hashids.js b/src/__mocks__/Hashids.js new file mode 100644 index 0000000000..482464bd03 --- /dev/null +++ b/src/__mocks__/Hashids.js @@ -0,0 +1,7 @@ +export const mockDecode = jest.fn() + +jest.mock('hashids', () => { + return jest.fn().mockImplementation(() => { + return { decode: mockDecode } + }) +}) diff --git a/src/containers/App.js b/src/containers/App.js index c16453345f..a8adf45a02 100644 --- a/src/containers/App.js +++ b/src/containers/App.js @@ -52,7 +52,10 @@ import { FOLLOWING_USERS_ROUTE, FOLLOWERS_USERS_ROUTE, TRENDING_GENRES, - APP_REDIRECT + APP_REDIRECT, + TRACK_ID_PAGE, + USER_ID_PAGE, + PLAYLIST_ID_PAGE } from 'utils/route' import 'utils/redirect' import { isMobile, getClient } from 'utils/clientUtil' @@ -704,6 +707,20 @@ class App extends Component { render={() => } /> + {/* Hash id routes */} + ( + + )} + /> + + + + { - const routeParams = this.getRouteParams(pathname) - if (routeParams) { - const { id, handle } = routeParams - if (forceFetch || id !== this.state.playlistId) { + const params = parseCollectionRoute(pathname) + if (params) { + const { handle, collectionId } = params + if (forceFetch || collectionId !== this.state.playlistId) { this.resetCollection() - this.setState({ playlistId: id as number }) - this.props.fetchCollection(handle, id as number) + this.setState({ playlistId: collectionId as number }) + this.props.fetchCollection(handle, collectionId as number) this.props.fetchTracks() } } + if ( this.props.smartCollection && this.props.smartCollection.playlist_contents @@ -288,31 +302,6 @@ class CollectionPage extends Component< } } - getRouteParams = (pathname: string) => { - const match = matchPath<{ - handle: string - collectionType: string - name: string - }>(pathname, { - path: '/:handle/:collectionType/:name', - exact: true - }) - if ( - !match || - (match.params.collectionType !== 'playlist' && - match.params.collectionType !== 'album') - ) { - return null - } - const collectionType = match.params.collectionType - const nameParts = match.params.name.split('-') - const handleEncoded = match.params.handle - const handle = decodeURIComponent(handleEncoded) - const name = nameParts.slice(0, -1).join('-') - const id = this.maybeParseInt(nameParts[nameParts.length - 1]) - return { id, name, handle, collectionType } - } - resetCollection = () => { const { collection: { collectionUid, userUid } @@ -806,7 +795,7 @@ function makeMapStateToProps() { function mapDispatchToProps(dispatch: Dispatch) { return { - fetchCollection: (handle: string, id: number) => + fetchCollection: (handle: string | null, id: number) => dispatch(collectionActions.fetchCollection(handle, id)), fetchTracks: () => dispatch(tracksActions.fetchLineupMetadatas(0, 200, false, undefined)), diff --git a/src/containers/collection-page/store/sagas.js b/src/containers/collection-page/store/sagas.js index 558d59329f..d45bacbd43 100644 --- a/src/containers/collection-page/store/sagas.js +++ b/src/containers/collection-page/store/sagas.js @@ -15,28 +15,33 @@ function* watchFetchCollection() { const collectionId = action.id const handle = action.handle - const user = yield call(fetchUserByHandle, handle) - if (!user) { - yield put(collectionActions.fetchCollectionFailed()) + let user + if (handle) { + user = yield call(fetchUserByHandle, handle) + if (!user) { + yield put(collectionActions.fetchCollectionFailed()) + } } - const userUid = makeUid(Kind.USERS, user.user_id) // Retrieve collections and fetch nested tracks const { collections, uids: collectionUids } = yield call( retrieveCollections, - user.user_id, + user?.user_id ?? null, [collectionId], true ) if (Object.values(collections).length === 0) { - yield put(collectionActions.fetchCollectionFailed(userUid)) + yield put(collectionActions.fetchCollectionFailed()) } const collection = collections[collectionId] + const userUid = makeUid(Kind.USERS, collection.playlist_owner_id) const collectionUid = collectionUids[collectionId] if (collection) { yield put( - cacheActions.subscribe(Kind.USERS, [{ uid: userUid, id: user.user_id }]) + cacheActions.subscribe(Kind.USERS, [ + { uid: userUid, id: collection.playlist_owner_id } + ]) ) yield put( collectionActions.fetchCollectionSucceeded( diff --git a/src/containers/profile-page/ProfilePageProvider.tsx b/src/containers/profile-page/ProfilePageProvider.tsx index 938d72cb89..a5f56c2a79 100644 --- a/src/containers/profile-page/ProfilePageProvider.tsx +++ b/src/containers/profile-page/ProfilePageProvider.tsx @@ -1,8 +1,7 @@ import React, { PureComponent } from 'react' import { connect } from 'react-redux' import { withRouter, RouteComponentProps } from 'react-router-dom' -import { push as pushRoute } from 'connected-react-router' -import { matchPath } from 'react-router' +import { push as pushRoute, replace } from 'connected-react-router' import moment from 'moment' import { UnregisterCallback } from 'history' import { AppState, Status } from 'store/types' @@ -22,6 +21,7 @@ import { feedActions } from './store/lineups/feed/actions' import { makeGetLineupMetadatas } from 'store/lineup/selectors' import { getAccountUser } from 'store/account/selectors' import { getPlaying, getBuffering } from 'store/player/selectors' +import { getLocationPathname } from 'store/routing/selectors' import { makeGetCurrent } from 'store/queue/selectors' import { @@ -30,7 +30,7 @@ import { getProfileTracksLineup } from './store/selectors' import { CollectionSortMode } from 'containers/profile-page/store/types' -import { NOT_FOUND_PAGE, staticRoutes } from 'utils/route' +import { NOT_FOUND_PAGE, profilePage } from 'utils/route' import { newUserMetadata } from 'schemas' import { formatCount } from 'utils/formatUtil' @@ -44,6 +44,7 @@ import { } from 'store/application/ui/mobileOverflowModal/types' import { make, TrackEvent } from 'store/analytics/actions' import { Name, FollowSource, ShareSource } from 'services/analytics' +import { parseUserRoute } from 'utils/route/userRouteParser' const INITIAL_UPDATE_FIELDS = { updatedName: null, @@ -116,7 +117,11 @@ class ProfilePage extends PureComponent { this.props.resetProfile() this.props.resetArtistTracks() this.props.resetUserFeedTracks() - this.fetchProfile(location.pathname) + const params = parseUserRoute(location.pathname) + if (params) { + // Fetch profile if this is a new profile page + this.fetchProfile(location.pathname) + } this.setState({ ...INITIAL_UPDATE_FIELDS }) @@ -129,7 +134,7 @@ class ProfilePage extends PureComponent { } componentDidUpdate(prevProps: ProfilePageProps, prevState: ProfilePageState) { - const { profile, artistTracks, goToRoute } = this.props + const { pathname, profile, artistTracks, goToRoute } = this.props const { activeTab } = this.state if (profile && profile.status === Status.ERROR) { @@ -161,6 +166,18 @@ class ProfilePage extends PureComponent { activeTab: Tabs.REPOSTS }) } + + // Replace the URL with the properly formatted /handle route + if (profile && profile.profile && profile.status === Status.SUCCESS) { + const params = parseUserRoute(pathname) + if (params) { + const { handle } = params + if (!handle) { + const newPath = profilePage(profile.profile.handle) + this.props.replaceRoute(newPath) + } + } + } } // Check that the sorted order has the _artist_pick track as the first @@ -212,18 +229,17 @@ class ProfilePage extends PureComponent { shouldSetLoading = true, deleteExistingEntry = false ) => { - const match = matchPath<{ handle: string }>(pathname, { - path: '/:handle', - exact: true - }) - if (match && !staticRoutes.has(pathname)) { - const handle = match.params.handle + const params = parseUserRoute(pathname) + if (params) { this.props.fetchProfile( - handle, + params.handle, + params.userId, forceUpdate, shouldSetLoading, deleteExistingEntry ) + } else { + this.props.goToRoute(NOT_FOUND_PAGE) } } @@ -868,7 +884,8 @@ function makeMapStateToProps() { userFeed: getUserFeedMetadatas(state), currentQueueItem: getCurrentQueueItem(state), playing: getPlaying(state), - buffering: getBuffering(state) + buffering: getBuffering(state), + pathname: getLocationPathname(state) }) return mapStateToProps } @@ -876,7 +893,8 @@ function makeMapStateToProps() { function mapDispatchToProps(dispatch: Dispatch) { return { fetchProfile: ( - handle: string, + handle: string | null, + userId: ID | null, forceUpdate: boolean, shouldSetLoading: boolean, deleteExistingEntry: boolean @@ -884,6 +902,7 @@ function mapDispatchToProps(dispatch: Dispatch) { dispatch( profileActions.fetchProfile( handle, + userId, forceUpdate, shouldSetLoading, deleteExistingEntry @@ -893,6 +912,7 @@ function mapDispatchToProps(dispatch: Dispatch) { dispatch(profileActions.updateProfile(metadata)), resetProfile: () => dispatch(profileActions.resetProfile()), goToRoute: (route: string) => dispatch(pushRoute(route)), + replaceRoute: (route: string) => dispatch(replace(route)), updateCollectionOrder: (mode: CollectionSortMode) => dispatch(profileActions.updateCollectionSortMode(mode)), onFollow: (userId: ID) => diff --git a/src/containers/profile-page/store/actions.js b/src/containers/profile-page/store/actions.js index 83c2d05cab..00d1d691eb 100644 --- a/src/containers/profile-page/store/actions.js +++ b/src/containers/profile-page/store/actions.js @@ -23,8 +23,11 @@ export const UPDATE_MOST_USED_TAGS = 'PROFILE/UPDATE_MOST_USED_TAGS' export const SET_NOTIFICATION_SUBSCRIPTION = 'PROFILE/SET_NOTIFICATION_SUBSCRIPTION' +// Either handle or userId is required +// TODO: Move this to redux toolkit export function fetchProfile( handle, + userId, forceUpdate, shouldSetLoading, deleteExistingEntry @@ -32,6 +35,7 @@ export function fetchProfile( return { type: FETCH_PROFILE, handle, + userId, forceUpdate, shouldSetLoading, deleteExistingEntry diff --git a/src/containers/profile-page/store/reducer.js b/src/containers/profile-page/store/reducer.js index b74b40acc7..3413711dbd 100644 --- a/src/containers/profile-page/store/reducer.js +++ b/src/containers/profile-page/store/reducer.js @@ -52,6 +52,7 @@ const actionsMap = { return { ...state, handle: action.handle, + userId: action.userId, status: action.shouldSetLoading ? Status.LOADING : state.status } }, @@ -59,7 +60,8 @@ const actionsMap = { return { ...state, status: Status.SUCCESS, - userId: action.userId + userId: action.userId, + handle: action.handle } }, [FETCH_FOLLOW_USERS](state, action) { diff --git a/src/containers/profile-page/store/sagas.js b/src/containers/profile-page/store/sagas.js index e65896471c..49f43aa52f 100644 --- a/src/containers/profile-page/store/sagas.js +++ b/src/containers/profile-page/store/sagas.js @@ -26,6 +26,7 @@ import { import { makeUid, makeUids, makeKindId } from 'utils/uid' import { + fetchUsers, fetchUserByHandle, fetchUserCollections } from 'store/cache/users/sagas' @@ -44,14 +45,26 @@ function* watchFetchProfile() { function* fetchProfileAsync(action) { try { - const user = yield call( - fetchUserByHandle, - action.handle, - new Set(), - action.forceUpdate, - action.shouldSetLoading, - action.deleteExistingEntry - ) + let user + if (action.handle) { + user = yield call( + fetchUserByHandle, + action.handle, + new Set(), + action.forceUpdate, + action.shouldSetLoading, + action.deleteExistingEntry + ) + } else if (action.userId) { + const users = yield call( + fetchUsers, + [action.userId], + new Set(), + action.forceUpdate, + action.shouldSetLoading + ) + user = users.entries[action.userId] + } if (!user) { const isReachable = yield select(getIsReachable) if (isReachable) { @@ -59,7 +72,7 @@ function* fetchProfileAsync(action) { } return } - yield put(profileActions.fetchProfileSucceeded(action.handle, user.user_id)) + yield put(profileActions.fetchProfileSucceeded(user.handle, user.user_id)) yield fork(fetchUserCollections, user.user_id) const isSubscribed = yield call( AudiusBackend.getUserSubscribed, diff --git a/src/containers/remix-settings-modal/store/sagas.ts b/src/containers/remix-settings-modal/store/sagas.ts index 4d48b0d6bd..c3fadcadb1 100644 --- a/src/containers/remix-settings-modal/store/sagas.ts +++ b/src/containers/remix-settings-modal/store/sagas.ts @@ -1,10 +1,8 @@ import { takeEvery, call, put } from 'redux-saga/effects' -import { matchPath } from 'react-router-dom' import { fetchTrack, fetchTrackSucceeded, fetchTrackFailed } from './slice' -import { TRACK_PAGE } from 'utils/route' -import { parseIdFromRoute } from 'containers/track-page/TrackPageProvider' import { retrieveTracks } from 'store/cache/tracks/utils/retrieveTracks' +import { parseTrackRoute } from 'utils/route/trackRouteParser' const getTrackId = (url: string) => { // Get just the pathname part from the url @@ -18,13 +16,9 @@ const getTrackId = (url: string) => { ) { return null } - // Match on the pathname and return a valid track id if found - const match = matchPath<{ trackName: string; handle: string }>(pathname, { - path: TRACK_PAGE, - exact: true - }) - if (match && match.params.trackName) { - const { trackId } = parseIdFromRoute(match.params.trackName) + const params = parseTrackRoute(pathname) + if (params) { + const { trackId } = params return trackId } return null diff --git a/src/containers/track-page/TrackPageProvider.tsx b/src/containers/track-page/TrackPageProvider.tsx index 2ba50eddb2..bbb9bb1668 100644 --- a/src/containers/track-page/TrackPageProvider.tsx +++ b/src/containers/track-page/TrackPageProvider.tsx @@ -2,7 +2,6 @@ import React, { Component } from 'react' import { open } from 'store/application/ui/mobileOverflowModal/actions' import { connect } from 'react-redux' import { push as pushRoute, replace } from 'connected-react-router' -import { matchPath } from 'react-router' import { AppState, Status } from 'store/types' import { Dispatch } from 'redux' @@ -30,9 +29,11 @@ import { FAVORITING_USERS_ROUTE, REPOSTING_USERS_ROUTE, fullTrackPage, - trackRemixesPage + trackRemixesPage, + trackPage } from 'utils/route' import { formatUrlName } from 'utils/formatUtil' +import { parseTrackRoute } from 'utils/route/trackRouteParser' import { ID, CID, PlayableType } from 'models/common/Identifiers' import { Uid } from 'utils/uid' import { getLocationPathname } from 'store/routing/selectors' @@ -75,17 +76,9 @@ import StemsSEOHint from './components/StemsSEOHint' import { getTrackPageTitle, getTrackPageDescription } from 'utils/seo' import { formatSeconds, formatDate } from 'utils/timeUtil' import { getCannonicalName } from 'utils/genres' -import restrictedHandles from 'utils/restrictedHandles' import Track from 'models/Track' import DeletedPage from 'containers/deleted-page/DeletedPage' -export const parseIdFromRoute = (route: string) => { - const nameParts = route.split('-') - const trackTitle = nameParts.slice(0, nameParts.length - 1).join('-') - const trackId = parseInt(nameParts[nameParts.length - 1], 10) - return { trackTitle, trackId } -} - const getRemixParentTrackId = (track: Track | null) => track?.remix_of?.tracks?.[0]?.parent_track_id @@ -116,7 +109,7 @@ class TrackPageProvider extends Component< pathname: this.props.pathname, ownerHandle: null, showDeleteConfirmation: false, - routeKey: parseIdFromRoute(this.props.pathname).trackId, + routeKey: parseTrackRoute(this.props.pathname)?.trackId ?? 0, source: undefined } @@ -129,9 +122,12 @@ class TrackPageProvider extends Component< if (status === Status.ERROR) { this.props.goToRoute(NOT_FOUND_PAGE) } - if (pathname !== this.state.pathname) { - this.setState({ pathname }) - this.fetchTracks(pathname) + if (!isMobile()) { + // Refetch if the pathname changes because on desktop the component is shared + if (pathname !== this.state.pathname) { + this.setState({ pathname }) + this.fetchTracks(pathname) + } } // Set the lineup source in state once it's set in redux @@ -152,27 +148,35 @@ class TrackPageProvider extends Component< refetchTracksLinup() } - // Check that the track name hasn't changed. If so, update url. if (track) { - const match = matchPath<{ name: string; handle: string }>(pathname, { - path: '/:handle/:name', - exact: true - }) - if (match) { - const { trackTitle, trackId } = parseIdFromRoute(match.params.name) + const params = parseTrackRoute(pathname) + if (params) { + // Check if we are coming from a non-canonical route and replace route if necessary. + const { trackTitle, trackId, handle } = params const newTrackTitle = formatUrlName(track.title) - if (track.track_id === trackId) { - if (newTrackTitle !== trackTitle) { - const newPath = pathname.replace(trackTitle, newTrackTitle) + if (!trackTitle || !handle) { + if (this.props.user) { + const newPath = trackPage( + this.props.user.handle, + newTrackTitle, + track.track_id + ) this.props.replaceRoute(newPath) } + } else { + // Check that the track name hasn't changed. If so, update url. + if (track.track_id === trackId) { + if (newTrackTitle !== trackTitle) { + const newPath = pathname.replace(trackTitle, newTrackTitle) + this.props.replaceRoute(newPath) + } + } } } } } componentWillUnmount() { - this.props.reset(this.state.source) if (!isMobile()) { // Don't reset on mobile because there are two // track pages mounted at a time due to animations. @@ -182,34 +186,32 @@ class TrackPageProvider extends Component< fetchTracks = (pathname: string) => { const { track } = this.props - const match = matchPath<{ - handle: string - name: string - }>(pathname, { - path: '/:handle/:name', - exact: true - }) - if (!match || restrictedHandles.has(match.params.handle)) return - const nameParts = match.params.name.split('-') - const ownerHandle = match.params.handle - const trackId = parseInt(nameParts[nameParts.length - 1], 10) - // Go to 404 if the track id isn't parsed correctly - if (isNaN(trackId)) { - this.props.goToRoute(NOT_FOUND_PAGE) - return - } - // Go to feed if the track is deleted - if (track && track.track_id === trackId) { - if (track._marked_deleted) { - this.props.goToRoute(FEED_PAGE) - return + const params = parseTrackRoute(pathname) + if (params) { + const { trackTitle, trackId, handle } = params + + // Go to feed if the track is deleted + if (track && track.track_id === trackId) { + if (track._marked_deleted) { + this.props.goToRoute(FEED_PAGE) + return + } } + this.props.reset() + this.props.setTrackId(trackId) + this.props.fetchTrack( + trackId, + trackTitle, + handle, + !!(trackTitle && handle) + ) + if (handle) { + this.setState({ ownerHandle: handle }) + } + } else { + // Go to 404 if the track id isn't parsed correctly + this.props.goToRoute(NOT_FOUND_PAGE) } - this.props.reset() - this.props.setTrackId(trackId) - const trackName = nameParts.slice(0, nameParts.length - 1).join('-') - this.props.fetchTrack(trackId, trackName, ownerHandle) - this.setState({ ownerHandle }) } onHeroPlay = (heroPlaying: boolean) => { @@ -497,8 +499,20 @@ function makeMapStateToProps() { function mapDispatchToProps(dispatch: Dispatch) { return { - fetchTrack: (trackId: ID, trackName: string, ownerHandle: string) => - dispatch(trackPageActions.fetchTrack(trackId, trackName, ownerHandle)), + fetchTrack: ( + trackId: ID, + trackName: string | null, + ownerHandle: string | null, + canBeUnlisted: boolean + ) => + dispatch( + trackPageActions.fetchTrack( + trackId, + trackName, + ownerHandle, + canBeUnlisted + ) + ), setTrackId: (trackId: number) => dispatch(trackPageActions.setTrackId(trackId)), resetTrackPage: () => dispatch(trackPageActions.resetTrackPage()), diff --git a/src/containers/track-page/store/actions.js b/src/containers/track-page/store/actions.js index ce18c78486..9a993804bd 100644 --- a/src/containers/track-page/store/actions.js +++ b/src/containers/track-page/store/actions.js @@ -23,11 +23,12 @@ export const resetTrackPage = rank => ({ type: RESET }) export const setTrackId = trackId => ({ type: SET_TRACK_ID, trackId }) export const makeTrackPublic = trackId => ({ type: MAKE_TRACK_PUBLIC, trackId }) -export const fetchTrack = (trackId, trackName, ownerHandle) => ({ +export const fetchTrack = (trackId, trackName, ownerHandle, canBeUnlisted) => ({ type: FETCH_TRACK, trackId, trackName, - ownerHandle + ownerHandle, + canBeUnlisted }) export const fetchTrackSucceeded = trackId => ({ type: FETCH_TRACK_SUCCEEDED, diff --git a/src/containers/track-page/store/lineups/tracks/sagas.js b/src/containers/track-page/store/lineups/tracks/sagas.js index 43724f0510..9014f43199 100644 --- a/src/containers/track-page/store/lineups/tracks/sagas.js +++ b/src/containers/track-page/store/lineups/tracks/sagas.js @@ -10,16 +10,16 @@ import { getSourceSelector as sourceSelector, getLineup } from 'containers/track-page/store/selectors' -import { fetchUserByHandle } from 'store/cache/users/sagas' +import { getUserFromTrack } from 'store/cache/users/selectors' import { LineupSagas } from 'store/lineup/sagas' import { processAndCacheTracks } from 'store/cache/tracks/utils' import { getTrack } from 'store/cache/tracks/selectors' import { waitForValue } from 'utils/sagaHelpers' function* getTracks({ offset, limit, payload }) { - const { ownerHandle, trackId } = payload + const { trackId } = payload - const user = yield call(fetchUserByHandle, ownerHandle) + const user = yield select(getUserFromTrack, { id: trackId }) const tracks = yield call(AudiusBackend.getArtistTracks, { offset, diff --git a/src/containers/track-page/store/sagas.js b/src/containers/track-page/store/sagas.js index 749e6c298d..5322546b71 100644 --- a/src/containers/track-page/store/sagas.js +++ b/src/containers/track-page/store/sagas.js @@ -15,7 +15,7 @@ import TimeRange from 'models/TimeRange' import { push as pushRoute } from 'connected-react-router' import { retrieveTracks } from 'store/cache/tracks/utils' import { NOT_FOUND_PAGE, trackRemixesPage } from 'utils/route' -import { getUsers } from 'store/cache/users/selectors' +import { getUsers, getUserFromTrack } from 'store/cache/users/selectors' function* watchTrackBadge() { yield takeEvery(trackPageActions.GET_TRACK_RANKS, function* (action) { @@ -87,18 +87,26 @@ function* getTrackRanks(trackId) { } function* getMoreByThisArtist(trackId, ownerHandle) { + const owner = yield select(getUserFromTrack, { id: trackId }) yield put( - tracksActions.fetchLineupMetadatas(0, 6, false, { ownerHandle, trackId }) + tracksActions.fetchLineupMetadatas(0, 6, false, { + ownerHandle: owner.handle, + trackId + }) ) } function* watchFetchTrack() { yield takeEvery(trackPageActions.FETCH_TRACK, function* (action) { - const { trackId, trackName, ownerHandle } = action + const { trackId, trackName, ownerHandle, canBeUnlisted } = action + const ids = canBeUnlisted + ? [{ id: trackId, url_title: trackName, handle: ownerHandle }] + : [trackId] + try { const trackIds = yield call(retrieveTracks, { - trackIds: [{ id: trackId, url_title: trackName, handle: ownerHandle }], - canBeUnlisted: true, + trackIds: ids, + canBeUnlisted, withStems: true, withRemixes: true, withRemixParents: true @@ -106,7 +114,7 @@ function* watchFetchTrack() { if ( !trackIds || !trackIds.length || - trackIds.every(id => id === undefined) + trackIds.every(track => track === undefined || !track.track_id) ) { // If no tracks because no internet, do nothing. Else navigate to 404. const isReachable = yield select(getIsReachable) diff --git a/src/setupTests.js b/src/setupTests.js index 8f3dfeea03..9e7f87325f 100644 --- a/src/setupTests.js +++ b/src/setupTests.js @@ -1,6 +1,8 @@ // eslint-disable-next-line import '__mocks__/AudiusBackend' // eslint-disable-next-line +import '__mocks__/Hashids' +// eslint-disable-next-line // import '__mocks__/Hls.js' // Mock Canvas / Context2D calls diff --git a/src/utils/route.js b/src/utils/route.js index 26d557a272..fa455e36a2 100644 --- a/src/utils/route.js +++ b/src/utils/route.js @@ -57,6 +57,10 @@ export const ALBUM_PAGE = '/:handle/album/:albumName' export const TRACK_PAGE = '/:handle/:trackName' export const TRACK_REMIXES_PAGE = '/:handle/:trackName/remixes' export const PROFILE_PAGE = '/:handle' +// Opaque id routes +export const TRACK_ID_PAGE = '/tracks/:id' +export const USER_ID_PAGE = '/users/:id' +export const PLAYLIST_ID_PAGE = '/playlists/:id' // Mobile Only Routes export const REPOSTING_USERS_ROUTE = '/reposting_users' diff --git a/src/utils/route/collectionRouteParser.test.js b/src/utils/route/collectionRouteParser.test.js new file mode 100644 index 0000000000..643e13d332 --- /dev/null +++ b/src/utils/route/collectionRouteParser.test.js @@ -0,0 +1,69 @@ +import { parseCollectionRoute } from './collectionRouteParser' +// eslint-disable-next-line +import { mockDecode } from '__mocks__/Hashids' + +describe('parseCollectionRoute', () => { + it('can decode a playlist id route', () => { + const route = '/arizmendi/playlist/croissants-11' + const { + title, + collectionId, + handle, + collectionType + } = parseCollectionRoute(route) + expect(title).toEqual('croissants') + expect(collectionId).toEqual(11) + expect(handle).toEqual('arizmendi') + expect(collectionType).toEqual('playlist') + }) + + it('can decode an album id route', () => { + const route = '/arizmendi/album/scones-20' + const { + title, + collectionId, + handle, + collectionType + } = parseCollectionRoute(route) + expect(title).toEqual('scones') + expect(collectionId).toEqual(20) + expect(handle).toEqual('arizmendi') + expect(collectionType).toEqual('album') + }) + + it('can decode a hashed collection id route', () => { + mockDecode.mockReturnValue([11845]) + + const route = '/playlists/eP9k7' + const { + title, + collectionId, + handle, + collectionType + } = parseCollectionRoute(route) + expect(title).toEqual(null) + expect(collectionId).toEqual(11845) + expect(handle).toEqual(null) + expect(collectionType).toEqual(null) + }) + + it('returns null for invalid id in playlist id route', () => { + const route = '/arizmendi/playlist/name-asdf' + const params = parseCollectionRoute(route) + expect(params).toEqual(null) + }) + + it('returns null for invalid id in album id route', () => { + const route = '/arizmendi/album/name-asdf' + const params = parseCollectionRoute(route) + expect(params).toEqual(null) + }) + + it('returns null for invalid id in hashed collection id route', () => { + mockDecode.mockReturnValue([NaN]) + + const route = '/playlists/asdf' + const params = parseCollectionRoute(route) + expect(params).toEqual(null) + }) +}) diff --git a/src/utils/route/collectionRouteParser.ts b/src/utils/route/collectionRouteParser.ts new file mode 100644 index 0000000000..dcc7beeee1 --- /dev/null +++ b/src/utils/route/collectionRouteParser.ts @@ -0,0 +1,65 @@ +import { matchPath } from 'react-router-dom' +import { PLAYLIST_PAGE, ALBUM_PAGE, PLAYLIST_ID_PAGE } from 'utils/route' +import { decodeHashId } from './hashIds' +import { ID } from 'models/common/Identifiers' + +type CollectionRouteParams = + | { + collectionId: ID + handle: string + collectionType: 'playlist' | 'album' + title: string + } + | { collectionId: ID; handle: null; collectionType: null; title: null } + | null + +/** + * Parses a collection route into handle, title, id, and type + * If the route is a hash id route, title, handle, and type are not returned + * @param route + */ +export const parseCollectionRoute = (route: string): CollectionRouteParams => { + const collectionIdPageMatch = matchPath<{ id: string }>(route, { + path: PLAYLIST_ID_PAGE, + exact: true + }) + if (collectionIdPageMatch) { + const collectionId = decodeHashId(collectionIdPageMatch.params.id) + if (collectionId === null) return null + return { collectionId, handle: null, collectionType: null, title: null } + } + + const playlistPageMatch = matchPath<{ + handle: string + playlistName: string + }>(route, { + path: PLAYLIST_PAGE, + exact: true + }) + if (playlistPageMatch) { + const { handle, playlistName } = playlistPageMatch.params + const nameParts = playlistName.split('-') + const title = nameParts.slice(0, nameParts.length - 1).join('-') + const collectionId = parseInt(nameParts[nameParts.length - 1], 10) + if (!collectionId || isNaN(collectionId)) return null + return { title, collectionId, handle, collectionType: 'playlist' } + } + + const albumPageMatch = matchPath<{ + handle: string + albumName: string + }>(route, { + path: ALBUM_PAGE, + exact: true + }) + if (albumPageMatch) { + const { handle, albumName } = albumPageMatch.params + const nameParts = albumName.split('-') + const title = nameParts.slice(0, nameParts.length - 1).join('-') + const collectionId = parseInt(nameParts[nameParts.length - 1], 10) + if (!collectionId || isNaN(collectionId)) return null + return { title, collectionId, handle, collectionType: 'album' } + } + + return null +} diff --git a/src/utils/route/hashIds.test.js b/src/utils/route/hashIds.test.js new file mode 100644 index 0000000000..3466b34ded --- /dev/null +++ b/src/utils/route/hashIds.test.js @@ -0,0 +1,22 @@ +import { decodeHashId } from './hashIds' +// eslint-disable-next-line +import { mockDecode } from '__mocks__/Hashids' + +describe('decodeHashId', () => { + it('can decode a hash id', () => { + mockDecode.mockReturnValue([11845]) + + const hashed = 'eP9k7' + const decoded = decodeHashId(hashed) + expect(decoded).toEqual(11845) + expect(typeof decoded).toEqual('number') + }) + + it('can handle an error', () => { + mockDecode.mockReturnValue([]) + + const hashed = 'eP9k7' + const decoded = decodeHashId(hashed) + expect(decoded).toEqual(null) + }) +}) diff --git a/src/utils/route/hashIds.ts b/src/utils/route/hashIds.ts new file mode 100644 index 0000000000..b35bd0ec5f --- /dev/null +++ b/src/utils/route/hashIds.ts @@ -0,0 +1,19 @@ +import Hashids from 'hashids' + +const HASH_SALT = 'azowernasdfoia' +const MIN_LENGTH = 5 +const hashids = new Hashids(HASH_SALT, MIN_LENGTH) + +/** Decodes a string id into an int. Returns null if an invalid ID. */ +export const decodeHashId = (id: string): number | null => { + try { + const ids = hashids.decode(id) + if (!ids.length) return null + const num = Number(ids[0]) + if (isNaN(num)) return null + return num + } catch (e) { + console.error(`Failed to decode ${id}`, e) + return null + } +} diff --git a/src/utils/route/trackRouteParser.test.js b/src/utils/route/trackRouteParser.test.js new file mode 100644 index 0000000000..b7c03c08b4 --- /dev/null +++ b/src/utils/route/trackRouteParser.test.js @@ -0,0 +1,37 @@ +import { parseTrackRoute } from './trackRouteParser' +// eslint-disable-next-line +import { mockDecode } from '__mocks__/Hashids' + +describe('parseTrackRoute', () => { + it('can decode a track id route', () => { + const route = '/tartine/morning-buns-25' + const { trackTitle, trackId, handle } = parseTrackRoute(route) + expect(trackTitle).toEqual('morning-buns') + expect(trackId).toEqual(25) + expect(handle).toEqual('tartine') + }) + + it('can decode a hashed track id route', () => { + mockDecode.mockReturnValue([11845]) + + const route = '/tracks/eP9k7' + const { trackTitle, trackId, handle } = parseTrackRoute(route) + expect(trackTitle).toEqual(null) + expect(trackId).toEqual(11845) + expect(handle).toEqual(null) + }) + + it('returns null for invalid track id in track id route', () => { + const route = '/blah/track-asdf' + const params = parseTrackRoute(route) + expect(params).toEqual(null) + }) + + it('returns null for invalid track id in hashed track id route', () => { + mockDecode.mockReturnValue([NaN]) + + const route = '/tracks/asdf' + const params = parseTrackRoute(route) + expect(params).toEqual(null) + }) +}) diff --git a/src/utils/route/trackRouteParser.ts b/src/utils/route/trackRouteParser.ts new file mode 100644 index 0000000000..487265dd65 --- /dev/null +++ b/src/utils/route/trackRouteParser.ts @@ -0,0 +1,44 @@ +import { matchPath } from 'react-router-dom' +import { TRACK_ID_PAGE, TRACK_PAGE } from 'utils/route' +import { decodeHashId } from './hashIds' +import { ID } from 'models/common/Identifiers' + +type TrackRouteParams = + | { trackTitle: string; trackId: ID; handle: string } + | { trackTitle: null; trackId: ID; handle: null } + | null + +/** + * Parses a track route into title, track id, and handle + * If the route is a hash id route, track title and handle are not returned + * @param route + */ +export const parseTrackRoute = (route: string): TrackRouteParams => { + const trackIdPageMatch = matchPath<{ id: string }>(route, { + path: TRACK_ID_PAGE, + exact: true + }) + if (trackIdPageMatch) { + const trackId = decodeHashId(trackIdPageMatch.params.id) + if (trackId === null) return null + return { trackTitle: null, trackId, handle: null } + } + + const trackPageMatch = matchPath<{ trackName: string; handle: string }>( + route, + { + path: TRACK_PAGE, + exact: true + } + ) + if (trackPageMatch) { + const { handle, trackName } = trackPageMatch.params + const nameParts = trackName.split('-') + const trackTitle = nameParts.slice(0, nameParts.length - 1).join('-') + const trackId = parseInt(nameParts[nameParts.length - 1], 10) + if (!trackId || isNaN(trackId)) return null + return { trackTitle, trackId, handle } + } + + return null +} diff --git a/src/utils/route/userRouteParser.test.js b/src/utils/route/userRouteParser.test.js new file mode 100644 index 0000000000..f2c5b65fa6 --- /dev/null +++ b/src/utils/route/userRouteParser.test.js @@ -0,0 +1,35 @@ +import { parseUserRoute } from './userRouteParser' +// eslint-disable-next-line +import { mockDecode } from '__mocks__/Hashids' + +describe('parseUserRoute', () => { + it('can decode a user handle route', () => { + const route = '/vivelatarte' + const { userId, handle } = parseUserRoute(route) + expect(handle).toEqual('vivelatarte') + expect(userId).toEqual(null) + }) + + it('can decode a hashed user id route', () => { + mockDecode.mockReturnValue([11845]) + + const route = '/users/eP9k7' + const { userId, handle } = parseUserRoute(route) + expect(userId).toEqual(11845) + expect(handle).toEqual(null) + }) + + it('returns null for a static route', () => { + const route = '/404' + const params = parseUserRoute(route) + expect(params).toEqual(null) + }) + + it('returns null for an invalid hash id', () => { + mockDecode.mockReturnValue([NaN]) + + const route = '/users/asdf' + const params = parseUserRoute(route) + expect(params).toEqual(null) + }) +}) diff --git a/src/utils/route/userRouteParser.ts b/src/utils/route/userRouteParser.ts new file mode 100644 index 0000000000..47b532546b --- /dev/null +++ b/src/utils/route/userRouteParser.ts @@ -0,0 +1,38 @@ +import { matchPath } from 'react-router-dom' +import { USER_ID_PAGE, PROFILE_PAGE, staticRoutes } from 'utils/route' +import { decodeHashId } from './hashIds' +import { ID } from 'models/common/Identifiers' + +type UserRouteParams = + | { handle: string; userId: null } + | { handle: null; userId: ID } + | null + +/** + * Parses a user route into handle or id + * @param route + */ +export const parseUserRoute = (route: string): UserRouteParams => { + if (staticRoutes.has(route)) return null + + const userIdPageMatch = matchPath<{ id: string }>(route, { + path: USER_ID_PAGE, + exact: true + }) + if (userIdPageMatch) { + const userId = decodeHashId(userIdPageMatch.params.id) + if (userId === null) return null + return { userId, handle: null } + } + + const profilePageMatch = matchPath<{ handle: string }>(route, { + path: PROFILE_PAGE, + exact: true + }) + if (profilePageMatch) { + const { handle } = profilePageMatch.params + return { handle, userId: null } + } + + return null +}