diff --git a/packages/mobile/babel.config.js b/packages/mobile/babel.config.js index 887d3d5f4d5..89479a867af 100644 --- a/packages/mobile/babel.config.js +++ b/packages/mobile/babel.config.js @@ -10,6 +10,7 @@ module.exports = (api) => { extensions: ['.js', '.jsx', '.ts', '.tsx', '.json'], root: ['.'], alias: { + app: './src', '@audius/common/adapters': '../common/src/adapters', '@audius/common/messages': '../common/src/messages', '@audius/common/hooks': '../common/src/hooks', 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/jest.setup.js b/packages/mobile/jest.setup.js new file mode 100644 index 00000000000..206639e9090 --- /dev/null +++ b/packages/mobile/jest.setup.js @@ -0,0 +1 @@ +require('@testing-library/jest-native/extend-expect') diff --git a/packages/mobile/package.json b/packages/mobile/package.json index 6c3f2369827..50076a1e703 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -217,8 +217,13 @@ }, "jest": { "preset": "react-native", - "setupFilesAfterEnv": [ - "@testing-library/jest-native/extend-expect" + "setupFilesAfterEnv": ["/jest.setup.js"], + "moduleNameMapper": { + "^react-native$": "/node_modules/react-native", + "^@testing-library/react-native$": "/node_modules/@testing-library/react-native" + }, + "transformIgnorePatterns": [ + "node_modules/(?!((jest-)?react-native|@react-native(-community)?|react-native-reanimated)/)" ], "moduleFileExtensions": [ "ts", 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..5e0ac28cfbc --- /dev/null +++ b/packages/mobile/src/components/navigation-container/NavigationContainer.test.ts @@ -0,0 +1,60 @@ +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') + }) +}) diff --git a/packages/mobile/src/components/navigation-container/NavigationContainer.tsx b/packages/mobile/src/components/navigation-container/NavigationContainer.tsx index d66cc7d8066..7b34196c9ec 100644 --- a/packages/mobile/src/components/navigation-container/NavigationContainer.tsx +++ b/packages/mobile/src/components/navigation-container/NavigationContainer.tsx @@ -3,18 +3,12 @@ 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, - PartialState -} from '@react-navigation/native' +import type { LinkingOptions } from '@react-navigation/native' import { NavigationContainer as RNNavigationContainer, 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' @@ -22,6 +16,8 @@ import { screen } from 'app/services/analytics' import { getPrimaryRoute } from 'app/utils/navigation' import { useThemeVariant } from 'app/utils/theme' +import { getNavigationStateFromDeeplinkPath } from '../../utils/deeplink/getNavigationStateFromDeeplinkPath' + import { navigationThemes } from './navigationThemes' type NavigationContainerProps = { @@ -31,67 +27,6 @@ type NavigationContainerProps = { export const navigationRef = createNavigationContainerRef() -const createAppTabState = ( - state: PartialState -): PartialState => ({ - routes: [ - { - name: 'HomeStack', - state: { - routes: [ - { - name: 'App', - state: { - routes: [ - { - name: 'AppTabs', - state - } - ] - } - } - ] - } - } - ] -}) - -const createTrendingStackState = (route): PartialState => - createAppTabState({ - routes: [ - { - name: 'trending', - state: { - index: 1, - routes: [ - { - name: 'Trending' - }, - route - ] - } - } - ] - }) - -const createExploreStackState = (route): PartialState => - createAppTabState({ - routes: [ - { - name: 'explore', - state: { - index: 1, - routes: [ - { - name: 'Explore' - }, - route - ] - } - } - ] - }) - /** * NavigationContainer contains the react-navigation context * and configures linking @@ -258,250 +193,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/harmony-native/components/Text/Text.test.tsx b/packages/mobile/src/harmony-native/components/Text/Text.test.tsx index 6d08ae3db6f..684dd011bb4 100644 --- a/packages/mobile/src/harmony-native/components/Text/Text.test.tsx +++ b/packages/mobile/src/harmony-native/components/Text/Text.test.tsx @@ -1,15 +1,22 @@ +import type { ReactElement } from 'react' + import { render, screen } from '@testing-library/react-native' +import { ThemeProvider } from '../../foundations/theme' + import { Text } from './Text' +const renderWithTheme = (ui: ReactElement) => + render({ui}) + test('renders text correctly', () => { - render(hello world) + renderWithTheme(hello world) expect(screen.getByText(/hello world/i)).toBeOnTheScreen() }) test('it renders display variant correctly', () => { - render(test display) + renderWithTheme(test display) expect( screen.getByRole('heading', { name: /test display/i }) @@ -17,7 +24,7 @@ test('it renders display variant correctly', () => { }) test('it renders heading variant correctly', () => { - render(test heading) + renderWithTheme(test heading) expect( screen.getByRole('heading', { name: /test heading/i }) @@ -25,7 +32,7 @@ test('it renders heading variant correctly', () => { }) test('it renders labels correctly', () => { - render(test label) + renderWithTheme(test label) expect(screen.getByText(/test label/i)).toHaveStyle({ textTransform: 'uppercase' @@ -33,9 +40,9 @@ test('it renders labels correctly', () => { }) test('it renders color correctly', () => { - render(test label) + renderWithTheme(test label) expect(screen.getByText(/test label/i)).toHaveStyle({ - color: '#C2C0CC' + color: '#A2A0AFFF' }) }) diff --git a/packages/mobile/src/utils/deeplink/getNavigationStateFromDeeplinkPath.ts b/packages/mobile/src/utils/deeplink/getNavigationStateFromDeeplinkPath.ts new file mode 100644 index 00000000000..b3395828b27 --- /dev/null +++ b/packages/mobile/src/utils/deeplink/getNavigationStateFromDeeplinkPath.ts @@ -0,0 +1,333 @@ +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 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 => ({ + 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 (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(/^\/[^/]+\/[^/]+$/)) { + // 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) +}