From bb09ef1aa46dbd62d1452c2dc8018108f471bf00 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 9 Apr 2026 22:19:55 +0000 Subject: [PATCH 1/2] Fix playlist ID deep link routing on mobile Co-authored-by: Dylan Jeffers --- packages/mobile/jest.deeplink.config.js | 15 + packages/mobile/package.json | 4 +- .../NavigationContainer.test.ts | 41 +++ .../NavigationContainer.tsx | 255 +------------- .../getNavigationStateFromDeeplinkPath.ts | 328 ++++++++++++++++++ 5 files changed, 394 insertions(+), 249 deletions(-) create mode 100644 packages/mobile/jest.deeplink.config.js create mode 100644 packages/mobile/src/components/navigation-container/NavigationContainer.test.ts create mode 100644 packages/mobile/src/utils/deeplink/getNavigationStateFromDeeplinkPath.ts diff --git a/packages/mobile/jest.deeplink.config.js b/packages/mobile/jest.deeplink.config.js new file mode 100644 index 00000000000..01c671d1092 --- /dev/null +++ b/packages/mobile/jest.deeplink.config.js @@ -0,0 +1,15 @@ +module.exports = { + testMatch: ['/src/components/navigation-container/**/*.test.ts'], + testEnvironment: 'node', + setupFilesAfterEnv: [], + transformIgnorePatterns: ['/node_modules/'], + moduleNameMapper: { + '^react-native$': '/__mocks__/react-native.js', + '^react-native/(.*)$': '/__mocks__/react-native.js', + '^~/(.*)$': '/src/$1', + '^app/(.*)$': '/src/$1', + '^@audius/sdk$': '/../sdk/src', + '^@audius/sdk/(.*)$': '/../sdk/src/$1' + } +} + diff --git a/packages/mobile/package.json b/packages/mobile/package.json index 6c3f2369827..adaffdbd8d1 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -217,9 +217,7 @@ }, "jest": { "preset": "react-native", - "setupFilesAfterEnv": [ - "@testing-library/jest-native/extend-expect" - ], + "setupFilesAfterEnv": ["/jest.setup.js"], "moduleFileExtensions": [ "ts", "tsx", diff --git a/packages/mobile/src/components/navigation-container/NavigationContainer.test.ts b/packages/mobile/src/components/navigation-container/NavigationContainer.test.ts new file mode 100644 index 00000000000..f80965b3853 --- /dev/null +++ b/packages/mobile/src/components/navigation-container/NavigationContainer.test.ts @@ -0,0 +1,41 @@ +import { getNavigationStateFromDeeplinkPath } from 'app/utils/deeplink/getNavigationStateFromDeeplinkPath' + +const stubGetStateFromPath = (path: string) => ({ routes: [{ name: path }] }) + +const getLeafRouteName = (state: any): string | undefined => { + let current: any = state + while (current?.routes?.length) { + current = current.routes[current.index ?? 0] + if (current?.state) current = current.state + } + return current?.name +} + +describe('getNavigationStateFromDeeplinkPath', () => { + test('routes /users/:id to Profile', () => { + const state = getNavigationStateFromDeeplinkPath({ + path: '/users/Nz9yBb4', + options: undefined, + hasAccount: true, + accountHandle: 'someone', + routeName: '/trending', + getStateFromPath: stubGetStateFromPath as any + }) + + expect(getLeafRouteName(state)).toBe('Profile') + }) + + test('routes /playlists/:id to Collection', () => { + const state = getNavigationStateFromDeeplinkPath({ + path: '/playlists/Nz9yBb4', + options: undefined, + hasAccount: true, + accountHandle: 'someone', + routeName: '/trending', + getStateFromPath: stubGetStateFromPath as any + }) + + expect(getLeafRouteName(state)).toBe('Collection') + }) +}) + diff --git a/packages/mobile/src/components/navigation-container/NavigationContainer.tsx b/packages/mobile/src/components/navigation-container/NavigationContainer.tsx index d66cc7d8066..c8cc8e0122e 100644 --- a/packages/mobile/src/components/navigation-container/NavigationContainer.tsx +++ b/packages/mobile/src/components/navigation-container/NavigationContainer.tsx @@ -3,7 +3,6 @@ import { useEffect } from 'react' import { useCurrentAccountUser, useHasAccount } from '@audius/common/api' import { Status } from '@audius/common/models' -import { OptionalHashId } from '@audius/sdk' import type { LinkingOptions, NavigationState, @@ -14,7 +13,6 @@ import { createNavigationContainerRef, getStateFromPath } from '@react-navigation/native' -import queryString from 'query-string' import { useAccountStatus } from '~/api/tan-query/users/account/useAccountStatus' import { AppTabNavigationProvider } from 'app/screens/app-screen' @@ -23,6 +21,7 @@ import { getPrimaryRoute } from 'app/utils/navigation' import { useThemeVariant } from 'app/utils/theme' import { navigationThemes } from './navigationThemes' +import { getNavigationStateFromDeeplinkPath } from '../../utils/deeplink/getNavigationStateFromDeeplinkPath' type NavigationContainerProps = { children: ReactNode @@ -258,250 +257,14 @@ const NavigationContainer = (props: NavigationContainerProps) => { }, // TODO: This should be unit tested getStateFromPath: (path, options) => { - const pathPart = (path: string) => (index: number) => { - const rawResult = path.split('/')[index] - const queryIndex = rawResult?.indexOf('?') ?? -1 - const trimmed = - queryIndex > -1 ? rawResult.slice(0, queryIndex) : rawResult - return trimmed - } - - // Add leading slash if it is missing - if (path[0] !== '/') path = `/${path}` - - // Decode URL-encoded characters in the path - try { - path = decodeURIComponent(path) - } catch (e) { - // If decoding fails, continue with the original path - console.warn('Failed to decode URL path:', path, e) - } - - path = path.replace('#embed', '') - - // OAuth authorization URLs (e.g. /oauth/authorize?...) are intercepted by - // the app via Universal Links. Route them to the OAuth screen instead of - // crashing with an unmatched path. - if (path.match(/^\/oauth\//)) { - const queryStart = path.indexOf('?') - const search = queryStart > -1 ? path.slice(queryStart) : '' - return { - routes: [{ name: 'OAuthScreen', params: { search } }] - } - } - - const connectPath = /^\/(connect)/ - if (path.match(connectPath)) { - path = `${path.replace( - connectPath, - routeNameRef.current ?? '/trending' - )}&path=connect` - } - - const walletConnectPath = /^\/(wallet-connect)/ - if (path.match(walletConnectPath)) { - path = `${path.replace( - walletConnectPath, - '/wallets' - )}&path=wallet-connect` - } - - const walletSignPath = /^\/(wallet-sign-message)/ - if (path.match(walletSignPath)) { - path = `${path.replace( - walletSignPath, - '/wallets' - )}&path=wallet-sign-message` - } - - if (path.match(`^/app-redirect`)) { - // Remove the app-redirect prefix if present - path = path.replace(`/app-redirect`, '') - } - - // Strip the trending query param because `/trending` will - // always go to ThisWeek - if (path.match(/^\/trending/)) { - path = '/trending' - } - - // Opaque ID routes - // /tracks/Nz9yBb4 - if (path.match(/^\/tracks\//)) { - const id = OptionalHashId.parse(pathPart(path)(2)) - return createTrendingStackState({ - name: 'Track', - params: { - id - } - }) - } - - // /users/Nz9yBb4 - if (path.match(/^\/users\//)) { - const id = OptionalHashId.parse(pathPart(path)(2)) - return createTrendingStackState({ - name: 'Profile', - params: { - id - } - }) - } - - // /playlists/Nz9yBb4 - if (path.match(/^\/playlists\//)) { - const id = OptionalHashId.parse(pathPart(path)(2)) - return createTrendingStackState({ - name: 'Profile', - params: { - id - } - }) - } - - if (path.match(/^\/rewards/)) { - return createTrendingStackState({ - name: 'RewardsScreen' - }) - } - - if (path.match(/^\/wallet(?:\/|\?|$)/)) { - return createTrendingStackState({ - name: 'wallet' - }) - } - - if (path.match(/^\/coins/)) { - const ticker = pathPart(path)(2) - const coinRoute = pathPart(path)(3) - const redeemCode = pathPart(path)(4) - - if (ticker && ticker !== 'create') { - // Normalize ticker to uppercase - const normalizedTicker = ticker.toUpperCase() - - if (coinRoute === 'redeem') { - return createTrendingStackState({ - name: 'CoinRedeemScreen', - params: { - ticker: normalizedTicker, - code: redeemCode ?? undefined - } - }) - } - - return createTrendingStackState({ - name: 'CoinDetailsScreen', - params: { ticker: normalizedTicker } - }) - } - - return createTrendingStackState({ - name: 'ArtistCoinsExplore' - }) - } - - // /search - if (path.match(/^\/search(?:\/|\?|$)/)) { - const { - query: { query: searchQuery, ...filters } - } = queryString.parseUrl(path) - - // Route search URLs to the explore tab with SearchExplore screen - // This ensures proper deeplinking for both search URLs and search with filters - return createExploreStackState({ - name: 'SearchExplore', - params: { - query: searchQuery, - category: pathPart(path)(2) ?? 'all', - filters, - autoFocus: false - } - }) - } - - // /explore - if (path.match(/^\/explore(?:\/|\?|$)/)) { - const { - query: { query: exploreQuery, ...filters } - } = queryString.parseUrl(path) - - // Route explore URLs to the explore tab with SearchExplore screen - // This ensures both /search and /explore URLs work for deeplinking - return createExploreStackState({ - name: 'SearchExplore', - params: { - query: exploreQuery, - category: pathPart(path)(2) ?? 'all', - filters, - autoFocus: false - } - }) - } - - const { query } = queryString.parseUrl(path) - const { login, warning } = query - - if (login && warning) { - path = queryString.stringifyUrl({ url: '/reset-password', query }) - } - - const settingsPath = /^\/(settings)/ - if (path.match(settingsPath)) { - const subpath = pathPart(path)(2) - const subpathParam = subpath != null ? `?path=${subpath}` : '' - const queryParamsStart = path.indexOf('?') - const queryParams = - queryParamsStart > -1 ? `&${path.slice(queryParamsStart + 1)}` : '' - path = `/settings${subpathParam}${queryParams}` - } else if (path.match(`^/${accountHandle}(/|$)`)) { - // If the path is the current user and set path as `/profile` - path = path.replace(`/${accountHandle}`, '/profile') - } else { - // If the path has two parts - if (path.match(/^\/[^/]+\/[^/]+$/)) { - // If the path doesn't match a profile tab, it's a track - if (!path.match(/^\/[^/]+\/(tracks|albums|playlists|reposts)$/)) { - path = `/track${path}` - } - } - - if (path.match(/^\/[^/]+\/playlist\/[^/]+$/)) { - // set the path as `collection` - path = path.replace( - /(^\/[^/]+\/)(playlist)(\/[^/]+$)/, - '$1collection$3' - ) - path = `${path}?collectionType=playlist` - } else if (path.match(/^\/[^/]+\/album\/[^/]+$/)) { - // set the path as `collection` - path = path.replace(/(^\/[^/]+\/)(album)(\/[^/]+$)/, '$1collection$3') - path = `${path}?collectionType=album` - } - } - - if (!hasAccount && !path.match(/^\/reset-password/)) { - // Redirect to sign in with original path in query params - const { url, query } = queryString.parseUrl(path) - - // If url is signin or signup, set screen param instead of routeOnCompletion - if (url === '/signin' || url === '/signup') { - path = queryString.stringifyUrl({ - url: '/sign-on', - query: { - ...query, - screen: url === '/signin' ? 'sign-in' : 'sign-up' - } - }) - } else { - path = queryString.stringifyUrl({ - url: '/sign-on', - query: { ...query, screen: 'sign-up', routeOnCompletion: url } - }) - } - } - - return getStateFromPath(path, options) + return getNavigationStateFromDeeplinkPath({ + path, + options, + hasAccount, + accountHandle, + routeName: routeNameRef.current, + getStateFromPath + }) } } diff --git a/packages/mobile/src/utils/deeplink/getNavigationStateFromDeeplinkPath.ts b/packages/mobile/src/utils/deeplink/getNavigationStateFromDeeplinkPath.ts new file mode 100644 index 00000000000..9ab59deb164 --- /dev/null +++ b/packages/mobile/src/utils/deeplink/getNavigationStateFromDeeplinkPath.ts @@ -0,0 +1,328 @@ +import queryString from 'query-string' + +const parseOptionalHashId = (raw: string | undefined) => { + return raw == null || raw === '' ? null : raw +} + +type GetStateFromPath = ( + path: string, + options: any +) => any + +type GetNavigationStateFromDeeplinkPathArgs = { + path: string + options: any + hasAccount: boolean + accountHandle?: string + routeName?: string + getStateFromPath: GetStateFromPath +} + +const createAppTabState = ( + state: any +): any => ({ + routes: [ + { + name: 'HomeStack', + state: { + routes: [ + { + name: 'App', + state: { + routes: [ + { + name: 'AppTabs', + state + } + ] + } + } + ] + } + } + ] +}) + +const createTrendingStackState = (route): any => + createAppTabState({ + routes: [ + { + name: 'trending', + state: { + index: 1, + routes: [ + { + name: 'Trending' + }, + route + ] + } + } + ] + }) + +const createExploreStackState = (route): any => + createAppTabState({ + routes: [ + { + name: 'explore', + state: { + index: 1, + routes: [ + { + name: 'Explore' + }, + route + ] + } + } + ] + }) + +export const getNavigationStateFromDeeplinkPath = ({ + path, + options, + hasAccount, + accountHandle, + routeName, + getStateFromPath +}: GetNavigationStateFromDeeplinkPathArgs) => { + const pathPart = (path: string) => (index: number) => { + const rawResult = path.split('/')[index] + const queryIndex = rawResult?.indexOf('?') ?? -1 + const trimmed = queryIndex > -1 ? rawResult.slice(0, queryIndex) : rawResult + return trimmed + } + + // Add leading slash if it is missing + if (path[0] !== '/') path = `/${path}` + + // Decode URL-encoded characters in the path + try { + path = decodeURIComponent(path) + } catch (e) { + // If decoding fails, continue with the original path + console.warn('Failed to decode URL path:', path, e) + } + + path = path.replace('#embed', '') + + // OAuth authorization URLs (e.g. /oauth/authorize?...) are intercepted by + // the app via Universal Links. Route them to the OAuth screen instead of + // crashing with an unmatched path. + if (path.match(/^\/oauth\//)) { + const queryStart = path.indexOf('?') + const search = queryStart > -1 ? path.slice(queryStart) : '' + return { + routes: [{ name: 'OAuthScreen', params: { search } }] + } + } + + const connectPath = /^\/(connect)/ + if (path.match(connectPath)) { + path = `${path.replace(connectPath, routeName ?? '/trending')}&path=connect` + } + + const walletConnectPath = /^\/(wallet-connect)/ + if (path.match(walletConnectPath)) { + path = `${path.replace(walletConnectPath, '/wallets')}&path=wallet-connect` + } + + const walletSignPath = /^\/(wallet-sign-message)/ + if (path.match(walletSignPath)) { + path = `${path.replace( + walletSignPath, + '/wallets' + )}&path=wallet-sign-message` + } + + if (path.match(`^/app-redirect`)) { + // Remove the app-redirect prefix if present + path = path.replace(`/app-redirect`, '') + } + + // Strip the trending query param because `/trending` will + // always go to ThisWeek + if (path.match(/^\/trending/)) { + path = '/trending' + } + + // Opaque ID routes + // /tracks/Nz9yBb4 + if (path.match(/^\/tracks\//)) { + const id = parseOptionalHashId(pathPart(path)(2)) + return createTrendingStackState({ + name: 'Track', + params: { + id + } + }) + } + + // /users/Nz9yBb4 + if (path.match(/^\/users\//)) { + const id = parseOptionalHashId(pathPart(path)(2)) + return createTrendingStackState({ + name: 'Profile', + params: { + id + } + }) + } + + // /playlists/Nz9yBb4 + if (path.match(/^\/playlists\//)) { + const id = parseOptionalHashId(pathPart(path)(2)) + return createTrendingStackState({ + name: 'Collection', + params: { + id + } + }) + } + + if (path.match(/^\/rewards/)) { + return createTrendingStackState({ + name: 'RewardsScreen' + }) + } + + if (path.match(/^\/wallet(?:\/|\?|$)/)) { + return createTrendingStackState({ + name: 'wallet' + }) + } + + if (path.match(/^\/coins/)) { + const ticker = pathPart(path)(2) + const coinRoute = pathPart(path)(3) + const redeemCode = pathPart(path)(4) + + if (ticker && ticker !== 'create') { + // Normalize ticker to uppercase + const normalizedTicker = ticker.toUpperCase() + + if (coinRoute === 'redeem') { + return createTrendingStackState({ + name: 'CoinRedeemScreen', + params: { + ticker: normalizedTicker, + code: redeemCode ?? undefined + } + }) + } + + return createTrendingStackState({ + name: 'CoinDetailsScreen', + params: { ticker: normalizedTicker } + }) + } + + return createTrendingStackState({ + name: 'ArtistCoinsExplore' + }) + } + + // /search + if (path.match(/^\/search(?:\/|\?|$)/)) { + const { + query: { query: searchQuery, ...filters } + } = queryString.parseUrl(path) + + // Route search URLs to the explore tab with SearchExplore screen + // This ensures proper deeplinking for both search URLs and search with filters + return createExploreStackState({ + name: 'SearchExplore', + params: { + query: searchQuery, + category: pathPart(path)(2) ?? 'all', + filters, + autoFocus: false + } + }) + } + + // /explore + if (path.match(/^\/explore(?:\/|\?|$)/)) { + const { + query: { query: exploreQuery, ...filters } + } = queryString.parseUrl(path) + + // Route explore URLs to the explore tab with SearchExplore screen + // This ensures both /search and /explore URLs work for deeplinking + return createExploreStackState({ + name: 'SearchExplore', + params: { + query: exploreQuery, + category: pathPart(path)(2) ?? 'all', + filters, + autoFocus: false + } + }) + } + + const { query } = queryString.parseUrl(path) + const { login, warning } = query + + if (login && warning) { + path = queryString.stringifyUrl({ url: '/reset-password', query }) + } + + const settingsPath = /^\/(settings)/ + if (path.match(settingsPath)) { + const subpath = pathPart(path)(2) + const subpathParam = subpath != null ? `?path=${subpath}` : '' + const queryParamsStart = path.indexOf('?') + const queryParams = + queryParamsStart > -1 ? `&${path.slice(queryParamsStart + 1)}` : '' + path = `/settings${subpathParam}${queryParams}` + } else if (path.match(`^/${accountHandle}(/|$)`)) { + // If the path is the current user and set path as `/profile` + path = path.replace(`/${accountHandle}`, '/profile') + } else { + // If the path has two parts + if (path.match(/^\/[^/]+\/[^/]+$/)) { + // If the path doesn't match a profile tab, it's a track + if (!path.match(/^\/[^/]+\/(tracks|albums|playlists|reposts)$/)) { + path = `/track${path}` + } + } + + if (path.match(/^\/[^/]+\/playlist\/[^/]+$/)) { + // set the path as `collection` + path = path.replace( + /(^\/[^/]+\/)(playlist)(\/[^/]+$)/, + '$1collection$3' + ) + path = `${path}?collectionType=playlist` + } else if (path.match(/^\/[^/]+\/album\/[^/]+$/)) { + // set the path as `collection` + path = path.replace(/(^\/[^/]+\/)(album)(\/[^/]+$)/, '$1collection$3') + path = `${path}?collectionType=album` + } + } + + if (!hasAccount && !path.match(/^\/reset-password/)) { + // Redirect to sign in with original path in query params + const { url, query } = queryString.parseUrl(path) + + // If url is signin or signup, set screen param instead of routeOnCompletion + if (url === '/signin' || url === '/signup') { + path = queryString.stringifyUrl({ + url: '/sign-on', + query: { + ...query, + screen: url === '/signin' ? 'sign-in' : 'sign-up' + } + }) + } else { + path = queryString.stringifyUrl({ + url: '/sign-on', + query: { ...query, screen: 'sign-up', routeOnCompletion: url } + }) + } + } + + return getStateFromPath(path, options) +} + From f6d401e9edea046aba7b5c21103b04752ec0a6f2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 9 Apr 2026 23:22:53 +0000 Subject: [PATCH 2/2] Fix current-user permalink deeplink rewrite Co-authored-by: Dylan Jeffers --- .../NavigationContainer.test.ts | 22 ++++++++++++++++++- .../getNavigationStateFromDeeplinkPath.ts | 15 ++++++++++--- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/packages/mobile/src/components/navigation-container/NavigationContainer.test.ts b/packages/mobile/src/components/navigation-container/NavigationContainer.test.ts index f80965b3853..70771efaafd 100644 --- a/packages/mobile/src/components/navigation-container/NavigationContainer.test.ts +++ b/packages/mobile/src/components/navigation-container/NavigationContainer.test.ts @@ -1,6 +1,13 @@ import { getNavigationStateFromDeeplinkPath } from 'app/utils/deeplink/getNavigationStateFromDeeplinkPath' -const stubGetStateFromPath = (path: string) => ({ routes: [{ name: path }] }) +const stubGetStateFromPath = (path: string) => + path.startsWith('/track/') + ? { routes: [{ name: 'Track' }] } + : path.startsWith('/profile') + ? { routes: [{ name: 'UserProfile' }] } + : path.includes('/collection/') + ? { routes: [{ name: 'Collection' }] } + : { routes: [{ name: path }] } const getLeafRouteName = (state: any): string | undefined => { let current: any = state @@ -37,5 +44,18 @@ describe('getNavigationStateFromDeeplinkPath', () => { expect(getLeafRouteName(state)).toBe('Collection') }) + + test('does not rewrite current user playlist permalink to /profile', () => { + const state = getNavigationStateFromDeeplinkPath({ + path: '/Audius/playlist/140', + options: undefined, + hasAccount: true, + accountHandle: 'Audius', + routeName: '/trending', + getStateFromPath: stubGetStateFromPath as any + }) + + expect(getLeafRouteName(state)).toBe('Collection') + }) }) diff --git a/packages/mobile/src/utils/deeplink/getNavigationStateFromDeeplinkPath.ts b/packages/mobile/src/utils/deeplink/getNavigationStateFromDeeplinkPath.ts index 9ab59deb164..7696a794cad 100644 --- a/packages/mobile/src/utils/deeplink/getNavigationStateFromDeeplinkPath.ts +++ b/packages/mobile/src/utils/deeplink/getNavigationStateFromDeeplinkPath.ts @@ -18,6 +18,15 @@ type GetNavigationStateFromDeeplinkPathArgs = { getStateFromPath: GetStateFromPath } +const isProfilePathForHandle = (path: string, handle: string) => { + const normalizedHandle = handle.replace(/^@/, '') + const escaped = normalizedHandle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + return ( + path.match(new RegExp(`^/${escaped}$`, 'i')) || + path.match(new RegExp(`^/${escaped}/(tracks|albums|playlists|reposts)$`, 'i')) + ) +} + const createAppTabState = ( state: any ): any => ({ @@ -276,9 +285,9 @@ export const getNavigationStateFromDeeplinkPath = ({ const queryParams = queryParamsStart > -1 ? `&${path.slice(queryParamsStart + 1)}` : '' path = `/settings${subpathParam}${queryParams}` - } else if (path.match(`^/${accountHandle}(/|$)`)) { - // If the path is the current user and set path as `/profile` - path = path.replace(`/${accountHandle}`, '/profile') + } else if (accountHandle && isProfilePathForHandle(path, accountHandle)) { + // If the path is explicitly a profile URL for the current user, rewrite to `/profile` + path = path.replace(new RegExp(`^/${accountHandle.replace(/^@/, '')}`, 'i'), '/profile') } else { // If the path has two parts if (path.match(/^\/[^/]+\/[^/]+$/)) {