Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions packages/mobile/jest.deeplink.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
module.exports = {
testMatch: ['<rootDir>/src/components/navigation-container/**/*.test.ts'],
testEnvironment: 'node',
setupFilesAfterEnv: [],
transformIgnorePatterns: ['/node_modules/'],
moduleNameMapper: {
'^react-native$': '<rootDir>/__mocks__/react-native.js',
'^react-native/(.*)$': '<rootDir>/__mocks__/react-native.js',
'^~/(.*)$': '<rootDir>/src/$1',
'^app/(.*)$': '<rootDir>/src/$1',
'^@audius/sdk$': '<rootDir>/../sdk/src',
'^@audius/sdk/(.*)$': '<rootDir>/../sdk/src/$1'
}
}

4 changes: 1 addition & 3 deletions packages/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -217,9 +217,7 @@
},
"jest": {
"preset": "react-native",
"setupFilesAfterEnv": [
"@testing-library/jest-native/extend-expect"
],
"setupFilesAfterEnv": ["<rootDir>/jest.setup.js"],
"moduleFileExtensions": [
"ts",
"tsx",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { getNavigationStateFromDeeplinkPath } from 'app/utils/deeplink/getNavigationStateFromDeeplinkPath'

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
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')
})

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')
})
})

Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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'
Expand All @@ -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
Expand Down Expand Up @@ -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
})
}
}

Expand Down
Loading