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
+}