diff --git a/android/app/build.gradle b/android/app/build.gradle index 5692a52de02..7c37abf3371 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -179,7 +179,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionName "7.37.1" - versionCode 1520 + versionCode 1534 testBuildType System.getProperty('testBuildType', 'debug') missingDimensionStrategy 'react-native-camera', 'general' testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" diff --git a/app/actions/browser/index.js b/app/actions/browser/index.js index 1148ca544df..d7d12810d9c 100644 --- a/app/actions/browser/index.js +++ b/app/actions/browser/index.js @@ -36,10 +36,12 @@ export function addToHistory({ url, name }) { /** * Clears the entire browser history */ -export function clearHistory() { +export function clearHistory(metricsEnabled, marketingEnabled) { return { type: 'CLEAR_BROWSER_HISTORY', id: Date.now(), + metricsEnabled, + marketingEnabled, }; } diff --git a/app/component-library/components/Avatars/Avatar/variants/AvatarFavicon/AvatarFavicon.tsx b/app/component-library/components/Avatars/Avatar/variants/AvatarFavicon/AvatarFavicon.tsx index 31c42b931c8..4078026d14d 100644 --- a/app/component-library/components/Avatars/Avatar/variants/AvatarFavicon/AvatarFavicon.tsx +++ b/app/component-library/components/Avatars/Avatar/variants/AvatarFavicon/AvatarFavicon.tsx @@ -12,7 +12,6 @@ import { ICONSIZE_BY_AVATARSIZE } from '../../Avatar.constants'; import AvatarBase from '../../foundation/AvatarBase'; // Internal dependencies. -import { isNumber } from 'lodash'; import { isFaviconSVG } from '../../../../../../util/favicon'; import { AVATARFAVICON_IMAGE_TESTID, @@ -28,9 +27,14 @@ const AvatarFavicon = ({ style, ...props }: AvatarFaviconProps) => { - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const [error, setError] = useState(undefined); + const isRequireSource = !!(imageSource && typeof imageSource === 'number'); + const isRemoteSource = !!( + imageSource && + typeof imageSource === 'object' && + 'uri' in imageSource + ); + const isValidSource = isRequireSource || isRemoteSource; + const [error, setError] = useState(undefined); const [svgSource, setSvgSource] = useState(''); const { styles } = useStyles(stylesheet, { style }); @@ -40,9 +44,7 @@ const AvatarFavicon = ({ [setError], ); - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const onSvgError = useCallback((e: any) => setError(e), [setError]); + const onSvgError = useCallback((e: Error) => setError(e), [setError]); // TODO add the fallback with uppercase letter initial // requires that the domain is passed in as a prop from the parent @@ -53,30 +55,29 @@ const AvatarFavicon = ({ /> ); + // Checks if image is SVG useEffect(() => { + if (!isRemoteSource) return; + const checkSvgContentType = async (uri: string) => { try { const response = await fetch(uri, { method: 'HEAD' }); const contentType = response.headers.get('Content-Type'); return contentType?.includes('image/svg+xml'); - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } catch (err: any) { + } catch (_) { return false; } }; - if (imageSource && !isNumber(imageSource) && 'uri' in imageSource) { - const svg = isFaviconSVG(imageSource); - if (svg) { - checkSvgContentType(svg).then((isSvg) => { - if (isSvg) { - setSvgSource(svg); - } - }); - } + const svg = isFaviconSVG(imageSource); + if (svg) { + checkSvgContentType(svg).then((isSvg) => { + if (isSvg) { + setSvgSource(svg); + } + }); } - }, [imageSource]); + }, [imageSource, isRemoteSource]); const renderSvg = () => svgSource ? ( @@ -86,9 +87,7 @@ const AvatarFavicon = ({ height="100%" uri={svgSource} style={styles.image} - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - onError={(e: any) => onSvgError(e)} + onError={(e: unknown) => onSvgError(e as Error)} /> ) : null; @@ -106,7 +105,7 @@ const AvatarFavicon = ({ return ( - {error ? renderFallbackFavicon() : renderFavicon()} + {error || !isValidSource ? renderFallbackFavicon() : renderFavicon()} ); }; diff --git a/app/components/Nav/Main/MainNavigator.js b/app/components/Nav/Main/MainNavigator.js index 18d7361ff51..69646d5a6b5 100644 --- a/app/components/Nav/Main/MainNavigator.js +++ b/app/components/Nav/Main/MainNavigator.js @@ -60,7 +60,6 @@ import { colors as importedColors } from '../../../styles/common'; import OrderDetails from '../../UI/Ramp/Views/OrderDetails'; import SendTransaction from '../../UI/Ramp/Views/SendTransaction'; import TabBar from '../../../component-library/components/Navigation/TabBar'; -import BrowserUrlModal from '../../Views/BrowserUrlModal'; ///: BEGIN:ONLY_INCLUDE_IF(external-snaps) import { SnapsSettingsList } from '../../Views/Snaps/SnapsSettingsList'; import { SnapSettings } from '../../Views/Snaps/SnapSettings'; @@ -212,11 +211,10 @@ const BrowserFlow = () => ( cardStyle: { backgroundColor: importedColors.transparent }, }} > - ); diff --git a/app/components/UI/BrowserUrlBar/BrowserUrlBar.styles.ts b/app/components/UI/BrowserUrlBar/BrowserUrlBar.styles.ts index 0658da7b6b3..3a4f7682c2c 100644 --- a/app/components/UI/BrowserUrlBar/BrowserUrlBar.styles.ts +++ b/app/components/UI/BrowserUrlBar/BrowserUrlBar.styles.ts @@ -1,17 +1,60 @@ import { StyleSheet } from 'react-native'; - import { Theme } from '../../../util/theme/models'; +import { fontStyles } from '../../../styles/common'; +import Device from '../../../util/device'; -const styleSheet = (params: { theme: Theme }) => +const styleSheet = ({ + theme: { colors }, + vars: { isUrlBarFocused }, +}: { + theme: Theme; + vars: { isUrlBarFocused: boolean }; +}) => StyleSheet.create({ main: { flexDirection: 'row', alignItems: 'center', + flex: 1, + borderRadius: 999, + marginHorizontal: 16, + backgroundColor: isUrlBarFocused + ? colors.background.alternative + : colors.background.default, + }, + connectionIcon: { + marginRight: 8, + }, + textInputWrapper: { + flex: 1, + }, + textInput: { + flex: 1, + height: 44, + paddingVertical: 0, + margin: 0, + paddingLeft: isUrlBarFocused ? 16 : 0, + ...fontStyles.normal, + fontSize: Device.isAndroid() ? 16 : 14, + color: colors.text.default, + }, + browserUrlBarWrapper: { + flexDirection: 'row', + alignItems: 'center', + }, + clearButton: { + marginRight: 8, + marginLeft: 4, + }, + cancelButton: { + marginRight: 16, + justifyContent: 'center', }, - text: { - marginLeft: 8, - color: params.theme.colors.text.alternative, + cancelButtonText: { + fontSize: 14, + color: colors.primary.default, + ...fontStyles.normal, }, + rightButton: { height: 50, justifyContent: 'center' }, }); export default styleSheet; diff --git a/app/components/UI/BrowserUrlBar/BrowserUrlBar.test.tsx b/app/components/UI/BrowserUrlBar/BrowserUrlBar.test.tsx new file mode 100644 index 00000000000..18da98158d3 --- /dev/null +++ b/app/components/UI/BrowserUrlBar/BrowserUrlBar.test.tsx @@ -0,0 +1,218 @@ +import React from 'react'; +import renderWithProvider from '../../../util/test/renderWithProvider'; +import BrowserUrlBar from './BrowserUrlBar'; +import { ConnectionType } from './BrowserUrlBar.types'; +import { useMetrics } from '../../../components/hooks/useMetrics'; +import { backgroundState } from '../../../util/test/initial-root-state'; +import { selectAccountsLength } from '../../../selectors/accountTrackerController'; +import { + selectNetworkConfigurations, + selectProviderConfig, +} from '../../../selectors/networkController'; +import { fireEvent } from '@testing-library/react-native'; +import { BrowserURLBarSelectorsIDs } from '../../../../e2e/selectors/Browser/BrowserURLBar.selectors'; +import { AccountOverviewSelectorsIDs } from '../../../../e2e/selectors/Browser/AccountOverview.selectors'; +import Routes from '../../../constants/navigation/Routes'; + +const mockNavigate = jest.fn(); +const navigation = { + params: { + url: 'https://metamask.github.io/test-dapp/', + }, +}; +jest.mock('@react-navigation/native', () => { + const actualNav = jest.requireActual('@react-navigation/native'); + return { + ...actualNav, + useNavigation: () => ({ + navigate: mockNavigate, + }), + useRoute: jest.fn(() => ({ params: navigation.params })), + }; +}); + +jest.mock('../../../components/hooks/useMetrics', () => ({ + useMetrics: jest.fn(), +})); + +const mockTrackEvent = jest.fn(); +const mockCreateEventBuilder = jest.fn(() => ({ + addProperties: jest.fn(() => ({ + build: jest.fn(), + })), +})); + +const mockInitialState = { + engine: { + backgroundState, + }, +}; + +const mockUseSelector = jest.fn(); +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: (callback: unknown) => mockUseSelector(callback), +})); + +describe('BrowserUrlBar', () => { + const defaultProps = { + connectionType: ConnectionType.SECURE, + onSubmitEditing: jest.fn(), + onCancel: jest.fn(), + onFocus: jest.fn(), + onBlur: jest.fn(), + onChangeText: jest.fn(), + connectedAccounts: ['0x123'], + activeUrl: 'https://example.com', + setIsUrlBarFocused: jest.fn(), + isUrlBarFocused: true, + }; + const propsWithoutUrlBarFocused = { + connectionType: ConnectionType.SECURE, + onSubmitEditing: jest.fn(), + onCancel: jest.fn(), + onFocus: jest.fn(), + onBlur: jest.fn(), + onChangeText: jest.fn(), + connectedAccounts: ['0x123'], + activeUrl: 'https://example.com', + setIsUrlBarFocused: jest.fn(), + isUrlBarFocused: false, + }; + + beforeEach(() => { + jest.clearAllMocks(); + (useMetrics as jest.Mock).mockReturnValue({ + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, + }); + + mockUseSelector.mockImplementation((selector) => { + if (selector === selectAccountsLength) return 1; + if (selector === selectNetworkConfigurations) return {}; + if (selector === selectProviderConfig) return { chainId: '0x1' }; + return null; + }); + }); + + it('should render correctly', () => { + const { toJSON } = renderWithProvider(, { + state: mockInitialState, + }); + expect(toJSON()).toMatchSnapshot(); + }); + + it('should render correctly when url bar is not focused', () => { + const { toJSON } = renderWithProvider( + , + { + state: mockInitialState, + }, + ); + expect(toJSON()).toMatchSnapshot(); + }); + + it('should handle text input changes', () => { + const { getByTestId } = renderWithProvider( + , + { + state: mockInitialState, + }, + ); + + const urlInput = getByTestId(BrowserURLBarSelectorsIDs.URL_INPUT); + fireEvent.changeText(urlInput, 'test.com'); + + expect(defaultProps.onChangeText).toHaveBeenCalledWith('test.com'); + }); + + it('should handle submit editing', () => { + const { getByTestId } = renderWithProvider( + , + { + state: mockInitialState, + }, + ); + + const urlInput = getByTestId(BrowserURLBarSelectorsIDs.URL_INPUT); + fireEvent(urlInput, 'submitEditing', { + nativeEvent: { text: ' test.com ' }, + }); + + expect(defaultProps.onSubmitEditing).toHaveBeenCalledWith('test.com'); + }); + + it('should handle clear input button press', () => { + const { getByTestId } = renderWithProvider( + , + { + state: mockInitialState, + }, + ); + + const clearButton = getByTestId(BrowserURLBarSelectorsIDs.URL_CLEAR_ICON); + fireEvent.press(clearButton); + + expect(defaultProps.onChangeText).toHaveBeenCalledWith(''); + }); + + it('should handle cancel button press', () => { + const { getByTestId } = renderWithProvider( + , + { + state: mockInitialState, + }, + ); + + const cancelButton = getByTestId( + BrowserURLBarSelectorsIDs.CANCEL_BUTTON_ON_BROWSER_ID, + ); + fireEvent.press(cancelButton); + + expect(defaultProps.onCancel).toHaveBeenCalled(); + expect(defaultProps.setIsUrlBarFocused).toHaveBeenCalledWith(false); + }); + + it('should handle account right button press', () => { + const { getByTestId } = renderWithProvider( + , + { state: mockInitialState }, + ); + + const accountButton = getByTestId( + AccountOverviewSelectorsIDs.ACCOUNT_BUTTON, + ); + fireEvent.press(accountButton); + + expect(mockTrackEvent).toHaveBeenCalledTimes(2); + expect(mockNavigate).toHaveBeenCalledWith(Routes.MODAL.ROOT_MODAL_FLOW, { + screen: Routes.SHEET.ACCOUNT_PERMISSIONS, + params: { + hostInfo: { + metadata: { + origin: 'example.com', + }, + }, + }, + }); + }); + + it('should handle focus and blur events', () => { + const { getByTestId } = renderWithProvider( + , + { + state: mockInitialState, + }, + ); + + const urlInput = getByTestId(BrowserURLBarSelectorsIDs.URL_INPUT); + + fireEvent(urlInput, 'focus'); + expect(defaultProps.setIsUrlBarFocused).toHaveBeenCalledWith(true); + expect(defaultProps.onFocus).toHaveBeenCalled(); + + fireEvent(urlInput, 'blur'); + expect(defaultProps.setIsUrlBarFocused).toHaveBeenCalledWith(false); + expect(defaultProps.onBlur).toHaveBeenCalled(); + }); +}); diff --git a/app/components/UI/BrowserUrlBar/BrowserUrlBar.tsx b/app/components/UI/BrowserUrlBar/BrowserUrlBar.tsx index 9daea63b352..a0fdfd28934 100644 --- a/app/components/UI/BrowserUrlBar/BrowserUrlBar.tsx +++ b/app/components/UI/BrowserUrlBar/BrowserUrlBar.tsx @@ -1,63 +1,248 @@ -import React from 'react'; -import { TouchableOpacity, View } from 'react-native'; - +import React, { forwardRef, useImperativeHandle, useMemo, useRef } from 'react'; +import { + NativeSyntheticEvent, + TextInput, + TextInputSubmitEditingEventData, + TouchableOpacity, + View, +} from 'react-native'; import { useStyles } from '../../../component-library/hooks'; -import { getURLProtocol } from '../../../util/general'; -import { PROTOCOLS } from '../../../constants/deeplinks'; -import { isGatewayUrl } from '../../../lib/ens-ipfs/resolver'; -import AppConstants from '../../../core/AppConstants'; import Icon, { IconName, IconSize, } from '../../../component-library/components/Icons/Icon'; -import Text from '../../../component-library/components/Texts/Text'; - -import { BrowserUrlBarProps } from './BrowserUrlBar.types'; +import { + BrowserUrlBarProps, + BrowserUrlBarRef, + ConnectionType, +} from './BrowserUrlBar.types'; import stylesheet from './BrowserUrlBar.styles'; import { BrowserViewSelectorsIDs } from '../../../../e2e/selectors/Browser/BrowserView.selectors'; -import Url from 'url-parse'; -import { regex } from '../../../../app/util/regex'; - -const BrowserUrlBar = ({ url, route, onPress }: BrowserUrlBarProps) => { - const getDappMainUrl = () => { - if (!url) return; - - const urlObj = new Url(url); - const ensUrl = route.params?.currentEnsName ?? ''; - - if ( - isGatewayUrl(urlObj) && - url.search(`${AppConstants.IPFS_OVERRIDE_PARAM}=false`) === -1 && - Boolean(ensUrl) - ) { - return ensUrl.toLowerCase().replace(regex.startUrl, ''); - } - return urlObj.host.toLowerCase().replace(regex.startUrl, ''); - }; - - const contentProtocol = getURLProtocol(url); - const isHttps = contentProtocol === PROTOCOLS.HTTPS; - - const secureConnectionIcon = isHttps ? IconName.Lock : IconName.LockSlash; - - const mainUrl = getDappMainUrl(); - - const { styles, theme } = useStyles(stylesheet, {}); - - return ( - - - - - {mainUrl} - +import { strings } from '../../../../locales/i18n'; +import { BrowserURLBarSelectorsIDs } from '../../../../e2e/selectors/Browser/BrowserURLBar.selectors'; +import AccountRightButton from '../AccountRightButton'; +import Text from '../../../component-library/components/Texts/Text'; +import { selectAccountsLength } from '../../../selectors/accountTrackerController'; +import { useSelector } from 'react-redux'; +import { selectNetworkConfigurations } from '../../../selectors/networkController'; +import { useMetrics } from '../../hooks/useMetrics'; +import { MetaMetricsEvents } from '../../../core/Analytics'; +import { useNavigation } from '@react-navigation/native'; +import Routes from '../../../constants/navigation/Routes'; +import URLParse from 'url-parse'; +import ButtonIcon, { + ButtonIconSizes, +} from '../../../component-library/components/Buttons/ButtonIcon'; + +const BrowserUrlBar = forwardRef( + ( + { + connectionType, + onSubmitEditing, + onCancel, + onFocus, + onBlur, + onChangeText, + connectedAccounts, + activeUrl, + setIsUrlBarFocused, + isUrlBarFocused, + }, + ref, + ) => { + const inputValueRef = useRef(''); + const inputRef = useRef(null); + const shouldTriggerBlurCallbackRef = useRef(true); + const accountsLength = useSelector(selectAccountsLength); + const networkConfigurations = useSelector(selectNetworkConfigurations); + const { trackEvent, createEventBuilder } = useMetrics(); + const navigation = useNavigation(); + const selectedAddress = connectedAccounts?.[0]; + const { + styles, + theme: { colors, themeAppearance }, + } = useStyles(stylesheet, { isUrlBarFocused }); + const isConnectionIconVisible = + connectionType !== ConnectionType.UNKNOWN && !isUrlBarFocused; + + const unfocusInput = () => { + setIsUrlBarFocused(false); + // Reset the input value + inputValueRef.current = ''; + }; + + const onCancelInput = () => { + shouldTriggerBlurCallbackRef.current = false; + inputRef?.current?.blur(); + unfocusInput(); + onCancel(); + }; + + useImperativeHandle(ref, () => ({ + hide: () => onCancelInput(), + blur: () => inputRef?.current?.blur(), + focus: () => inputRef?.current?.focus(), + setNativeProps: (props: object) => + inputRef?.current?.setNativeProps(props), + })); + + /** + * Gets browser url bar icon based on connection type + */ + const connectionTypeIcon = useMemo(() => { + // Default to unsecure icon + let iconName = IconName.LockSlash; + + switch (connectionType) { + case ConnectionType.SECURE: + iconName = IconName.Lock; + break; + case ConnectionType.UNSECURE: + iconName = IconName.LockSlash; + break; + } + return iconName; + }, [connectionType]); + + const onBlurInput = () => { + if (!shouldTriggerBlurCallbackRef.current) { + shouldTriggerBlurCallbackRef.current = true; + return; + } + unfocusInput(); + onBlur(); + }; + + const onFocusInput = () => { + setIsUrlBarFocused(true); + onFocus(); + }; + + const onChangeTextInput = (text: string) => { + inputRef?.current?.setNativeProps({ text }); + onChangeText(text); + }; + + const onSubmitEditingInput = ({ + nativeEvent: { text }, + }: NativeSyntheticEvent) => { + const trimmedText = text.trim(); + inputValueRef.current = trimmedText; + onSubmitEditing(trimmedText); + }; + + const handleAccountRightButtonPress = () => { + const nonTestnetNetworks = Object.keys(networkConfigurations).length + 1; + const numberOfConnectedAccounts = connectedAccounts.length; + + // TODO: This is currently tracking two events, we should consolidate + trackEvent( + createEventBuilder(MetaMetricsEvents.OPEN_DAPP_PERMISSIONS) + .addProperties({ + number_of_accounts: accountsLength, + number_of_accounts_connected: numberOfConnectedAccounts, + number_of_networks: nonTestnetNetworks, + }) + .build(), + ); + // Track Event: "Opened Acount Switcher" + trackEvent( + createEventBuilder(MetaMetricsEvents.BROWSER_OPEN_ACCOUNT_SWITCH) + .addProperties({ + number_of_accounts: numberOfConnectedAccounts, + }) + .build(), + ); + + navigation.navigate(Routes.MODAL.ROOT_MODAL_FLOW, { + screen: Routes.SHEET.ACCOUNT_PERMISSIONS, + params: { + hostInfo: { + metadata: { + // TODO: This is not an origin, it's a hostname + origin: activeUrl && new URLParse(activeUrl).hostname, + }, + }, + }, + }); + }; + + /** + * Clears the input value and calls the onChangeText callback + */ + const onClearInput = () => { + const clearedText = ''; + inputRef?.current?.clear(); + inputValueRef.current = clearedText; + onChangeText(clearedText); + }; + + return ( + + + {isConnectionIconVisible ? ( + + ) : null} + + + + {isUrlBarFocused ? ( + + ) : null} + + + {isUrlBarFocused ? ( + + + {strings('browser.cancel')} + + + ) : ( + + )} + - - ); -}; + ); + }, +); export default BrowserUrlBar; diff --git a/app/components/UI/BrowserUrlBar/BrowserUrlBar.types.ts b/app/components/UI/BrowserUrlBar/BrowserUrlBar.types.ts index fba86832f74..000fc52b7e1 100644 --- a/app/components/UI/BrowserUrlBar/BrowserUrlBar.types.ts +++ b/app/components/UI/BrowserUrlBar/BrowserUrlBar.types.ts @@ -1,7 +1,36 @@ -export interface BrowserUrlBarProps { - url: string; - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - route: any; - onPress: () => void; +/** + * Connection type for identifying the icon of the browser url bar + */ +export enum ConnectionType { + SECURE = 'secure', + UNSECURE = 'unsecure', + UNKNOWN = 'unknown', } + +/** + * Ref for the BrowserUrlBar component + */ +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type BrowserUrlBarRef = { + hide: () => void; + blur: () => void; + focus: () => void; + setNativeProps: (props: object) => void; +}; + +/** + * BrowserUrlBar props + */ +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type BrowserUrlBarProps = { + connectionType: ConnectionType; + onSubmitEditing: (text: string) => void; + onCancel: () => void; + onFocus: () => void; + onBlur: () => void; + onChangeText: (text: string) => void; + connectedAccounts: string[]; + activeUrl: string; + setIsUrlBarFocused: (focused: boolean) => void; + isUrlBarFocused: boolean; +}; diff --git a/app/components/UI/BrowserUrlBar/__snapshots__/BrowserUrlBar.test.tsx.snap b/app/components/UI/BrowserUrlBar/__snapshots__/BrowserUrlBar.test.tsx.snap new file mode 100644 index 00000000000..d4cd3407d2a --- /dev/null +++ b/app/components/UI/BrowserUrlBar/__snapshots__/BrowserUrlBar.test.tsx.snap @@ -0,0 +1,363 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`BrowserUrlBar should render correctly 1`] = ` + + + + + + + + + + + + + Cancel + + + + +`; + +exports[`BrowserUrlBar should render correctly when url bar is not focused 1`] = ` + + + + + + + + + + + + + + + + + + + ? + + + + + + + + +`; diff --git a/app/components/UI/BrowserUrlBar/index.ts b/app/components/UI/BrowserUrlBar/index.ts index 5abf0923371..cb43dd3ccc7 100644 --- a/app/components/UI/BrowserUrlBar/index.ts +++ b/app/components/UI/BrowserUrlBar/index.ts @@ -1 +1,3 @@ export { default } from './BrowserUrlBar'; + +export * from './BrowserUrlBar.types'; diff --git a/app/components/UI/DeleteWalletModal/__snapshots__/index.test.tsx.snap b/app/components/UI/DeleteWalletModal/__snapshots__/index.test.tsx.snap new file mode 100644 index 00000000000..5a618b44257 --- /dev/null +++ b/app/components/UI/DeleteWalletModal/__snapshots__/index.test.tsx.snap @@ -0,0 +1,516 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DeleteWalletModal should render correctly 1`] = ` + + + + + + + + + + + + + +  + + + Are you sure you want to erase your wallet? + + + + Your current wallet, accounts and assets will be + + + removed from this app permanently. + + + This action cannot be undone. + + + + + You can ONLY recover this wallet with your + + + Secret Recovery Phrase + + + MetaMask does not have your Secret Recovery Phrase. + + + + + + + + I understand, continue + + + + + Cancel + + + + + + + + + + +`; diff --git a/app/components/UI/DeleteWalletModal/index.test.tsx b/app/components/UI/DeleteWalletModal/index.test.tsx new file mode 100644 index 00000000000..55279c8db86 --- /dev/null +++ b/app/components/UI/DeleteWalletModal/index.test.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import DeleteWalletModal from './'; +import renderWithProvider from '../../../util/test/renderWithProvider'; +import { backgroundState } from '../../../util/test/initial-root-state'; + +const mockInitialState = { + engine: { backgroundState }, + security: { + dataCollectionForMarketing: false, + }, +}; + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useDispatch: () => jest.fn(), + useSelector: jest.fn(), +})); +const mockNavigate = jest.fn(); + +jest.mock('@react-navigation/native', () => { + const actualNav = jest.requireActual('@react-navigation/native'); + return { + ...actualNav, + useNavigation: () => ({ + navigate: mockNavigate, + }), + }; +}); + +describe('DeleteWalletModal', () => { + it('should render correctly', () => { + const wrapper = renderWithProvider(, { + state: mockInitialState, + }); + + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/app/components/UI/DeleteWalletModal/index.tsx b/app/components/UI/DeleteWalletModal/index.tsx index fb12f474eca..da8b0b3d402 100644 --- a/app/components/UI/DeleteWalletModal/index.tsx +++ b/app/components/UI/DeleteWalletModal/index.tsx @@ -25,9 +25,10 @@ import { DeleteWalletModalSelectorsIDs } from '../../../../e2e/selectors/Setting import generateTestId from '../../../../wdio/utils/generateTestId'; import { MetaMetricsEvents } from '../../../core/Analytics'; import { useMetrics } from '../../../components/hooks/useMetrics'; -import { useDispatch } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import { clearHistory } from '../../../actions/browser'; import CookieManager from '@react-native-cookies/cookies'; +import { RootState } from '../../../reducers'; const DELETE_KEYWORD = 'delete'; @@ -38,7 +39,7 @@ if (Device.isAndroid() && UIManager.setLayoutAnimationEnabledExperimental) { const DeleteWalletModal = () => { const navigation = useNavigation(); const { colors, themeAppearance } = useTheme(); - const { trackEvent, createEventBuilder } = useMetrics(); + const { trackEvent, createEventBuilder, isEnabled } = useMetrics(); const styles = createStyles(colors); const modalRef = useRef(null); @@ -49,6 +50,9 @@ const DeleteWalletModal = () => { const [resetWalletState, deleteUser] = useDeleteWallet(); const dispatch = useDispatch(); + const isDataCollectionForMarketingEnabled = useSelector( + (state: RootState) => state.security.dataCollectionForMarketing, + ); const showConfirmModal = () => { setShowConfirm(true); @@ -89,7 +93,9 @@ const DeleteWalletModal = () => { }; const deleteWallet = async () => { - await dispatch(clearHistory()); + await dispatch( + clearHistory(isEnabled(), isDataCollectionForMarketingEnabled), + ); await CookieManager.clearAll(true); triggerClose(); await resetWalletState(); diff --git a/app/components/UI/Navbar/index.js b/app/components/UI/Navbar/index.js index d859702e0ed..6019710ca37 100644 --- a/app/components/UI/Navbar/index.js +++ b/app/components/UI/Navbar/index.js @@ -29,7 +29,6 @@ import { isNotificationsFeatureEnabled } from '../../../util/notifications'; import Device from '../../../util/device'; import generateTestId from '../../../../wdio/utils/generateTestId'; import PickerNetwork from '../../../component-library/components/Pickers/PickerNetwork'; -import BrowserUrlBar from '../BrowserUrlBar'; import { NAV_ANDROID_BACK_BUTTON } from '../../../../wdio/screen-objects/testIDs/Screens/NetworksScreen.testids'; import { BACK_BUTTON_SIMPLE_WEBVIEW } from '../../../../wdio/screen-objects/testIDs/Components/SimpleWebView.testIds'; import Routes from '../../../constants/navigation/Routes'; @@ -614,62 +613,6 @@ export function getSendFlowTitle( }; } -/** - * Function that returns the navigation options - * This is used by views that will show our custom navbar - * which contains accounts icon, Title or MetaMask Logo and current network, and settings icon - * - * @param {Object} navigation - Navigation object required to push new views - * @returns {Object} - Corresponding navbar options containing headerTitle, headerLeft and headerRight - */ -export function getBrowserViewNavbarOptions( - route, - themeColors, - rightButtonAnalyticsEvent, - headerShown = true, -) { - const innerStyles = StyleSheet.create({ - headerStyle: { - backgroundColor: themeColors.background.default, - shadowColor: importedColors.transparent, - elevation: 0, - borderBottomWidth: 0.5, - borderColor: themeColors.border.muted, - }, - headerIcon: { - color: themeColors.primary.default, - }, - }); - - const url = route.params?.url ?? ''; - - const handleUrlPress = () => route.params?.showUrlModal?.(); - - const handleAccountRightButtonPress = (permittedAccounts, currentUrl) => { - rightButtonAnalyticsEvent(permittedAccounts, currentUrl); - route.params?.setAccountsPermissionsVisible(); - }; - - const connectedAccounts = route.params?.connectedAccounts; - - return { - gestureEnabled: false, - headerTitleAlign: 'left', - headerTitle: () => ( - - ), - headerRight: () => ( - - ), - headerStyle: innerStyles.headerStyle, - headerShown, - }; -} - /** * Function that returns the navigation options * for our modals diff --git a/app/components/UI/OnboardingWizard/index.tsx b/app/components/UI/OnboardingWizard/index.tsx index 3f96607358e..1c2b70dd3e1 100644 --- a/app/components/UI/OnboardingWizard/index.tsx +++ b/app/components/UI/OnboardingWizard/index.tsx @@ -74,9 +74,7 @@ interface OnboardingWizardProps { // TODO: Replace "any" with type // eslint-disable-next-line @typescript-eslint/no-explicit-any navigation: any; - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - coachmarkRef: React.RefObject | null; + coachmarkRef?: React.RefObject | null; } interface DrawerRef { diff --git a/app/components/UI/Tabs/TabThumbnail/TabThumbnail.styles.ts b/app/components/UI/Tabs/TabThumbnail/TabThumbnail.styles.ts index f672e05c73a..728edf6fc94 100644 --- a/app/components/UI/Tabs/TabThumbnail/TabThumbnail.styles.ts +++ b/app/components/UI/Tabs/TabThumbnail/TabThumbnail.styles.ts @@ -10,12 +10,7 @@ const margin = 15; const width = Dimensions.get('window').width - margin * 2; const height = Dimensions.get('window').height / (Device.isIphone5S() ? 4 : 5); let paddingTop = Dimensions.get('window').height - 190; -if (Device.isIphoneX()) { - paddingTop -= 65; -} -if (Device.isAndroid()) { - paddingTop -= 10; -} +paddingTop -= 100; const createStyles = (colors: ThemeColors) => StyleSheet.create({ diff --git a/app/components/UI/Tabs/TabThumbnail/TabThumbnail.tsx b/app/components/UI/Tabs/TabThumbnail/TabThumbnail.tsx index 3b660012ba8..e7a0d49aeb2 100644 --- a/app/components/UI/Tabs/TabThumbnail/TabThumbnail.tsx +++ b/app/components/UI/Tabs/TabThumbnail/TabThumbnail.tsx @@ -1,10 +1,5 @@ import React, { useContext, useMemo } from 'react'; -import { - Image, - ImageSourcePropType, - TouchableOpacity, - View, -} from 'react-native'; +import { Image, TouchableOpacity, View } from 'react-native'; import ElevatedView from 'react-native-elevated-view'; import { strings } from '../../../../../locales/i18n'; import Avatar, { @@ -22,21 +17,17 @@ import Icon, { import Text, { TextVariant, } from '../../../../component-library/components/Texts/Text'; -import AppConstants from '../../../../core/AppConstants'; -import METAMASK_FOX from '../../../../images/fox.png'; import { useNetworkInfo } from '../../../../selectors/selectedNetworkController'; import { getHost } from '../../../../util/browser'; import Device from '../../../../util/device'; import { ThemeContext, mockTheme } from '../../../../util/theme'; -import WebsiteIcon from '../../WebsiteIcon'; import createStyles from './TabThumbnail.styles'; import { TabThumbnailProps } from './TabThumbnail.types'; import { useSelector } from 'react-redux'; import { selectPermissionControllerState } from '../../../../selectors/snaps/permissionController'; import { getPermittedAccountsByHostname } from '../../../../core/Permissions'; import { useAccounts } from '../../../hooks/useAccounts'; - -const { HOMEPAGE_URL } = AppConstants; +import { useFavicon } from '../../../hooks/useFavicon'; /** * View that renders a tab thumbnail to be displayed in the in-app browser. @@ -52,22 +43,21 @@ const TabThumbnail = ({ const { colors } = useContext(ThemeContext) || mockTheme; const styles = useMemo(() => createStyles(colors), [colors]); const Container: React.ElementType = Device.isAndroid() ? View : ElevatedView; - const hostname = getHost(tab.url); - const isHomepage = hostname === getHost(HOMEPAGE_URL); + const tabTitle = getHost(tab.url); // Get permitted accounts for this hostname const permittedAccountsList = useSelector(selectPermissionControllerState); const permittedAccountsByHostname = getPermittedAccountsByHostname( permittedAccountsList, - hostname, + tabTitle, ); const activeAddress = permittedAccountsByHostname[0]; const { accounts } = useAccounts({}); const selectedAccount = accounts.find( (account) => account.address.toLowerCase() === activeAddress?.toLowerCase(), ); - - const { networkName, networkImageSource } = useNetworkInfo(hostname); + const { networkName, networkImageSource } = useNetworkInfo(tabTitle); + const faviconSource = useFavicon(tab.url); return ( @@ -79,21 +69,14 @@ const TabThumbnail = ({ > - {isHomepage ? ( - - ) : ( - - )} + - {isHomepage ? strings('browser.new_tab') : hostname} + {tabTitle} - - - + - a - - + } + width={20} + /> - - - + - a - - + } + width={20} + /> @@ -21,7 +26,7 @@ exports[`UrlAutocomplete should render correctly 1`] = ` } } accessible={true} - focusable={false} + focusable={true} onClick={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} diff --git a/app/components/UI/UrlAutocomplete/index.js b/app/components/UI/UrlAutocomplete/index.js deleted file mode 100644 index 03f05bac182..00000000000 --- a/app/components/UI/UrlAutocomplete/index.js +++ /dev/null @@ -1,244 +0,0 @@ -import React, { PureComponent } from 'react'; -import { - TouchableWithoutFeedback, - View, - StyleSheet, - TouchableOpacity, - Text, -} from 'react-native'; -import PropTypes from 'prop-types'; -import dappUrlList from '../../../util/dapp-url-list'; -import Fuse from 'fuse.js'; -import { connect } from 'react-redux'; -import WebsiteIcon from '../WebsiteIcon'; -import { fontStyles } from '../../../styles/common'; -import { getHost } from '../../../util/browser'; -import { ThemeContext, mockTheme } from '../../../util/theme'; - -const createStyles = (colors) => - StyleSheet.create({ - wrapper: { - paddingVertical: 15, - flex: 1, - backgroundColor: colors.background.default, - }, - bookmarkIco: { - width: 26, - height: 26, - marginRight: 7, - borderRadius: 13, - }, - fallbackTextStyle: { - fontSize: 12, - }, - name: { - fontSize: 14, - color: colors.text.default, - ...fontStyles.normal, - }, - url: { - fontSize: 12, - color: colors.text.alternative, - ...fontStyles.normal, - }, - item: { - flex: 1, - marginBottom: 20, - }, - itemWrapper: { - flexDirection: 'row', - marginBottom: 20, - paddingHorizontal: 15, - }, - textContent: { - flex: 1, - marginLeft: 10, - }, - bg: { - flex: 1, - }, - }); - -/** - * PureComponent that renders an autocomplete - * based on an input string - */ -class UrlAutocomplete extends PureComponent { - static propTypes = { - /** - * input text for the autocomplete - */ - input: PropTypes.string, - /** - * Callback that is triggered while - * choosing one of the autocomplete options - */ - onSubmit: PropTypes.func, - /** - * Callback that is triggered while - * tapping on the background - */ - onDismiss: PropTypes.func, - /** - * An array of visited urls and names - */ - browserHistory: PropTypes.array, - }; - - state = { - results: [], - }; - - componentDidMount() { - const allUrls = [...this.props.browserHistory, ...dappUrlList]; - const singleUrlList = []; - const singleUrls = []; - for (let i = 0; i < allUrls.length; i++) { - const el = allUrls[i]; - if (!singleUrlList.includes(el.url)) { - singleUrlList.push(el.url); - singleUrls.push(el); - } - } - - this.fuse = new Fuse(singleUrls, { - shouldSort: true, - threshold: 0.45, - location: 0, - distance: 100, - maxPatternLength: 32, - minMatchCharLength: 1, - keys: [ - { name: 'name', weight: 0.5 }, - { name: 'url', weight: 0.5 }, - ], - }); - - this.timer = null; - this.mounted = true; - } - - componentDidUpdate(prevProps) { - if (prevProps.input !== this.props.input) { - if (this.timer) { - clearTimeout(this.timer); - } - - this.timer = setTimeout(() => { - const fuseSearchResult = this.fuse.search(this.props.input); - if (Array.isArray(fuseSearchResult)) { - this.updateResults([...fuseSearchResult]); - } else { - this.updateResults([]); - } - }, 500); - } - } - - componentWillUnmount() { - this.mounted = false; - } - - updateResults(results) { - this.mounted && this.setState({ results }); - } - - onSubmitInput = () => this.props.onSubmit(this.props.input); - - renderUrlOption = (url, name, onPress) => { - const colors = this.context.colors || mockTheme.colors; - const styles = createStyles(colors); - - name = typeof name === 'string' ? name : getHost(url); - return ( - - - - - - {name} - - - {url} - - - - - ); - }; - - render() { - const colors = this.context.colors || mockTheme.colors; - const styles = createStyles(colors); - - if (!this.props.input || this.props.input.length < 2) - return ( - - - - - - ); - if (this.state.results.length === 0) { - return ( - - - - - - {this.props.input} - - - - - - - - - ); - } - return ( - - {this.state.results.slice(0, 3).map((r) => { - const { url, name } = r; - const onPress = () => { - this.props.onSubmit(url); - }; - return this.renderUrlOption(url, name, onPress); - })} - - - - - ); - } -} - -const mapStateToProps = (state) => ({ - browserHistory: state.browser.history, -}); - -UrlAutocomplete.contextType = ThemeContext; - -export default connect(mapStateToProps)(UrlAutocomplete); diff --git a/app/components/UI/UrlAutocomplete/index.tsx b/app/components/UI/UrlAutocomplete/index.tsx new file mode 100644 index 00000000000..e724dc0c158 --- /dev/null +++ b/app/components/UI/UrlAutocomplete/index.tsx @@ -0,0 +1,167 @@ +import React, { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useRef, + useState, +} from 'react'; +import { + TouchableWithoutFeedback, + View, + TouchableOpacity, + Text, +} from 'react-native'; +import dappUrlList from '../../../util/dapp-url-list'; +import Fuse from 'fuse.js'; +import { useSelector } from 'react-redux'; +import WebsiteIcon from '../WebsiteIcon'; +import { getHost } from '../../../util/browser'; +import styleSheet from './styles'; +import { useStyles } from '../../../component-library/hooks'; +import { + UrlAutocompleteComponentProps, + FuseSearchResult, + UrlAutocompleteRef, +} from './types'; +import { selectBrowserHistory } from '../../../reducers/browser/selectors'; +import { debounce } from 'lodash'; + +export * from './types'; + +/** + * Autocomplete list that appears when the browser url bar is focused + */ +const UrlAutocomplete = forwardRef< + UrlAutocompleteRef, + UrlAutocompleteComponentProps +>(({ onSelect, onDismiss }, ref) => { + const [results, setResults] = useState([]); + // TODO: Browser history hasn't been working for a while. Need to either fix or remove. + const browserHistory = useSelector(selectBrowserHistory); + const fuseRef = useRef | null>(null); + const resultsRef = useRef(null); + const { styles } = useStyles(styleSheet, {}); + + /** + * Show the results view + */ + const show = () => { + resultsRef.current?.setNativeProps({ style: { display: 'flex' } }); + }; + + const search = (text: string) => { + const fuseSearchResult = fuseRef.current?.search(text); + if (Array.isArray(fuseSearchResult)) { + setResults([...fuseSearchResult]); + } else { + setResults([]); + } + }; + + /** + * Debounce the search function + */ + const debouncedSearchRef = useRef(debounce(search, 500)); + + /** + * Hide the results view + */ + const hide = useCallback(() => { + // Cancel the search + debouncedSearchRef.current.cancel(); + resultsRef.current?.setNativeProps({ style: { display: 'none' } }); + setResults([]); + }, [setResults]); + + const dismissAutocomplete = () => { + hide(); + // Call the onDismiss callback + onDismiss(); + }; + + useImperativeHandle(ref, () => ({ + search: debouncedSearchRef.current, + hide, + show, + })); + + useEffect(() => { + const allUrls: FuseSearchResult[] = [browserHistory, ...dappUrlList]; + const singleUrlList: string[] = []; + const singleUrls: FuseSearchResult[] = []; + for (const el of allUrls) { + if (!singleUrlList.includes(el.url)) { + singleUrlList.push(el.url); + singleUrls.push(el); + } + } + + // Create the fuse search + fuseRef.current = new Fuse(singleUrls, { + shouldSort: true, + threshold: 0.45, + location: 0, + distance: 100, + maxPatternLength: 32, + minMatchCharLength: 1, + keys: [ + { name: 'name', weight: 0.5 }, + { name: 'url', weight: 0.5 }, + ], + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const renderResult = useCallback( + (url: string, name: string, onPress: () => void) => { + name = typeof name === 'string' ? name : getHost(url); + + return ( + + + + + + {name} + + + {url} + + + + + ); + }, + [styles], + ); + + const renderResults = useCallback( + () => + results.slice(0, 3).map((result) => { + const { url, name } = result; + const onPress = () => { + hide(); + onSelect(url); + }; + return renderResult(url, name, onPress); + }), + [results, onSelect, hide, renderResult], + ); + + return ( + + {renderResults()} + + + + + ); +}); + +export default UrlAutocomplete; diff --git a/app/components/UI/UrlAutocomplete/styles.ts b/app/components/UI/UrlAutocomplete/styles.ts new file mode 100644 index 00000000000..eaa7bb06dc0 --- /dev/null +++ b/app/components/UI/UrlAutocomplete/styles.ts @@ -0,0 +1,50 @@ +import { Theme } from '@metamask/design-tokens'; +import { StyleSheet } from 'react-native'; +import { fontStyles } from '../../../styles/common'; + +const styleSheet = ({ theme: { colors } }: { theme: Theme }) => + StyleSheet.create({ + wrapper: { + ...StyleSheet.absoluteFillObject, + backgroundColor: colors.background.default, + // Hidden by default + display: 'none', + paddingTop: 8, + }, + bookmarkIco: { + width: 26, + height: 26, + marginRight: 7, + borderRadius: 13, + }, + fallbackTextStyle: { + fontSize: 12, + }, + name: { + fontSize: 14, + color: colors.text.default, + ...fontStyles.normal, + }, + url: { + fontSize: 12, + color: colors.text.alternative, + ...fontStyles.normal, + }, + item: { + paddingVertical: 8, + marginBottom: 8, + }, + itemWrapper: { + flexDirection: 'row', + paddingHorizontal: 15, + }, + textContent: { + flex: 1, + marginLeft: 10, + }, + bg: { + flex: 1, + }, + }); + +export default styleSheet; diff --git a/app/components/UI/UrlAutocomplete/types.ts b/app/components/UI/UrlAutocomplete/types.ts new file mode 100644 index 00000000000..e86416259ab --- /dev/null +++ b/app/components/UI/UrlAutocomplete/types.ts @@ -0,0 +1,44 @@ +/** + * Props for the UrlAutocomplete component + */ +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type UrlAutocompleteComponentProps = { + /** + * Callback that is triggered while + * choosing one of the autocomplete options + */ + onSelect: (url: string) => void; + /** + * Callback that is triggered while + * tapping on the background + */ + onDismiss: () => void; +}; + +/** + * Ref for the UrlAutocomplete component + */ +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type UrlAutocompleteRef = { + /** + * Search for autocomplete results + */ + search: (text: string) => void; + /** + * Hide the autocomplete results + */ + hide: () => void; + /** + * Show the autocomplete results + */ + show: () => void; +}; + +/** + * The result of an Fuse search + */ +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type FuseSearchResult = { + url: string; + name: string; +}; diff --git a/app/components/Views/AccountPermissions/__snapshots__/AccountPermissions.ff-on.test.tsx.snap b/app/components/Views/AccountPermissions/__snapshots__/AccountPermissions.ff-on.test.tsx.snap index c69a94b7005..8eb9153e266 100644 --- a/app/components/Views/AccountPermissions/__snapshots__/AccountPermissions.ff-on.test.tsx.snap +++ b/app/components/Views/AccountPermissions/__snapshots__/AccountPermissions.ff-on.test.tsx.snap @@ -137,18 +137,17 @@ exports[`AccountPermissions with feature flags ON should render AccountPermissio } } > - - void; @@ -140,19 +141,12 @@ const AssetOptions = (props: Props) => { existingTabId = existingPortfolioTab.id; } else { const analyticsEnabled = isEnabled(); - const portfolioUrl = new URL(AppConstants.PORTFOLIO.URL); - portfolioUrl.searchParams.append('metamaskEntry', 'mobile'); - - // Append user's privacy preferences for metrics + marketing on user navigation to Portfolio. - portfolioUrl.searchParams.append( - 'metricsEnabled', - String(analyticsEnabled), - ); - portfolioUrl.searchParams.append( - 'marketingEnabled', - String(!!isDataCollectionForMarketingEnabled), - ); + const portfolioUrl = appendURLParams(AppConstants.PORTFOLIO.URL, { + metamaskEntry: 'mobile', + metricsEnabled: analyticsEnabled, + marketingEnabled: isDataCollectionForMarketingEnabled ?? false, + }); newTabUrl = portfolioUrl.href; } diff --git a/app/components/Views/Browser/constants.ts b/app/components/Views/Browser/constants.ts new file mode 100644 index 00000000000..0376d6f120b --- /dev/null +++ b/app/components/Views/Browser/constants.ts @@ -0,0 +1,9 @@ +import { Dimensions } from 'react-native'; +import Device from '../../../util/device'; + +const margin = 16; + +export const THUMB_WIDTH = Dimensions.get('window').width / 2 - margin * 2; +export const THUMB_HEIGHT = Device.isIos() + ? THUMB_WIDTH * 1.81 + : THUMB_WIDTH * 1.48; diff --git a/app/components/Views/Browser/index.js b/app/components/Views/Browser/index.js index 49dd1e58f1a..3e50b3f020b 100644 --- a/app/components/Views/Browser/index.js +++ b/app/components/Views/Browser/index.js @@ -1,12 +1,6 @@ import PropTypes from 'prop-types'; -import React, { - useContext, - useEffect, - useRef, - useCallback, - useState, -} from 'react'; -import { Dimensions, Platform, View } from 'react-native'; +import React, { useContext, useEffect, useRef } from 'react'; +import { View } from 'react-native'; import { captureScreen } from 'react-native-view-shot'; import { connect, useSelector } from 'react-redux'; import { strings } from '../../../../locales/i18n'; @@ -26,34 +20,18 @@ import { import { useAccounts } from '../../../components/hooks/useAccounts'; import { MetaMetricsEvents } from '../../../core/Analytics'; import AppConstants from '../../../core/AppConstants'; -import { - getPermittedAccounts, - getPermittedAccountsByHostname, -} from '../../../core/Permissions'; -import { selectAccountsLength } from '../../../selectors/accountTrackerController'; -import { baseStyles } from '../../../styles/common'; +import { getPermittedAccounts } from '../../../core/Permissions'; import Logger from '../../../util/Logger'; import getAccountNameWithENS from '../../../util/accounts'; -import Device from '../../../util/device'; -import { useTheme } from '../../../util/theme'; import Tabs from '../../UI/Tabs'; -import BrowserTab from '../BrowserTab'; - -import { isEqual } from 'lodash'; +import BrowserTab from '../BrowserTab/BrowserTab'; import URL from 'url-parse'; import { useMetrics } from '../../../components/hooks/useMetrics'; -import { - selectNetworkConfigurations, - selectChainId, -} from '../../../selectors/networkController'; -import { getBrowserViewNavbarOptions } from '../../UI/Navbar'; -import { selectPermissionControllerState } from '../../../selectors/snaps/permissionController'; -import Engine from '../../../core/Engine'; -import Routes from '../../../constants/navigation/Routes'; - -const margin = 16; -const THUMB_WIDTH = Dimensions.get('window').width / 2 - margin * 2; -const THUMB_HEIGHT = Device.isIos() ? THUMB_WIDTH * 1.81 : THUMB_WIDTH * 1.48; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { appendURLParams } from '../../../util/browser'; +import { THUMB_WIDTH, THUMB_HEIGHT } from './constants'; +import { useStyles } from '../../hooks/useStyles'; +import styleSheet from './styles'; /** * Component that wraps all the browser @@ -70,12 +48,11 @@ export const Browser = (props) => { updateTab, activeTab: activeTabId, tabs, - accountsLength, - chainId, } = props; const previousTabs = useRef(null); - const { colors } = useTheme(); - const { trackEvent, createEventBuilder } = useMetrics(); + const { top: topInset } = useSafeAreaInsets(); + const { styles } = useStyles(styleSheet, { topInset }); + const { trackEvent, createEventBuilder, isEnabled } = useMetrics(); const { toastRef } = useContext(ToastContext); const browserUrl = props.route?.params?.url; const linkType = props.route?.params?.linkType; @@ -86,52 +63,19 @@ export const Browser = (props) => { ? AvatarAccountType.Blockies : AvatarAccountType.JazzIcon, ); - - // networkConfigurations has all the rpcs added by the user. We add 1 more to account the Ethereum Main Network - const nonTestnetNetworks = - Object.keys(props.networkConfigurations).length + 1; - - const activeTab = tabs.find((tab) => tab.id === activeTabId); - const permittedAccountsList = useSelector((state) => { - if (!activeTab) return []; - - const permissionsControllerState = selectPermissionControllerState(state); - const hostname = new URL(activeTab.url).hostname; - const permittedAcc = getPermittedAccountsByHostname( - permissionsControllerState, - hostname, - ); - return permittedAcc; - }, isEqual); - - const handleRightTopButtonAnalyticsEvent = () => { - trackEvent( - createEventBuilder(MetaMetricsEvents.OPEN_DAPP_PERMISSIONS) - .addProperties({ - number_of_accounts: accountsLength, - number_of_accounts_connected: permittedAccountsList.length, - number_of_networks: nonTestnetNetworks, - }) - .build(), - ); - }; - - useEffect( - () => - navigation.setOptions( - getBrowserViewNavbarOptions( - route, - colors, - handleRightTopButtonAnalyticsEvent, - !route.params?.showTabs, - ), - ), - /* eslint-disable-next-line */ - [navigation, route, colors], + const isDataCollectionForMarketingEnabled = useSelector( + (state) => state.security.dataCollectionForMarketing, ); + const homePageUrl = () => + appendURLParams(AppConstants.HOMEPAGE_URL, { + metricsEnabled: isEnabled(), + marketingEnabled: isDataCollectionForMarketingEnabled ?? false, + }).href; + const newTab = (url, linkType) => { - createNewTab(url || AppConstants.HOMEPAGE_URL, linkType); + // When a new tab is created, a new tab is rendered, which automatically sets the url source on the webview + createNewTab(url || homePageUrl(), linkType); }; const updateTabInfo = (url, tabID) => @@ -144,7 +88,6 @@ export const Browser = (props) => { ...route.params, showTabs: false, url, - silent: false, }); }; @@ -307,7 +250,6 @@ export const Browser = (props) => { navigation.setParams({ ...route.params, url: null, - silent: true, }); } }; @@ -328,7 +270,6 @@ export const Browser = (props) => { navigation.setParams({ ...route.params, url: newTab.url, - silent: true, }); } }); @@ -336,7 +277,6 @@ export const Browser = (props) => { navigation.setParams({ ...route.params, url: null, - silent: true, }); } } @@ -349,7 +289,6 @@ export const Browser = (props) => { navigation.setParams({ ...route.params, showTabs: false, - silent: true, }); } }; @@ -377,18 +316,19 @@ export const Browser = (props) => { )); return ( {renderBrowserTabs()} @@ -398,11 +338,8 @@ export const Browser = (props) => { }; const mapStateToProps = (state) => ({ - accountsLength: selectAccountsLength(state), - networkConfigurations: selectNetworkConfigurations(state), tabs: state.browser.tabs, activeTab: state.browser.activeTab, - chainId: selectChainId(state), }); const mapDispatchToProps = (dispatch) => ({ @@ -450,12 +387,6 @@ Browser.propTypes = { * Object that represents the current route info like params passed to it */ route: PropTypes.object, - accountsLength: PropTypes.number, - networkConfigurations: PropTypes.object, - /** - * Current network chainId - */ - chainId: PropTypes.string, }; export { default as createBrowserNavDetails } from './Browser.types'; diff --git a/app/components/Views/Browser/styles.ts b/app/components/Views/Browser/styles.ts new file mode 100644 index 00000000000..39ec5227dc8 --- /dev/null +++ b/app/components/Views/Browser/styles.ts @@ -0,0 +1,11 @@ +import { StyleSheet } from 'react-native'; + +const styleSheet = ({ vars: { topInset } }: { vars: { topInset: number } }) => + StyleSheet.create({ + browserContainer: { + flex: 1, + paddingTop: topInset, + }, + }); + +export default styleSheet; diff --git a/app/components/Views/BrowserTab/BrowserTab.tsx b/app/components/Views/BrowserTab/BrowserTab.tsx new file mode 100644 index 00000000000..b88ab587ac9 --- /dev/null +++ b/app/components/Views/BrowserTab/BrowserTab.tsx @@ -0,0 +1,1433 @@ +import React, { + useState, + useRef, + useEffect, + useCallback, + useMemo, +} from 'react'; +import { View, Alert, BackHandler, ImageSourcePropType } from 'react-native'; +import { isEqual } from 'lodash'; +import { WebView, WebViewMessageEvent } from '@metamask/react-native-webview'; +import BrowserBottomBar from '../../UI/BrowserBottomBar'; +import { connect, useSelector } from 'react-redux'; +import BackgroundBridge from '../../../core/BackgroundBridge/BackgroundBridge'; +import Engine from '../../../core/Engine'; +import WebviewProgressBar from '../../UI/WebviewProgressBar'; +import Logger from '../../../util/Logger'; +import { + processUrlForBrowser, + prefixUrlWithProtocol, + isTLD, + protocolAllowList, + trustedProtocolToDeeplink, + getAlertMessage, + allowLinkOpen, + getUrlObj, +} from '../../../util/browser'; +import { + SPA_urlChangeListener, + JS_DESELECT_TEXT, +} from '../../../util/browserScripts'; +import resolveEnsToIpfsContentId from '../../../lib/ens-ipfs/resolver'; +import { strings } from '../../../../locales/i18n'; +import URLParse from 'url-parse'; +import WebviewErrorComponent from '../../UI/WebviewError'; +import { addToHistory, addToWhitelist } from '../../../actions/browser'; +import Device from '../../../util/device'; +import AppConstants from '../../../core/AppConstants'; +import { MetaMetricsEvents } from '../../../core/Analytics'; +import OnboardingWizard from '../../UI/OnboardingWizard'; +import DrawerStatusTracker from '../../../core/DrawerStatusTracker'; +import EntryScriptWeb3 from '../../../core/EntryScriptWeb3'; +import ErrorBoundary from '../ErrorBoundary'; +import { getRpcMethodMiddleware } from '../../../core/RPCMethods/RPCMethodMiddleware'; +import downloadFile from '../../../util/browser/downloadFile'; +import { MAX_MESSAGE_LENGTH } from '../../../constants/dapp'; +import sanitizeUrlInput from '../../../util/url/sanitizeUrlInput'; +import { getPermittedAccountsByHostname } from '../../../core/Permissions'; +import Routes from '../../../constants/navigation/Routes'; +import { + selectIpfsGateway, + selectIsIpfsGatewayEnabled, +} from '../../../selectors/preferencesController'; +import { selectSelectedInternalAccountFormattedAddress } from '../../../selectors/accountsController'; +import useFavicon from '../../hooks/useFavicon/useFavicon'; +import { + HOMEPAGE_HOST, + IPFS_GATEWAY_DISABLED_ERROR, + OLD_HOMEPAGE_URL_HOST, + NOTIFICATION_NAMES, + MM_MIXPANEL_TOKEN, +} from './constants'; +import { regex } from '../../../../app/util/regex'; +import { selectChainId } from '../../../selectors/networkController'; +import { BrowserViewSelectorsIDs } from '../../../../e2e/selectors/Browser/BrowserView.selectors'; +import { useMetrics } from '../../../components/hooks/useMetrics'; +import { trackDappViewedEvent } from '../../../util/metrics'; +import trackErrorAsAnalytics from '../../../util/metrics/TrackError/trackErrorAsAnalytics'; +import { selectPermissionControllerState } from '../../../selectors/snaps/permissionController'; +import { isTest } from '../../../util/test/utils.js'; +import { EXTERNAL_LINK_TYPE } from '../../../constants/browser'; +import { PermissionKeys } from '../../../core/Permissions/specifications'; +import { CaveatTypes } from '../../../core/Permissions/constants'; +import { AccountPermissionsScreens } from '../AccountPermissions/AccountPermissions.types'; +import { isMultichainVersion1Enabled } from '../../../util/networks'; +import { useIsFocused, useNavigation } from '@react-navigation/native'; +import { useStyles } from '../../hooks/useStyles'; +import styleSheet from './styles'; +import { type RootState } from '../../../reducers'; +import { type Dispatch } from 'redux'; +import { + type SessionENSNames, + type BrowserTabProps, + type IpfsContentResult, + WebViewNavigationEventName, +} from './types'; +import { StackNavigationProp } from '@react-navigation/stack'; +import { + WebViewNavigationEvent, + WebViewErrorEvent, + WebViewError, + WebViewProgressEvent, + WebViewNavigation, +} from '@metamask/react-native-webview/lib/WebViewTypes'; +import PhishingModal from './components/PhishingModal'; +import BrowserUrlBar, { + ConnectionType, + BrowserUrlBarRef, +} from '../../UI/BrowserUrlBar'; +import { getMaskedUrl, isENSUrl } from './utils'; +import { getURLProtocol } from '../../../util/general'; +import { PROTOCOLS } from '../../../constants/deeplinks'; +import Options from './components/Options'; +import IpfsBanner from './components/IpfsBanner'; +import UrlAutocomplete, { UrlAutocompleteRef } from '../../UI/UrlAutocomplete'; +import { selectSearchEngine } from '../../../reducers/browser/selectors'; + +/** + * Tab component for the in-app browser + */ +export const BrowserTab: React.FC = ({ + id: tabId, + isIpfsGatewayEnabled, + addToWhitelist: triggerAddToWhitelist, + showTabs, + linkType, + isInTabsView, + wizardStep, + updateTabInfo, + addToBrowserHistory, + bookmarks, + initialUrl, + ipfsGateway, + newTab, + homePageUrl, + activeChainId, +}) => { + // This any can be removed when react navigation is bumped to v6 - issue https://github.com/react-navigation/react-navigation/issues/9037#issuecomment-735698288 + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const navigation = useNavigation>(); + const { styles } = useStyles(styleSheet, {}); + const [backEnabled, setBackEnabled] = useState(false); + const [forwardEnabled, setForwardEnabled] = useState(false); + const [progress, setProgress] = useState(0); + const [allowedInitialUrl, setAllowedInitialUrl] = useState(''); + const [firstUrlLoaded, setFirstUrlLoaded] = useState(false); + const [error, setError] = useState(false); + const [showOptions, setShowOptions] = useState(false); + const [entryScriptWeb3, setEntryScriptWeb3] = useState(); + const [showPhishingModal, setShowPhishingModal] = useState(false); + const [blockedUrl, setBlockedUrl] = useState(); + const [ipfsBannerVisible, setIpfsBannerVisible] = useState(false); + const [isResolvedIpfsUrl, setIsResolvedIpfsUrl] = useState(false); + const [isUrlBarFocused, setIsUrlBarFocused] = useState(false); + const [connectionType, setConnectionType] = useState(ConnectionType.UNKNOWN); + const webviewRef = useRef(null); + const blockListType = useRef(''); // TODO: Consider improving this type + const webStates = useRef< + Record + >({}); + // Track if webview is loaded for the first time + const isWebViewReadyToLoad = useRef(false); + const urlBarRef = useRef(null); + const autocompleteRef = useRef(null); + const onSubmitEditingRef = useRef<(text: string) => Promise>( + async () => { + // eslint-disable-next-line @typescript-eslint/no-empty-function + }, + ); + //const [resolvedUrl, setResolvedUrl] = useState(''); + const resolvedUrlRef = useRef(''); + const submittedUrlRef = useRef(''); + const titleRef = useRef(''); + const iconRef = useRef(); + const sessionENSNamesRef = useRef({}); + const ensIgnoreListRef = useRef([]); + const backgroundBridgeRef = useRef<{ + url: string; + hostname: string; + sendNotification: (payload: unknown) => void; + onDisconnect: () => void; + onMessage: (message: Record) => void; + }>(); + const fromHomepage = useRef(false); + const wizardScrollAdjustedRef = useRef(false); + const searchEngine = useSelector(selectSearchEngine); + const permittedAccountsList = useSelector((state: RootState) => { + const permissionsControllerState = selectPermissionControllerState(state); + const hostname = new URLParse(resolvedUrlRef.current).hostname; + const permittedAcc = getPermittedAccountsByHostname( + permissionsControllerState, + hostname, + ); + return permittedAcc; + }, isEqual); + + const favicon = useFavicon(resolvedUrlRef.current); + const { trackEvent, isEnabled, getMetaMetricsId, createEventBuilder } = + useMetrics(); + /** + * Is the current tab the active tab + */ + const isTabActive = useSelector( + (state: RootState) => state.browser.activeTab === tabId, + ); + + /** + * whitelisted url to bypass the phishing detection + */ + const whitelist = useSelector((state: RootState) => state.browser.whitelist); + + const isFocused = useIsFocused(); + + /** + * Checks if a given url or the current url is the homepage + */ + const isHomepage = useCallback((checkUrl = null) => { + const currentPage = checkUrl || resolvedUrlRef.current; + const prefixedUrl = prefixUrlWithProtocol(currentPage); + const { host: currentHost } = getUrlObj(prefixedUrl); + return ( + currentHost === HOMEPAGE_HOST || currentHost === OLD_HOMEPAGE_URL_HOST + ); + }, []); + + const notifyAllConnections = useCallback((payload) => { + backgroundBridgeRef.current?.sendNotification(payload); + }, []); + + /** + * Dismiss the text selection on the current website + */ + const dismissTextSelectionIfNeeded = useCallback(() => { + if (isTabActive && Device.isAndroid()) { + const { current } = webviewRef; + if (current) { + setTimeout(() => { + current.injectJavaScript(JS_DESELECT_TEXT); + }, 50); + } + } + }, [isTabActive]); + + /** + * Toggle the options menu + */ + const toggleOptions = useCallback(() => { + dismissTextSelectionIfNeeded(); + setShowOptions(!showOptions); + + trackEvent( + createEventBuilder(MetaMetricsEvents.DAPP_BROWSER_OPTIONS).build(), + ); + }, [ + dismissTextSelectionIfNeeded, + showOptions, + trackEvent, + createEventBuilder, + ]); + + /** + * Show the options menu + */ + const toggleOptionsIfNeeded = useCallback(() => { + if (showOptions) { + toggleOptions(); + } + }, [showOptions, toggleOptions]); + + /** + * Go back to previous website in history + */ + const goBack = useCallback(() => { + if (!backEnabled) return; + + toggleOptionsIfNeeded(); + const { current } = webviewRef; + if (!current) { + Logger.log('WebviewRef current is not defined!'); + } + // Reset error state + setError(false); + current?.goBack?.(); + }, [backEnabled, toggleOptionsIfNeeded]); + + /** + * Go forward to the next website in history + */ + const goForward = async () => { + if (!forwardEnabled) return; + + toggleOptionsIfNeeded(); + const { current } = webviewRef; + current?.goForward?.(); + }; + + /** + * Check if an origin is allowed + */ + const isAllowedOrigin = useCallback( + (urlOrigin: string) => { + const { PhishingController } = Engine.context; + + // Update phishing configuration if it is out-of-date + // This is async but we are not `await`-ing it here intentionally, so that we don't slow + // down network requests. The configuration is updated for the next request. + PhishingController.maybeUpdateState(); + + const phishingControllerTestResult = PhishingController.test(urlOrigin); + + // Only assign the if the hostname is on the block list + if ( + phishingControllerTestResult.result && + phishingControllerTestResult.name + ) + blockListType.current = phishingControllerTestResult.name; + + return ( + whitelist?.includes(urlOrigin) || !phishingControllerTestResult.result + ); + }, + [whitelist], + ); + + /** + * Show a phishing modal when a url is not allowed + */ + const handleNotAllowedUrl = useCallback((urlOrigin: string) => { + setBlockedUrl(urlOrigin); + setTimeout(() => setShowPhishingModal(true), 1000); + }, []); + + /** + * Get IPFS info from a ens url + * TODO: Consider improving this function and it's types + */ + const handleIpfsContent = useCallback( + async ( + fullUrl, + { hostname, pathname, query }, + ): Promise => { + const { provider } = + Engine.context.NetworkController.getProviderAndBlockTracker(); + let gatewayUrl; + try { + const { type, hash } = await resolveEnsToIpfsContentId({ + provider, + name: hostname, + chainId: activeChainId, + }); + if (type === 'ipfs-ns') { + gatewayUrl = `${ipfsGateway}${hash}${pathname || '/'}${query || ''}`; + const response = await fetch(gatewayUrl); + const statusCode = response.status; + if (statusCode >= 400) { + Logger.log('Status code ', statusCode, gatewayUrl); + return null; + } + } else if (type === 'swarm-ns') { + gatewayUrl = `${AppConstants.SWARM_DEFAULT_GATEWAY_URL}${hash}${ + pathname || '/' + }${query || ''}`; + } else if (type === 'ipns-ns') { + gatewayUrl = `${AppConstants.IPNS_DEFAULT_GATEWAY_URL}${hostname}${ + pathname || '/' + }${query || ''}`; + } + return { + url: gatewayUrl, + hash, + type, + }; + } catch (err: unknown) { + const handleIpfsContentError = err as Error; + //if it's not a ENS but a TLD (Top Level Domain) + if (isTLD(hostname, handleIpfsContentError)) { + ensIgnoreListRef.current.push(hostname); + return { url: fullUrl, reload: true }; + } + if ( + handleIpfsContentError?.message?.startsWith( + 'EnsIpfsResolver - no known ens-ipfs registry for chainId', + ) + ) { + trackErrorAsAnalytics( + 'Browser: Failed to resolve ENS name for chainId', + handleIpfsContentError?.message, + ); + } else { + Logger.error(handleIpfsContentError, 'Failed to resolve ENS name'); + } + + if ( + handleIpfsContentError?.message?.startsWith( + IPFS_GATEWAY_DISABLED_ERROR, + ) + ) { + setIpfsBannerVisible(true); + goBack(); + throw new Error(handleIpfsContentError?.message); + } else { + Alert.alert( + strings('browser.failed_to_resolve_ens_name'), + handleIpfsContentError.message, + ); + } + goBack(); + } + }, + [goBack, ipfsGateway, setIpfsBannerVisible, activeChainId], + ); + + const triggerDappViewedEvent = useCallback((urlToTrigger: string) => { + const permissionsControllerState = + Engine.context.PermissionController.state; + const hostname = new URLParse(urlToTrigger).hostname; + const connectedAccounts = getPermittedAccountsByHostname( + permissionsControllerState, + hostname, + ); + + // Check if there are any connected accounts + if (!connectedAccounts.length) { + return; + } + + // Track dapp viewed event + trackDappViewedEvent({ + hostname, + numberOfConnectedAccounts: connectedAccounts.length, + }); + }, []); + + /** + * Open a new tab + */ + const openNewTab = useCallback( + (newTabUrl?: string) => { + toggleOptionsIfNeeded(); + dismissTextSelectionIfNeeded(); + newTab(newTabUrl); + }, + [dismissTextSelectionIfNeeded, newTab, toggleOptionsIfNeeded], + ); + + /** + * Handle when the drawer (app menu) is opened + */ + const drawerOpenHandler = useCallback(() => { + dismissTextSelectionIfNeeded(); + }, [dismissTextSelectionIfNeeded]); + + const handleFirstUrl = useCallback(async () => { + setIsResolvedIpfsUrl(false); + const prefixedUrl = prefixUrlWithProtocol(initialUrl); + const { origin: urlOrigin } = new URLParse(prefixedUrl); + + if (isAllowedOrigin(urlOrigin)) { + setAllowedInitialUrl(prefixedUrl); + setFirstUrlLoaded(true); + setProgress(0); + return; + } + + handleNotAllowedUrl(prefixedUrl); + return; + }, [initialUrl, handleNotAllowedUrl, isAllowedOrigin]); + + /** + * Set initial url, dapp scripts and engine. Similar to componentDidMount + */ + useEffect(() => { + if (!isTabActive || isWebViewReadyToLoad.current) return; + + isWebViewReadyToLoad.current = true; + + const getEntryScriptWeb3 = async () => { + const entryScriptWeb3Fetched = await EntryScriptWeb3.get(); + setEntryScriptWeb3(entryScriptWeb3Fetched + SPA_urlChangeListener); + }; + + getEntryScriptWeb3(); + handleFirstUrl(); + }, [isTabActive, handleFirstUrl]); + + // Cleanup bridges when tab is closed + useEffect( + () => () => { + backgroundBridgeRef.current?.onDisconnect(); + }, + [], + ); + + useEffect(() => { + if (Device.isAndroid()) { + DrawerStatusTracker.hub.on('drawer::open', drawerOpenHandler); + } + + return function cleanup() { + if (Device.isAndroid()) { + DrawerStatusTracker?.hub?.removeListener( + 'drawer::open', + drawerOpenHandler, + ); + } + }; + }, [drawerOpenHandler]); + + /** + * Set navigation listeners + */ + useEffect(() => { + const handleAndroidBackPress = () => { + if (!isTabActive) return false; + goBack(); + return true; + }; + + BackHandler.addEventListener('hardwareBackPress', handleAndroidBackPress); + + // Handle hardwareBackPress event only for browser, not components rendered on top + navigation.addListener('focus', () => { + BackHandler.addEventListener('hardwareBackPress', handleAndroidBackPress); + }); + navigation.addListener('blur', () => { + BackHandler.removeEventListener( + 'hardwareBackPress', + handleAndroidBackPress, + ); + }); + + return function cleanup() { + BackHandler.removeEventListener( + 'hardwareBackPress', + handleAndroidBackPress, + ); + }; + }, [goBack, isTabActive, navigation]); + + /** + * Inject home page scripts to get the favourites and set analytics key + */ + const injectHomePageScripts = useCallback( + async (injectedBookmarks?: string[]) => { + const { current } = webviewRef; + const analyticsEnabled = isEnabled(); + const disctinctId = await getMetaMetricsId(); + const homepageScripts = ` + window.__mmFavorites = ${JSON.stringify( + injectedBookmarks || bookmarks, + )}; + window.__mmSearchEngine = "${searchEngine}"; + window.__mmMetametrics = ${analyticsEnabled}; + window.__mmDistinctId = "${disctinctId}"; + window.__mmMixpanelToken = "${MM_MIXPANEL_TOKEN}"; + (function () { + try { + window.dispatchEvent(new Event('metamask_onHomepageScriptsInjected')); + } catch (e) { + //Nothing to do + } + })() + `; + + current?.injectJavaScript(homepageScripts); + }, + [isEnabled, getMetaMetricsId, bookmarks, searchEngine], + ); + + /** + * Handles error for example, ssl certificate error or cannot open page + */ + const handleError = useCallback( + (webViewError: WebViewError) => { + resolvedUrlRef.current = submittedUrlRef.current; + titleRef.current = `Can't Open Page`; + iconRef.current = undefined; + setConnectionType(ConnectionType.UNKNOWN); + setBackEnabled(true); + setForwardEnabled(false); + // Show error and reset progress bar + setError(webViewError); + setProgress(0); + + // Prevent url from being set when the url bar is focused + !isUrlBarFocused && + urlBarRef.current?.setNativeProps({ text: submittedUrlRef.current }); + + isTabActive && + navigation.setParams({ + url: getMaskedUrl( + submittedUrlRef.current, + sessionENSNamesRef.current, + ), + }); + + // Used to render tab title in tab selection + updateTabInfo(`Can't Open Page`, tabId); + Logger.log(webViewError); + }, + [ + setConnectionType, + setBackEnabled, + setForwardEnabled, + setError, + setProgress, + isUrlBarFocused, + isTabActive, + tabId, + updateTabInfo, + navigation, + ], + ); + + const checkTabPermissions = useCallback(() => { + if ( + !( + isMultichainVersion1Enabled && + isFocused && + !isInTabsView && + isTabActive + ) + ) { + return; + } + if (!resolvedUrlRef.current) return; + const hostname = new URLParse(resolvedUrlRef.current).hostname; + const permissionsControllerState = + Engine.context.PermissionController.state; + const permittedAccounts = getPermittedAccountsByHostname( + permissionsControllerState, + hostname, + ); + + const isConnected = permittedAccounts.length > 0; + + if (isConnected) { + let permittedChains = []; + try { + const caveat = Engine.context.PermissionController.getCaveat( + hostname, + PermissionKeys.permittedChains, + CaveatTypes.restrictNetworkSwitching, + ); + permittedChains = Array.isArray(caveat?.value) ? caveat.value : []; + + const currentChainId = activeChainId; + const isCurrentChainIdAlreadyPermitted = + permittedChains.includes(currentChainId); + + if (!isCurrentChainIdAlreadyPermitted) { + navigation.navigate(Routes.MODAL.ROOT_MODAL_FLOW, { + screen: Routes.SHEET.ACCOUNT_PERMISSIONS, + params: { + isNonDappNetworkSwitch: true, + hostInfo: { + metadata: { + origin: hostname, + }, + }, + isRenderedAsBottomSheet: true, + initialScreen: AccountPermissionsScreens.Connected, + }, + }); + } + } catch (e) { + const checkTabPermissionsError = e as Error; + Logger.error(checkTabPermissionsError, 'Error in checkTabPermissions'); + } + } + }, [activeChainId, navigation, isFocused, isInTabsView, isTabActive]); + + /** + * Handles state changes for when the url changes + */ + const handleSuccessfulPageResolution = useCallback( + async (siteInfo: { + url: string; + title: string; + icon: ImageSourcePropType; + canGoBack: boolean; + canGoForward: boolean; + }) => { + resolvedUrlRef.current = siteInfo.url; + titleRef.current = siteInfo.title; + if (siteInfo.icon) iconRef.current = siteInfo.icon; + + const hostName = new URLParse(siteInfo.url).hostname; + // Prevent url from being set when the url bar is focused + !isUrlBarFocused && urlBarRef.current?.setNativeProps({ text: hostName }); + + const contentProtocol = getURLProtocol(siteInfo.url); + if (contentProtocol === PROTOCOLS.HTTPS) { + setConnectionType(ConnectionType.SECURE); + } else if (contentProtocol === PROTOCOLS.HTTP) { + setConnectionType(ConnectionType.UNSECURE); + } + setBackEnabled(siteInfo.canGoBack); + setForwardEnabled(siteInfo.canGoForward); + + isTabActive && + navigation.setParams({ + url: getMaskedUrl(siteInfo.url, sessionENSNamesRef.current), + }); + + updateTabInfo( + getMaskedUrl(siteInfo.url, sessionENSNamesRef.current), + tabId, + ); + + addToBrowserHistory({ + name: siteInfo.title, + url: getMaskedUrl(siteInfo.url, sessionENSNamesRef.current), + }); + + checkTabPermissions(); + }, + [ + isUrlBarFocused, + setConnectionType, + isTabActive, + tabId, + updateTabInfo, + addToBrowserHistory, + navigation, + checkTabPermissions, + ], + ); + + /** + * Function that allows custom handling of any web view requests. + * Return `true` to continue loading the request and `false` to stop loading. + */ + const onShouldStartLoadWithRequest = ({ + url: urlToLoad, + }: { + url: string; + }) => { + const { origin: urlOrigin } = new URLParse(urlToLoad); + + webStates.current[urlToLoad] = { + ...webStates.current[urlToLoad], + requested: true, + }; + + // Cancel loading the page if we detect its a phishing page + if (!isAllowedOrigin(urlOrigin)) { + handleNotAllowedUrl(urlOrigin); + return false; + } + + if (!isIpfsGatewayEnabled && isResolvedIpfsUrl) { + setIpfsBannerVisible(true); + return false; + } + + // Continue request loading it the protocol is whitelisted + const { protocol } = new URLParse(urlToLoad); + if (protocolAllowList.includes(protocol)) return true; + Logger.log(`Protocol not allowed ${protocol}`); + + // If it is a trusted deeplink protocol, do not show the + // warning alert. Allow the OS to deeplink the URL + // and stop the webview from loading it. + if (trustedProtocolToDeeplink.includes(protocol)) { + allowLinkOpen(urlToLoad); + return false; + } + + // TODO: add logging for untrusted protocol being used + // Sentry + const alertMsg = getAlertMessage(protocol, strings); + + // Pop up an alert dialog box to prompt the user for permission + // to execute the request + Alert.alert(strings('onboarding.warning_title'), alertMsg, [ + { + text: strings('browser.protocol_alert_options.ignore'), + onPress: () => null, + style: 'cancel', + }, + { + text: strings('browser.protocol_alert_options.allow'), + onPress: () => allowLinkOpen(urlToLoad), + style: 'default', + }, + ]); + + return false; + }; + + /** + * Sets loading bar progress + */ + const onLoadProgress = useCallback( + ({ + nativeEvent: { progress: onLoadProgressProgress }, + }: WebViewProgressEvent) => { + setProgress(onLoadProgressProgress); + }, + [setProgress], + ); + + /** + * When website finished loading + */ + const onLoadEnd = useCallback( + ({ + event: { nativeEvent }, + forceResolve, + }: { + event: WebViewNavigationEvent | WebViewErrorEvent; + forceResolve?: boolean; + }) => { + if ('code' in nativeEvent) { + // Handle error - code is a property of WebViewErrorEvent + return handleError(nativeEvent); + } + + // Handle navigation event + const { url, title, canGoBack, canGoForward } = nativeEvent; + // Do not update URL unless website has successfully completed loading. + webStates.current[url] = { ...webStates.current[url], ended: true }; + const { started, ended } = webStates.current[url]; + const incomingOrigin = new URLParse(url).origin; + const activeOrigin = new URLParse(resolvedUrlRef.current).origin; + if ( + forceResolve || + (started && ended) || + incomingOrigin === activeOrigin + ) { + delete webStates.current[url]; + // Update navigation bar address with title of loaded url. + handleSuccessfulPageResolution({ + title, + url, + icon: favicon, + canGoBack, + canGoForward, + }); + } + }, + [handleError, handleSuccessfulPageResolution, favicon], + ); + + /** + * Handle message from website + */ + const onMessage = ({ nativeEvent }: WebViewMessageEvent) => { + const data = nativeEvent.data; + try { + if (data.length > MAX_MESSAGE_LENGTH) { + console.warn( + `message exceeded size limit and will be dropped: ${data.slice( + 0, + 1000, + )}...`, + ); + return; + } + const dataParsed = typeof data === 'string' ? JSON.parse(data) : data; + if (!dataParsed || (!dataParsed.type && !dataParsed.name)) { + return; + } + if (dataParsed.name) { + backgroundBridgeRef.current?.onMessage(dataParsed); + return; + } + } catch (e: unknown) { + const onMessageError = e as Error; + Logger.error( + onMessageError, + `Browser::onMessage on ${resolvedUrlRef.current}`, + ); + } + }; + + const toggleUrlModal = useCallback(() => { + urlBarRef.current?.focus(); + }, []); + + const initializeBackgroundBridge = useCallback( + (urlBridge: string, isMainFrame: boolean) => { + // First disconnect and reset bridge + backgroundBridgeRef.current?.onDisconnect(); + backgroundBridgeRef.current = undefined; + + //@ts-expect-error - We should type bacgkround bridge js file + const newBridge = new BackgroundBridge({ + webview: webviewRef, + url: urlBridge, + getRpcMethodMiddleware: ({ + hostname, + getProviderState, + }: { + hostname: string; + getProviderState: () => void; + }) => + getRpcMethodMiddleware({ + hostname, + getProviderState, + navigation, + // Website info + url: resolvedUrlRef, + title: titleRef, + icon: iconRef, + // Bookmarks + isHomepage, + // Show autocomplete + fromHomepage, + toggleUrlModal, + // Wizard + wizardScrollAdjusted: wizardScrollAdjustedRef, + tabId, + injectHomePageScripts, + // TODO: This properties were missing, and were not optional + isWalletConnect: false, + isMMSDK: false, + analytics: {}, + }), + isMainFrame, + }); + backgroundBridgeRef.current = newBridge; + }, + [navigation, isHomepage, toggleUrlModal, tabId, injectHomePageScripts], + ); + + const sendActiveAccount = useCallback(async () => { + notifyAllConnections({ + method: NOTIFICATION_NAMES.accountsChanged, + params: permittedAccountsList, + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [notifyAllConnections, permittedAccountsList]); + + /** + * Website started to load + */ + const onLoadStart = useCallback( + async ({ nativeEvent }: WebViewNavigationEvent) => { + // Use URL to produce real url. This should be the actual website that the user is viewing. + const { origin: urlOrigin } = new URLParse(nativeEvent.url); + + webStates.current[nativeEvent.url] = { + ...webStates.current[nativeEvent.url], + started: true, + }; + + // Cancel loading the page if we detect its a phishing page + if (!isAllowedOrigin(urlOrigin)) { + handleNotAllowedUrl(urlOrigin); // should this be activeUrl.current instead of url? + return false; + } + + sendActiveAccount(); + + iconRef.current = undefined; + if (isHomepage(nativeEvent.url)) { + injectHomePageScripts(); + } + + initializeBackgroundBridge(urlOrigin, true); + }, + [ + isAllowedOrigin, + handleNotAllowedUrl, + sendActiveAccount, + isHomepage, + injectHomePageScripts, + initializeBackgroundBridge, + ], + ); + + /** + * Check whenever permissions change / account changes for Dapp + */ + useEffect(() => { + sendActiveAccount(); + }, [sendActiveAccount, permittedAccountsList]); + + /** + * Check when the ipfs gateway is enabled to hide the banner + */ + useEffect(() => { + if (isIpfsGatewayEnabled) { + setIpfsBannerVisible(false); + } + }, [isIpfsGatewayEnabled]); + + /** + * Render the progress bar + */ + const renderProgressBar = () => ( + + + + ); + + /** + * Track new tab event + */ + const trackNewTabEvent = () => { + trackEvent( + createEventBuilder(MetaMetricsEvents.BROWSER_NEW_TAB) + .addProperties({ + option_chosen: 'Browser Options', + number_of_tabs: undefined, + }) + .build(), + ); + }; + + /** + * Handle new tab button press + */ + const onNewTabPress = () => { + openNewTab(); + trackNewTabEvent(); + }; + + /** + * Show the different tabs + */ + const triggerShowTabs = () => { + dismissTextSelectionIfNeeded(); + showTabs(); + }; + + const isExternalLink = useMemo( + () => linkType === EXTERNAL_LINK_TYPE, + [linkType], + ); + + useEffect(() => { + checkTabPermissions(); + }, [checkTabPermissions, isFocused, isInTabsView, isTabActive]); + + const handleEnsUrl = useCallback( + async (ens: string) => { + try { + webviewRef.current?.stopLoading(); + + const { hostname, query, pathname } = new URLParse(ens); + const ipfsContent = await handleIpfsContent(ens, { + hostname, + query, + pathname, + }); + if (!ipfsContent?.url) return null; + const { url: ipfsUrl, reload } = ipfsContent; + // Reload with IPFS url + if (reload) return onSubmitEditingRef.current?.(ipfsUrl); + if (!ipfsContent.hash || !ipfsContent.type) { + Logger.error( + new Error('IPFS content is missing hash or type'), + 'Error in handleEnsUrl', + ); + return null; + } + const { type, hash } = ipfsContent; + sessionENSNamesRef.current[ipfsUrl] = { hostname, hash, type }; + setIsResolvedIpfsUrl(true); + return ipfsUrl; + } catch (_) { + return null; + } + }, + [handleIpfsContent, setIsResolvedIpfsUrl], + ); + + const onSubmitEditing = useCallback( + async (text: string) => { + if (!text) return; + setConnectionType(ConnectionType.UNKNOWN); + urlBarRef.current?.setNativeProps({ text }); + submittedUrlRef.current = text; + webviewRef.current?.stopLoading(); + // Format url for browser to be navigatable by webview + const processedUrl = processUrlForBrowser(text, searchEngine); + if (isENSUrl(processedUrl, ensIgnoreListRef.current)) { + const handledEnsUrl = await handleEnsUrl( + processedUrl.replace(regex.urlHttpToHttps, 'https://'), + ); + if (!handledEnsUrl) { + Logger.error( + new Error('Failed to handle ENS url'), + 'Error in onSubmitEditing', + ); + return; + } + return onSubmitEditingRef.current(handledEnsUrl); + } + // Directly update url in webview + webviewRef.current?.injectJavaScript(` + window.location.href = '${sanitizeUrlInput(processedUrl)}'; + true; // Required for iOS + `); + }, + [searchEngine, handleEnsUrl, setConnectionType], + ); + + // Assign the memoized function to the ref. This is needed since onSubmitEditing is a useCallback and is accessed recursively + useEffect(() => { + onSubmitEditingRef.current = onSubmitEditing; + }, [onSubmitEditing]); + + /** + * Go to home page, reload if already on homepage + */ + const goToHomepage = useCallback(async () => { + onSubmitEditing(homePageUrl); + toggleOptionsIfNeeded(); + triggerDappViewedEvent(resolvedUrlRef.current); + trackEvent(createEventBuilder(MetaMetricsEvents.DAPP_HOME).build()); + }, [ + toggleOptionsIfNeeded, + triggerDappViewedEvent, + trackEvent, + createEventBuilder, + onSubmitEditing, + homePageUrl, + ]); + + /** + * Reload current page + */ + const reload = useCallback(() => { + onSubmitEditing(resolvedUrlRef.current); + triggerDappViewedEvent(resolvedUrlRef.current); + }, [onSubmitEditing, triggerDappViewedEvent]); + + /** + * Render the onboarding wizard browser step + */ + const renderOnboardingWizard = () => { + if ([7].includes(wizardStep)) { + if (!wizardScrollAdjustedRef.current) { + setTimeout(() => { + reload(); + }, 1); + wizardScrollAdjustedRef.current = true; + } + return ; + } + return null; + }; + + const handleOnFileDownload = useCallback( + async ({ nativeEvent: { downloadUrl } }) => { + const downloadResponse = await downloadFile(downloadUrl); + if (downloadResponse) { + reload(); + } else { + Alert.alert(strings('download_files.error')); + reload(); + } + }, + [reload], + ); + + /** + * Return to the MetaMask Dapp Homepage + */ + const returnHome = () => { + onSubmitEditing(HOMEPAGE_HOST); + }; + + /** + * Render the bottom (navigation/options) bar + */ + const renderBottomBar = () => + isTabActive && !isUrlBarFocused ? ( + + ) : null; + + /** + * Handle autocomplete selection + */ + const onSelect = (url: string) => { + // Unfocus the url bar and hide the autocomplete results + urlBarRef.current?.hide(); + onSubmitEditing(url); + }; + + /** + * Handle autocomplete dismissal + */ + const onDismissAutocomplete = () => { + // Unfocus the url bar and hide the autocomplete results + urlBarRef.current?.hide(); + const hostName = + new URLParse(resolvedUrlRef.current).hostname || resolvedUrlRef.current; + urlBarRef.current?.setNativeProps({ text: hostName }); + }; + + /** + * Hide the autocomplete results + */ + const hideAutocomplete = () => autocompleteRef.current?.hide(); + + const onCancelUrlBar = () => { + hideAutocomplete(); + // Reset the url bar to the current url + const hostName = + new URLParse(resolvedUrlRef.current).hostname || resolvedUrlRef.current; + urlBarRef.current?.setNativeProps({ text: hostName }); + }; + + const onFocusUrlBar = () => { + // Show the autocomplete results + autocompleteRef.current?.show(); + urlBarRef.current?.setNativeProps({ text: resolvedUrlRef.current }); + }; + + const onChangeUrlBar = (text: string) => + // Search the autocomplete results + autocompleteRef.current?.search(text); + + const handleWebviewNavigationChange = useCallback( + (eventName: WebViewNavigationEventName) => + ( + syntheticEvent: + | WebViewNavigationEvent + | WebViewProgressEvent + | WebViewErrorEvent, + ) => { + const { OnLoadEnd, OnLoadProgress, OnLoadStart } = + WebViewNavigationEventName; + + const mappingEventNameString = () => { + switch (eventName) { + case OnLoadProgress: + return 'onLoadProgress'; + case OnLoadEnd: + return 'onLoadEnd'; + case OnLoadStart: + return 'onLoadStart'; + default: + return 'Invalid navigation name'; + } + }; + + Logger.log( + `WEBVIEW NAVIGATING: ${mappingEventNameString()} \n Values: ${JSON.stringify( + syntheticEvent.nativeEvent, + )}`, + ); + + switch (eventName) { + case OnLoadProgress: + return onLoadProgress(syntheticEvent as WebViewProgressEvent); + case OnLoadEnd: + return onLoadEnd({ + event: syntheticEvent as + | WebViewNavigationEvent + | WebViewErrorEvent, + }); + case OnLoadStart: + return onLoadStart(syntheticEvent as WebViewNavigationEvent); + default: + return; + } + }, + [onLoadProgress, onLoadEnd, onLoadStart], + ); + + const { OnLoadEnd, OnLoadProgress, OnLoadStart } = WebViewNavigationEventName; + + const handleOnNavigationStateChange = useCallback( + (event: WebViewNavigation) => { + const { + title: titleFromNativeEvent, + canGoForward, + canGoBack, + navigationType, + url, + } = event; + Logger.log( + `WEBVIEW NAVIGATING: OnNavigationStateChange \n Values: ${JSON.stringify( + event, + )}`, + ); + // Handles force resolves url when going back since the behavior slightly differs that results in onLoadEnd not being called + if (navigationType === 'backforward') { + const payload = { + nativeEvent: { + url, + title: titleFromNativeEvent, + canGoBack, + canGoForward, + }, + }; + onLoadEnd({ + event: payload as WebViewNavigationEvent | WebViewErrorEvent, + forceResolve: true, + }); + } + }, + [onLoadEnd], + ); + + // Don't render webview unless ready to load. This should save on performance for initial app start. + if (!isWebViewReadyToLoad.current) return null; + + /** + * Main render + */ + return ( + + + + + {renderProgressBar()} + + {!!entryScriptWeb3 && firstUrlLoaded && ( + <> + ( + + )} + source={{ + uri: allowedInitialUrl, + ...(isExternalLink ? { headers: { Cookie: '' } } : null), + }} + injectedJavaScriptBeforeContentLoaded={entryScriptWeb3} + style={styles.webview} + onLoadStart={handleWebviewNavigationChange(OnLoadStart)} + onLoadEnd={handleWebviewNavigationChange(OnLoadEnd)} + onLoadProgress={handleWebviewNavigationChange(OnLoadProgress)} + onNavigationStateChange={handleOnNavigationStateChange} + onMessage={onMessage} + onShouldStartLoadWithRequest={onShouldStartLoadWithRequest} + allowsInlineMediaPlayback + testID={BrowserViewSelectorsIDs.BROWSER_WEBVIEW_ID} + applicationNameForUserAgent={'WebView MetaMaskMobile'} + onFileDownload={handleOnFileDownload} + webviewDebuggingEnabled={isTest} + /> + {ipfsBannerVisible && ( + + )} + + )} + + + + {isTabActive && ( + + )} + {isTabActive && showOptions && ( + + )} + + {renderBottomBar()} + {isTabActive && renderOnboardingWizard()} + + + ); +}; + +const mapStateToProps = (state: RootState) => ({ + bookmarks: state.bookmarks, + ipfsGateway: selectIpfsGateway(state), + selectedAddress: + selectSelectedInternalAccountFormattedAddress(state)?.toLowerCase(), + isIpfsGatewayEnabled: selectIsIpfsGatewayEnabled(state), + wizardStep: state.wizard.step, + activeChainId: selectChainId(state), +}); + +const mapDispatchToProps = (dispatch: Dispatch) => ({ + addToBrowserHistory: ({ url, name }: { name: string; url: string }) => + dispatch(addToHistory({ url, name })), + addToWhitelist: (url: string) => dispatch(addToWhitelist(url)), +}); + +export default connect(mapStateToProps, mapDispatchToProps)(BrowserTab); diff --git a/app/components/Views/BrowserTab/__snapshots__/index.test.tsx.snap b/app/components/Views/BrowserTab/__snapshots__/index.test.tsx.snap index de34aecf059..69486b54986 100644 --- a/app/components/Views/BrowserTab/__snapshots__/index.test.tsx.snap +++ b/app/components/Views/BrowserTab/__snapshots__/index.test.tsx.snap @@ -1,35 +1,3 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Browser should render correctly 1`] = ` - - - -`; +exports[`BrowserTab should render correctly 1`] = `null`; diff --git a/app/components/Views/BrowserTab/components/IpfsBanner/__snapshots__/index.test.tsx.snap b/app/components/Views/BrowserTab/components/IpfsBanner/__snapshots__/index.test.tsx.snap new file mode 100644 index 00000000000..a64feef4758 --- /dev/null +++ b/app/components/Views/BrowserTab/components/IpfsBanner/__snapshots__/index.test.tsx.snap @@ -0,0 +1,200 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`IpfsBanner should render banner correctly 1`] = ` + + + + + + + + IPFS gateway + + + This is an IPFS website. to see this site, you need to turn on + + + IPFS gateway + + + in + + + Settings. + + + + + Turn on IPFS gateway + + + + + + + + + + +`; diff --git a/app/components/Views/BrowserTab/components/IpfsBanner/index.test.tsx b/app/components/Views/BrowserTab/components/IpfsBanner/index.test.tsx new file mode 100644 index 00000000000..76b051a26b0 --- /dev/null +++ b/app/components/Views/BrowserTab/components/IpfsBanner/index.test.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import IpfsBanner from '.'; +import { useNavigation } from '@react-navigation/native'; +import Routes from '../../../../../constants/navigation/Routes'; +import { TESTID_BANNER_CLOSE_BUTTON_ICON } from '../../../../../component-library/components/Banners/Banner/foundation/BannerBase/BannerBase.constants'; + +jest.mock('@react-navigation/native', () => ({ + useNavigation: jest.fn(), +})); + +describe('IpfsBanner', () => { + const mockSetIpfsBannerVisible = jest.fn(); + const mockNavigate = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + (useNavigation as jest.Mock).mockReturnValue({ navigate: mockNavigate }); + }); + + it('should render banner correctly', () => { + const { toJSON } = render( + , + ); + expect(toJSON()).toMatchSnapshot(); + }); + + it('should call setIpfsBannerVisible with false when banner is closed', () => { + const { getByTestId } = render( + , + ); + + const closeButton = getByTestId(TESTID_BANNER_CLOSE_BUTTON_ICON); + fireEvent.press(closeButton); + + expect(mockSetIpfsBannerVisible).toHaveBeenCalledWith(false); + }); + + it('should navigate to IPFS settings when action button is pressed', () => { + const { getByText } = render( + , + ); + + const actionButton = getByText('Turn on IPFS gateway'); + fireEvent.press(actionButton); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.MODAL.ROOT_MODAL_FLOW, { + screen: Routes.SHEET.SHOW_IPFS, + params: { + setIpfsBannerVisible: expect.any(Function), + }, + }); + }); +}); diff --git a/app/components/Views/BrowserTab/components/IpfsBanner/index.tsx b/app/components/Views/BrowserTab/components/IpfsBanner/index.tsx new file mode 100644 index 00000000000..47f0c19825d --- /dev/null +++ b/app/components/Views/BrowserTab/components/IpfsBanner/index.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import { useNavigation } from '@react-navigation/native'; +import { useStyles } from '../../../../hooks/useStyles'; +import styleSheet from './styles'; +import { View } from 'react-native'; +import Banner, { + BannerAlertSeverity, + BannerVariant, +} from '../../../../../component-library/components/Banners/Banner'; +import Text, { + TextVariant, +} from '../../../../../component-library/components/Texts/Text'; +import { strings } from '../../../../../../locales/i18n'; +import { ButtonVariants } from '../../../../../component-library/components/Buttons/Button'; +import Routes from '../../../../../constants/navigation/Routes'; + +const IpfsBanner = ({ + setIpfsBannerVisible, +}: { + setIpfsBannerVisible: (isVisible: boolean) => void; +}) => { + const { styles } = useStyles(styleSheet, {}); + const navigation = useNavigation(); + return ( + + + {strings('ipfs_gateway_banner.ipfs_gateway_banner_content1')}{' '} + + {strings('ipfs_gateway_banner.ipfs_gateway_banner_content2')} + {' '} + {strings('ipfs_gateway_banner.ipfs_gateway_banner_content3')}{' '} + + {strings('ipfs_gateway_banner.ipfs_gateway_banner_content4')} + + + } + actionButtonProps={{ + variant: ButtonVariants.Link, + onPress: () => + navigation.navigate(Routes.MODAL.ROOT_MODAL_FLOW, { + screen: Routes.SHEET.SHOW_IPFS, + params: { + setIpfsBannerVisible: () => setIpfsBannerVisible(false), + }, + }), + label: 'Turn on IPFS gateway', + }} + variant={BannerVariant.Alert} + severity={BannerAlertSeverity.Info} + onClose={() => setIpfsBannerVisible(false)} + /> + + ); +}; + +export default IpfsBanner; diff --git a/app/components/Views/BrowserTab/components/IpfsBanner/styles.ts b/app/components/Views/BrowserTab/components/IpfsBanner/styles.ts new file mode 100644 index 00000000000..31e450849c8 --- /dev/null +++ b/app/components/Views/BrowserTab/components/IpfsBanner/styles.ts @@ -0,0 +1,16 @@ +import { StyleSheet } from 'react-native'; +import { Theme } from '@metamask/design-tokens'; + +const styleSheet = ({ theme: { colors } }: { theme: Theme }) => + StyleSheet.create({ + bannerContainer: { + backgroundColor: colors.background.default, + position: 'absolute', + bottom: 16, + left: 16, + right: 16, + borderRadius: 4, + }, + }); + +export default styleSheet; diff --git a/app/components/Views/BrowserTab/components/Options/__snapshots__/index.test.tsx.snap b/app/components/Views/BrowserTab/components/Options/__snapshots__/index.test.tsx.snap new file mode 100644 index 00000000000..d80ec6138e0 --- /dev/null +++ b/app/components/Views/BrowserTab/components/Options/__snapshots__/index.test.tsx.snap @@ -0,0 +1,809 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Options should render homepage options correctly 1`] = ` + + + + + +  + + + + New tab + + + + + +  + + + + Go to Favorites + + + + +`; + +exports[`Options should render non-homepage options correctly 1`] = ` + + + + + +  + + + + New tab + + + + + +  + + + + Reload + + + + + +  + + + + Add to Favorites + + + + + +  + + + + Go to Favorites + + + + + +  + + + + Share + + + + + +  + + + + Open in browser + + + + +`; diff --git a/app/components/Views/BrowserTab/components/Options/index.test.tsx b/app/components/Views/BrowserTab/components/Options/index.test.tsx new file mode 100644 index 00000000000..d5b71085a9e --- /dev/null +++ b/app/components/Views/BrowserTab/components/Options/index.test.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import Options from '.'; +import { render } from '@testing-library/react-native'; +import { useNavigation } from '@react-navigation/native'; +import { useMetrics } from '../../../../hooks/useMetrics'; +import { useSelector } from 'react-redux'; + +jest.mock('@react-navigation/native', () => ({ + useNavigation: jest.fn(), +})); + +jest.mock('../../../../hooks/useMetrics', () => ({ + useMetrics: jest.fn(), +})); + +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), + useDispatch: () => jest.fn(), +})); + +describe('Options', () => { + const mockProps = { + toggleOptions: jest.fn(), + onNewTabPress: jest.fn(), + toggleOptionsIfNeeded: jest.fn(), + activeUrl: 'https://test.com', + isHomepage: jest.fn(() => false), + getMaskedUrl: jest.fn(), + onSubmitEditing: jest.fn(), + title: { current: 'Test Title' }, + reload: jest.fn(), + sessionENSNames: {}, + favicon: { uri: 'test-favicon' }, + icon: { current: undefined }, + }; + + beforeEach(() => { + (useNavigation as jest.Mock).mockReturnValue({ push: jest.fn() }); + (useMetrics as jest.Mock).mockReturnValue({ + trackEvent: jest.fn(), + createEventBuilder: jest.fn().mockReturnValue({ + build: jest.fn(), + addProperties: jest.fn().mockReturnValue({ build: jest.fn() }), + }), + }); + (useSelector as jest.Mock).mockReturnValue([]); + }); + + it('should render non-homepage options correctly', () => { + const { toJSON } = render(); + expect(toJSON()).toMatchSnapshot(); + }); + + it('should render homepage options correctly', () => { + const homepageProps = { + ...mockProps, + isHomepage: jest.fn(() => true), + }; + + const { toJSON } = render(); + expect(toJSON()).toMatchSnapshot(); + }); +}); diff --git a/app/components/Views/BrowserTab/components/Options/index.tsx b/app/components/Views/BrowserTab/components/Options/index.tsx new file mode 100644 index 00000000000..3a30a7c5feb --- /dev/null +++ b/app/components/Views/BrowserTab/components/Options/index.tsx @@ -0,0 +1,339 @@ +import React, { MutableRefObject, useCallback } from 'react'; +import { + Linking, + Platform, + Text, + TouchableWithoutFeedback, + View, + ImageSourcePropType, +} from 'react-native'; +import MaterialCommunityIcons from 'react-native-vector-icons/MaterialCommunityIcons'; +import generateTestId from '../../../../../../wdio/utils/generateTestId'; +import Device from '../../../../../util/device'; +import { useStyles } from '../../../../hooks/useStyles'; +import styleSheet from './styles'; +import Button from '../../../../UI/Button'; +import { strings } from '../../../../../../locales/i18n'; +import { + ADD_FAVORITES_OPTION, + MENU_ID, + NEW_TAB_OPTION, + OPEN_FAVORITES_OPTION, + OPEN_IN_BROWSER_OPTION, + RELOAD_OPTION, + SHARE_OPTION, +} from '../../../../../../wdio/screen-objects/testIDs/BrowserScreen/OptionMenu.testIds'; +import Icon from 'react-native-vector-icons/FontAwesome'; +import { MetaMetricsEvents, useMetrics } from '../../../../hooks/useMetrics'; +import Logger from '../../../../../util/Logger'; +import { OLD_HOMEPAGE_URL_HOST } from '../../constants'; +import { StackNavigationProp } from '@react-navigation/stack'; +import { useNavigation } from '@react-navigation/native'; +import { SessionENSNames } from '../../types'; +import { useDispatch, useSelector } from 'react-redux'; +import SearchApi from '@metamask/react-native-search-api'; +import Share from 'react-native-share'; + +import { addBookmark } from '../../../../../actions/bookmarks'; +import { RootState } from '../../../../../reducers'; + +interface OptionsProps { + toggleOptions: () => void; + onNewTabPress: () => void; + toggleOptionsIfNeeded: () => void; + activeUrl: string; + isHomepage: () => boolean; + getMaskedUrl: (urlToMask: string, sessionENSNames: SessionENSNames) => string; + onSubmitEditing: (url: string) => void; + title: MutableRefObject; + reload: () => void; + sessionENSNames: SessionENSNames; + favicon: ImageSourcePropType; + icon: MutableRefObject; +} + +/** + * Render the options menu of the browser tab + */ +const Options = ({ + toggleOptions, + onNewTabPress, + toggleOptionsIfNeeded, + activeUrl, + isHomepage, + getMaskedUrl, + onSubmitEditing, + title, + reload, + sessionENSNames, + favicon, + icon, +}: OptionsProps) => { + // This any can be removed when react navigation is bumped to v6 - issue https://github.com/react-navigation/react-navigation/issues/9037#issuecomment-735698288 + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const navigation = useNavigation>(); + const { styles } = useStyles(styleSheet, {}); + const { trackEvent, createEventBuilder } = useMetrics(); + const dispatch = useDispatch(); + const bookmarks = useSelector((state: RootState) => state.bookmarks); + + /** + /** + * Open external link + */ + const openInBrowser = () => { + toggleOptionsIfNeeded(); + Linking.openURL(activeUrl).catch((openInBrowserError) => + Logger.log( + `Error while trying to open external link: ${activeUrl}`, + openInBrowserError, + ), + ); + trackEvent( + createEventBuilder(MetaMetricsEvents.DAPP_OPEN_IN_BROWSER).build(), + ); + }; + /** + * Go to favorites page + */ + const goToFavorites = async () => { + toggleOptionsIfNeeded(); + onSubmitEditing(OLD_HOMEPAGE_URL_HOST); + trackEvent( + createEventBuilder(MetaMetricsEvents.DAPP_GO_TO_FAVORITES).build(), + ); + }; + + /** + * Track add site to favorites event + */ + const trackAddToFavoritesEvent = () => { + trackEvent( + createEventBuilder(MetaMetricsEvents.BROWSER_ADD_FAVORITES) + .addProperties({ + dapp_name: title.current || '', + }) + .build(), + ); + }; + + /** + * Add bookmark + */ + const navigateToAddBookmark = () => { + toggleOptionsIfNeeded(); + navigation.push('AddBookmarkView', { + screen: 'AddBookmark', + params: { + title: title.current || '', + url: getMaskedUrl(activeUrl, sessionENSNames), + onAddBookmark: async ({ + name, + url: urlToAdd, + }: { + name: string; + url: string; + }) => { + dispatch(addBookmark({ name, url: urlToAdd })); + if (Device.isIos()) { + const item = { + uniqueIdentifier: activeUrl, + title: name || getMaskedUrl(urlToAdd, sessionENSNames), + contentDescription: `Launch ${name || urlToAdd} on MetaMask`, + keywords: [name.split(' '), urlToAdd, 'dapp'], + thumbnail: { + uri: icon.current || favicon, + }, + }; + try { + SearchApi.indexSpotlightItem(item); + } catch (e: unknown) { + const searchApiError = e as Error; + Logger.error(searchApiError, 'Error adding to spotlight'); + } + } + }, + }, + }); + trackAddToFavoritesEvent(); + trackEvent( + createEventBuilder(MetaMetricsEvents.DAPP_ADD_TO_FAVORITE).build(), + ); + }; + + /** + * Renders Go to Favorites option + */ + const renderGoToFavorites = () => ( + + ); + + /** + * Handles reload button press + */ + const onReloadPress = useCallback(() => { + toggleOptionsIfNeeded(); + reload(); + trackEvent(createEventBuilder(MetaMetricsEvents.BROWSER_RELOAD).build()); + }, [reload, toggleOptionsIfNeeded, trackEvent, createEventBuilder]); + + const isBookmark = () => { + const maskedUrl = getMaskedUrl(activeUrl, sessionENSNames); + return bookmarks.some( + ({ url: bookmark }: { url: string }) => bookmark === maskedUrl, + ); + }; + + /** + * Track share site event + */ + const trackShareEvent = useCallback(() => { + trackEvent( + createEventBuilder(MetaMetricsEvents.BROWSER_SHARE_SITE).build(), + ); + }, [trackEvent, createEventBuilder]); + + /** + * Share url + */ + const share = useCallback(() => { + toggleOptionsIfNeeded(); + Share.open({ + url: activeUrl, + }).catch((err) => { + Logger.log('Error while trying to share address', err); + }); + trackShareEvent(); + }, [activeUrl, toggleOptionsIfNeeded, trackShareEvent]); + + /** + * Render share option + */ + const renderShareOption = useCallback( + () => + activeUrl ? ( + + ) : null, + [activeUrl, share, styles], + ); + + /** + * Render reload option + */ + const renderReloadOption = useCallback( + () => + activeUrl ? ( + + ) : null, + [activeUrl, onReloadPress, styles], + ); + + /** + * Render non-homepage options menu + */ + const renderNonHomeOptions = () => { + if (isHomepage()) return renderGoToFavorites(); + return ( + + {renderReloadOption()} + {!isBookmark() && ( + + )} + {renderGoToFavorites()} + {renderShareOption()} + + + ); + }; + + return ( + + + + + {renderNonHomeOptions()} + + + + ); +}; + +export default Options; diff --git a/app/components/Views/BrowserTab/components/Options/styles.ts b/app/components/Views/BrowserTab/components/Options/styles.ts new file mode 100644 index 00000000000..b9ed926e368 --- /dev/null +++ b/app/components/Views/BrowserTab/components/Options/styles.ts @@ -0,0 +1,74 @@ +import { StyleSheet } from 'react-native'; +import { Theme } from '@metamask/design-tokens'; +import { fontStyles } from '../../../../../styles/common'; +import Device from '../../../../../util/device'; + +const styleSheet = ({ theme: { colors, shadows } }: { theme: Theme }) => + StyleSheet.create({ + optionsOverlay: { + position: 'absolute', + zIndex: 99999998, + top: 0, + bottom: 0, + left: 0, + right: 0, + }, + optionsWrapper: { + position: 'absolute', + zIndex: 99999999, + width: 200, + borderWidth: 1, + borderColor: colors.border.default, + backgroundColor: colors.background.default, + borderRadius: 10, + paddingBottom: 5, + paddingTop: 10, + }, + optionsWrapperAndroid: { + ...shadows.size.xs, + bottom: 65, + right: 5, + }, + optionsWrapperIos: { + ...shadows.size.xs, + bottom: 90, + right: 5, + }, + option: { + paddingVertical: 10, + height: 'auto', + minHeight: 44, + paddingHorizontal: 15, + backgroundColor: colors.background.default, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'flex-start', + marginTop: Device.isAndroid() ? 0 : -5, + }, + optionText: { + fontSize: 16, + lineHeight: 16, + alignSelf: 'center', + justifyContent: 'center', + marginTop: 3, + color: colors.primary.default, + flex: 1, + ...fontStyles.fontPrimary, + }, + optionIconWrapper: { + flex: 0, + borderRadius: 5, + backgroundColor: colors.primary.muted, + padding: 3, + marginRight: 10, + alignSelf: 'center', + }, + optionIcon: { + color: colors.primary.default, + textAlign: 'center', + alignSelf: 'center', + fontSize: 18, + }, + }); + +export default styleSheet; diff --git a/app/components/Views/BrowserTab/components/PhishingModal/__snapshots__/index.test.tsx.snap b/app/components/Views/BrowserTab/components/PhishingModal/__snapshots__/index.test.tsx.snap new file mode 100644 index 00000000000..47df81ec059 --- /dev/null +++ b/app/components/Views/BrowserTab/components/PhishingModal/__snapshots__/index.test.tsx.snap @@ -0,0 +1,390 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PhishingModal should match snapshot when showPhishingModal is false 1`] = `null`; + +exports[`PhishingModal should match snapshot when showPhishingModal is true 1`] = ` + + + + + + + + +  + + + Ethereum Phishing Detection + + + + + + + phishing.com + + is currently on the MetaMask domain warning list. This means that based on information available to us, MetaMask believes this domain could currently compromise your security and, as an added safety feature, MetaMask has restricted access to the site. To override this, please read the rest of this warning for instructions on how to continue at your own risk. + + + There are many reasons sites can appear on our warning list, and our warning list compiles from other widely used industry lists. Such reasons can include known fraud or security risks, such as domains that test positive on the + + Ethereum Phishing Detector + + . + Domains on these warning lists may include outright malicious websites and legitimate websites that have been compromised by a malicious actor. + + + To read more about this site + + please review the domain on Etherscam. + + + + Note that this warning list is compiled on a voluntary basis. This list may be inaccurate or incomplete. Just because a domain does not appear on this list is not an implicit guarantee of that domain's safety. As always, your transactions are your own responsibility. If you wish to interact with any domain on our warning list, you can do so by + + continuing at your own risk. + + + + There are many reasons sites can appear on our warning list, and our warning list compiles from other widely used industry lists. Such reasons can include known fraud or security risks, such as domains that test positive on the + + please file an issue. + + + + + + + + + Back to safety + + + + + + +`; diff --git a/app/components/Views/BrowserTab/components/PhishingModal/index.test.tsx b/app/components/Views/BrowserTab/components/PhishingModal/index.test.tsx new file mode 100644 index 00000000000..3ac9468f121 --- /dev/null +++ b/app/components/Views/BrowserTab/components/PhishingModal/index.test.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import PhishingModal from '.'; +import { useNavigation } from '@react-navigation/native'; +import { render } from '@testing-library/react-native'; +import { ThemeContext, mockTheme } from '../../../../../util/theme'; + +jest.mock('@react-navigation/native', () => ({ + useNavigation: jest.fn(() => ({ + goBack: jest.fn(), + })), +})); + +describe('PhishingModal', () => { + beforeEach(() => { + (useNavigation as jest.Mock).mockReturnValue({ goBack: jest.fn() }); + }); + + it('should match snapshot when showPhishingModal is false', () => { + const { toJSON } = render( + + + , + ); + expect(toJSON()).toMatchSnapshot(); + }); + + it('should match snapshot when showPhishingModal is true', () => { + const { toJSON } = render( + + + , + ); + expect(toJSON()).toMatchSnapshot(); + }); +}); diff --git a/app/components/Views/BrowserTab/components/PhishingModal/index.tsx b/app/components/Views/BrowserTab/components/PhishingModal/index.tsx new file mode 100644 index 00000000000..a3e50e4a9ba --- /dev/null +++ b/app/components/Views/BrowserTab/components/PhishingModal/index.tsx @@ -0,0 +1,123 @@ +import React from 'react'; +import PhishingModalUI from '../../../../UI/PhishingModal'; +import URLParse from 'url-parse'; +import { + MM_PHISH_DETECT_URL, + PHISHFORT_BLOCKLIST_ISSUE_URL, + MM_ETHERSCAN_URL, + MM_BLOCKLIST_ISSUE_URL, +} from '../../../../../constants/urls'; +import Modal from 'react-native-modal'; +import { useStyles } from '../../../../../component-library/hooks'; +import styleSheet from './styles'; +import { BrowserUrlBarRef } from '../../../../UI/BrowserUrlBar/BrowserUrlBar.types'; + +interface PhishingModalProps { + blockedUrl?: string; + showPhishingModal: boolean; + setShowPhishingModal: (show: boolean) => void; + setBlockedUrl: (url: string | undefined) => void; + urlBarRef: React.RefObject; + addToWhitelist: (hostname: string) => void; + activeUrl: string; + blockListType: React.MutableRefObject; + goToUrl: (url: string) => void; +} + +const PhishingModal = ({ + blockedUrl, + showPhishingModal, + setShowPhishingModal, + setBlockedUrl, + urlBarRef, + addToWhitelist, + activeUrl, + blockListType, + goToUrl, +}: PhishingModalProps) => { + const { + styles, + theme: { colors }, + } = useStyles(styleSheet, {}); + /** + * Go to eth-phishing-detect page + */ + const goToETHPhishingDetector = () => { + setShowPhishingModal(false); + goToUrl(MM_PHISH_DETECT_URL); + }; + + /** + * Continue to phishing website + */ + const continueToPhishingSite = () => { + if (!blockedUrl) return; + const { origin: urlOrigin } = new URLParse(blockedUrl); + + addToWhitelist(urlOrigin); + setShowPhishingModal(false); + + blockedUrl !== activeUrl && + setTimeout(() => { + goToUrl(blockedUrl); + setBlockedUrl(undefined); + }, 1000); + }; + + /** + * Go to etherscam websiter + */ + const goToEtherscam = () => { + setShowPhishingModal(false); + goToUrl(MM_ETHERSCAN_URL); + }; + + /** + * Go to eth-phishing-detect issue + */ + const goToFilePhishingIssue = () => { + setShowPhishingModal(false); + blockListType.current === 'MetaMask' + ? goToUrl(MM_BLOCKLIST_ISSUE_URL) + : goToUrl(PHISHFORT_BLOCKLIST_ISSUE_URL); + }; + + /** + * Go back from phishing website alert + */ + const goBackToSafety = () => { + urlBarRef.current?.setNativeProps({ text: activeUrl }); + + setTimeout(() => { + setShowPhishingModal(false); + setBlockedUrl(undefined); + }, 500); + }; + + if (!showPhishingModal) return null; + + return ( + + + + ); +}; + +export default PhishingModal; diff --git a/app/components/Views/BrowserTab/components/PhishingModal/styles.ts b/app/components/Views/BrowserTab/components/PhishingModal/styles.ts new file mode 100644 index 00000000000..d1a1a17f907 --- /dev/null +++ b/app/components/Views/BrowserTab/components/PhishingModal/styles.ts @@ -0,0 +1,10 @@ +import { StyleSheet } from 'react-native'; + +const styleSheet = () => + StyleSheet.create({ + fullScreenModal: { + flex: 1, + }, + }); + +export default styleSheet; diff --git a/app/components/Views/BrowserTab/constants.ts b/app/components/Views/BrowserTab/constants.ts index c44661f38c5..225514ad053 100644 --- a/app/components/Views/BrowserTab/constants.ts +++ b/app/components/Views/BrowserTab/constants.ts @@ -1,2 +1,8 @@ +import AppConstants from '../../../core/AppConstants'; + export const IPFS_GATEWAY_DISABLED_ERROR = 'IPFS gateway is disabled on security and privacy settings'; +export const { HOMEPAGE_URL, NOTIFICATION_NAMES, OLD_HOMEPAGE_URL_HOST } = + AppConstants; +export const HOMEPAGE_HOST = new URL(HOMEPAGE_URL)?.hostname; +export const MM_MIXPANEL_TOKEN = process.env.MM_MIXPANEL_TOKEN; diff --git a/app/components/Views/BrowserTab/index.js b/app/components/Views/BrowserTab/index.js deleted file mode 100644 index b1a1afd93c2..00000000000 --- a/app/components/Views/BrowserTab/index.js +++ /dev/null @@ -1,1840 +0,0 @@ -import React, { - useState, - useRef, - useEffect, - useCallback, - useMemo, -} from 'react'; -import { - Text, - StyleSheet, - View, - TouchableWithoutFeedback, - Alert, - Linking, - BackHandler, - Platform, -} from 'react-native'; -import { isEqual } from 'lodash'; -import { withNavigation } from '@react-navigation/compat'; -import { WebView } from '@metamask/react-native-webview'; -import Icon from 'react-native-vector-icons/FontAwesome'; -import MaterialCommunityIcon from 'react-native-vector-icons/MaterialCommunityIcons'; -import BrowserBottomBar from '../../UI/BrowserBottomBar'; -import PropTypes from 'prop-types'; -import Share from 'react-native-share'; -import { connect, useSelector } from 'react-redux'; -import BackgroundBridge from '../../../core/BackgroundBridge/BackgroundBridge'; -import Engine from '../../../core/Engine'; -import PhishingModal from '../../UI/PhishingModal'; -import WebviewProgressBar from '../../UI/WebviewProgressBar'; -import { baseStyles, fontStyles } from '../../../styles/common'; -import Logger from '../../../util/Logger'; -import onUrlSubmit, { - prefixUrlWithProtocol, - isTLD, - protocolAllowList, - trustedProtocolToDeeplink, - getAlertMessage, - allowLinkOpen, - getUrlObj, -} from '../../../util/browser'; -import { - SPA_urlChangeListener, - JS_DESELECT_TEXT, -} from '../../../util/browserScripts'; -import resolveEnsToIpfsContentId from '../../../lib/ens-ipfs/resolver'; -import Button from '../../UI/Button'; -import { strings } from '../../../../locales/i18n'; -import URL from 'url-parse'; -import Modal from 'react-native-modal'; -import WebviewError from '../../UI/WebviewError'; -import { addBookmark } from '../../../actions/bookmarks'; -import { addToHistory, addToWhitelist } from '../../../actions/browser'; -import Device from '../../../util/device'; -import AppConstants from '../../../core/AppConstants'; -import SearchApi from '@metamask/react-native-search-api'; -import { MetaMetricsEvents } from '../../../core/Analytics'; -import setOnboardingWizardStep from '../../../actions/wizard'; -import OnboardingWizard from '../../UI/OnboardingWizard'; -import DrawerStatusTracker from '../../../core/DrawerStatusTracker'; -import EntryScriptWeb3 from '../../../core/EntryScriptWeb3'; -import ErrorBoundary from '../ErrorBoundary'; - -import { getRpcMethodMiddleware } from '../../../core/RPCMethods/RPCMethodMiddleware'; -import { useTheme } from '../../../util/theme'; -import downloadFile from '../../../util/browser/downloadFile'; -import { createBrowserUrlModalNavDetails } from '../BrowserUrlModal/BrowserUrlModal'; -import { - MM_PHISH_DETECT_URL, - MM_BLOCKLIST_ISSUE_URL, - PHISHFORT_BLOCKLIST_ISSUE_URL, - MM_ETHERSCAN_URL, -} from '../../../constants/urls'; -import { - MAX_MESSAGE_LENGTH, -} from '../../../constants/dapp'; -import sanitizeUrlInput from '../../../util/url/sanitizeUrlInput'; -import { - getPermittedAccounts, - getPermittedAccountsByHostname, -} from '../../../core/Permissions'; -import Routes from '../../../constants/navigation/Routes'; -import generateTestId from '../../../../wdio/utils/generateTestId'; -import { - ADD_FAVORITES_OPTION, - OPEN_FAVORITES_OPTION, - MENU_ID, - NEW_TAB_OPTION, - OPEN_IN_BROWSER_OPTION, - RELOAD_OPTION, - SHARE_OPTION, -} from '../../../../wdio/screen-objects/testIDs/BrowserScreen/OptionMenu.testIds'; -import { - selectIpfsGateway, - selectIsIpfsGatewayEnabled, -} from '../../../selectors/preferencesController'; -import { selectSelectedInternalAccountFormattedAddress } from '../../../selectors/accountsController'; -import useFavicon from '../../hooks/useFavicon/useFavicon'; -import { IPFS_GATEWAY_DISABLED_ERROR } from './constants'; -import Banner from '../../../component-library/components/Banners/Banner/Banner'; -import { - BannerAlertSeverity, - BannerVariant, -} from '../../../component-library/components/Banners/Banner'; -import { ButtonVariants } from '../../../component-library/components/Buttons/Button'; -import CLText from '../../../component-library/components/Texts/Text/Text'; -import { TextVariant } from '../../../component-library/components/Texts/Text'; -import { regex } from '../../../../app/util/regex'; -import { selectChainId } from '../../../selectors/networkController'; -import { BrowserViewSelectorsIDs } from '../../../../e2e/selectors/Browser/BrowserView.selectors'; -import { useMetrics } from '../../../components/hooks/useMetrics'; -import { trackDappViewedEvent } from '../../../util/metrics'; -import trackErrorAsAnalytics from '../../../util/metrics/TrackError/trackErrorAsAnalytics'; -import { selectPermissionControllerState } from '../../../selectors/snaps/permissionController'; -import { isTest } from '../../../util/test/utils.js'; -import { EXTERNAL_LINK_TYPE } from '../../../constants/browser'; -import { PermissionKeys } from '../../../core/Permissions/specifications'; -import { CaveatTypes } from '../../../core/Permissions/constants'; -import { AccountPermissionsScreens } from '../AccountPermissions/AccountPermissions.types'; -import { isMultichainVersion1Enabled } from '../../../util/networks'; -import { useIsFocused } from '@react-navigation/native'; - -const { HOMEPAGE_URL, NOTIFICATION_NAMES, OLD_HOMEPAGE_URL_HOST } = - AppConstants; -const HOMEPAGE_HOST = new URL(HOMEPAGE_URL)?.hostname; -const MM_MIXPANEL_TOKEN = process.env.MM_MIXPANEL_TOKEN; - -const createStyles = (colors, shadows) => - StyleSheet.create({ - wrapper: { - ...baseStyles.flexGrow, - backgroundColor: colors.background.default, - }, - hide: { - flex: 0, - opacity: 0, - display: 'none', - width: 0, - height: 0, - }, - progressBarWrapper: { - height: 3, - width: '100%', - left: 0, - right: 0, - top: 0, - position: 'absolute', - zIndex: 999999, - }, - optionsOverlay: { - position: 'absolute', - zIndex: 99999998, - top: 0, - bottom: 0, - left: 0, - right: 0, - }, - optionsWrapper: { - position: 'absolute', - zIndex: 99999999, - width: 200, - borderWidth: 1, - borderColor: colors.border.default, - backgroundColor: colors.background.default, - borderRadius: 10, - paddingBottom: 5, - paddingTop: 10, - }, - optionsWrapperAndroid: { - ...shadows.size.xs, - bottom: 65, - right: 5, - }, - optionsWrapperIos: { - ...shadows.size.xs, - bottom: 90, - right: 5, - }, - option: { - paddingVertical: 10, - height: 'auto', - minHeight: 44, - paddingHorizontal: 15, - backgroundColor: colors.background.default, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'flex-start', - marginTop: Device.isAndroid() ? 0 : -5, - }, - optionText: { - fontSize: 16, - lineHeight: 16, - alignSelf: 'center', - justifyContent: 'center', - marginTop: 3, - color: colors.primary.default, - flex: 1, - ...fontStyles.fontPrimary, - }, - optionIconWrapper: { - flex: 0, - borderRadius: 5, - backgroundColor: colors.primary.muted, - padding: 3, - marginRight: 10, - alignSelf: 'center', - }, - optionIcon: { - color: colors.primary.default, - textAlign: 'center', - alignSelf: 'center', - fontSize: 18, - }, - webview: { - ...baseStyles.flexGrow, - zIndex: 1, - }, - urlModalContent: { - flexDirection: 'row', - paddingTop: Device.isAndroid() ? 10 : Device.isIphoneX() ? 50 : 27, - paddingHorizontal: 10, - height: Device.isAndroid() ? 59 : Device.isIphoneX() ? 87 : 65, - backgroundColor: colors.background.default, - }, - searchWrapper: { - flexDirection: 'row', - borderRadius: 30, - backgroundColor: colors.background.alternative, - height: Device.isAndroid() ? 40 : 30, - flex: 1, - }, - clearButton: { paddingHorizontal: 12, justifyContent: 'center' }, - urlModal: { - justifyContent: 'flex-start', - margin: 0, - }, - urlInput: { - ...fontStyles.normal, - fontSize: Device.isAndroid() ? 16 : 14, - paddingLeft: 15, - flex: 1, - color: colors.text.default, - }, - cancelButton: { - marginTop: -6, - marginLeft: 10, - justifyContent: 'center', - }, - cancelButtonText: { - fontSize: 14, - color: colors.primary.default, - ...fontStyles.normal, - }, - bottomModal: { - justifyContent: 'flex-end', - margin: 0, - }, - fullScreenModal: { - flex: 1, - }, - bannerContainer: { - backgroundColor: colors.background.default, - position: 'absolute', - bottom: 16, - left: 16, - right: 16, - borderRadius: 4, - }, - }); - -const sessionENSNames = {}; -const ensIgnoreList = []; - -export const BrowserTab = (props) => { - const [backEnabled, setBackEnabled] = useState(false); - const [forwardEnabled, setForwardEnabled] = useState(false); - const [progress, setProgress] = useState(0); - const [initialUrl, setInitialUrl] = useState(''); - const [firstUrlLoaded, setFirstUrlLoaded] = useState(false); - const [error, setError] = useState(false); - const [showOptions, setShowOptions] = useState(false); - const [entryScriptWeb3, setEntryScriptWeb3] = useState(null); - const [showPhishingModal, setShowPhishingModal] = useState(false); - const [blockedUrl, setBlockedUrl] = useState(undefined); - const [ipfsBannerVisible, setIpfsBannerVisible] = useState(false); - const [isResolvedIpfsUrl, setIsResolvedIpfsUrl] = useState(false); - const webviewRef = useRef(null); - const blockListType = useRef(''); - const allowList = useRef([]); - - const url = useRef(''); - const title = useRef(''); - const icon = useRef(null); - const backgroundBridges = useRef([]); - const fromHomepage = useRef(false); - const wizardScrollAdjusted = useRef(false); - const permittedAccountsList = useSelector((state) => { - const permissionsControllerState = selectPermissionControllerState(state); - const hostname = new URL(url.current).hostname; - const permittedAcc = getPermittedAccountsByHostname( - permissionsControllerState, - hostname, - ); - return permittedAcc; - }, isEqual); - - const { colors, shadows } = useTheme(); - const styles = createStyles(colors, shadows); - const favicon = useFavicon(url.current); - const { trackEvent, isEnabled, getMetaMetricsId, createEventBuilder } = - useMetrics(); - /** - * Is the current tab the active tab - */ - const isTabActive = useSelector( - (state) => state.browser.activeTab === props.id, - ); - - const isFocused = useIsFocused(); - - /** - * Gets the url to be displayed to the user - * For example, if it's ens then show [site].eth instead of ipfs url - */ - const getMaskedUrl = (url) => { - if (!url) return url; - let replace = null; - if (url.startsWith(AppConstants.IPFS_DEFAULT_GATEWAY_URL)) { - replace = (key) => - `${AppConstants.IPFS_DEFAULT_GATEWAY_URL}${sessionENSNames[key].hash}/`; - } else if (url.startsWith(AppConstants.IPNS_DEFAULT_GATEWAY_URL)) { - replace = (key) => - `${AppConstants.IPNS_DEFAULT_GATEWAY_URL}${sessionENSNames[key].hostname}/`; - } else if (url.startsWith(AppConstants.SWARM_DEFAULT_GATEWAY_URL)) { - replace = (key) => - `${AppConstants.SWARM_GATEWAY_URL}${sessionENSNames[key].hash}/`; - } - - if (replace) { - const key = Object.keys(sessionENSNames).find((ens) => - url.startsWith(ens), - ); - if (key) { - url = url.replace( - replace(key), - `https://${sessionENSNames[key].hostname}/`, - ); - } - } - return url; - }; - - /** - * Checks if it is a ENS website - */ - const isENSUrl = (url) => { - const { hostname } = new URL(url); - const tld = hostname.split('.').pop(); - if (AppConstants.supportedTLDs.indexOf(tld.toLowerCase()) !== -1) { - // Make sure it's not in the ignore list - if (ensIgnoreList.indexOf(hostname) === -1) { - return true; - } - } - return false; - }; - - /** - * Checks if a given url or the current url is the homepage - */ - const isHomepage = useCallback((checkUrl = null) => { - const currentPage = checkUrl || url.current; - const prefixedUrl = prefixUrlWithProtocol(currentPage); - const { host: currentHost } = getUrlObj(prefixedUrl); - return ( - currentHost === HOMEPAGE_HOST || currentHost === OLD_HOMEPAGE_URL_HOST - ); - }, []); - - const notifyAllConnections = useCallback((payload, restricted = true) => { - const fullHostname = new URL(url.current).hostname; - - // TODO:permissions move permissioning logic elsewhere - backgroundBridges.current.forEach((bridge) => { - if (bridge.hostname === fullHostname) { - bridge.sendNotification(payload); - } - }); - }, []); - - /** - * Dismiss the text selection on the current website - */ - const dismissTextSelectionIfNeeded = useCallback(() => { - if (isTabActive && Device.isAndroid()) { - const { current } = webviewRef; - if (current) { - setTimeout(() => { - current.injectJavaScript(JS_DESELECT_TEXT); - }, 50); - } - } - }, [isTabActive]); - - /** - * Toggle the options menu - */ - const toggleOptions = useCallback(() => { - dismissTextSelectionIfNeeded(); - setShowOptions(!showOptions); - - trackEvent( - createEventBuilder(MetaMetricsEvents.DAPP_BROWSER_OPTIONS).build(), - ); - }, [ - dismissTextSelectionIfNeeded, - showOptions, - trackEvent, - createEventBuilder, - ]); - - /** - * Show the options menu - */ - const toggleOptionsIfNeeded = useCallback(() => { - if (showOptions) { - toggleOptions(); - } - }, [showOptions, toggleOptions]); - - /** - * Go back to previous website in history - */ - const goBack = useCallback(() => { - if (!backEnabled) return; - - toggleOptionsIfNeeded(); - const { current } = webviewRef; - current && current.goBack(); - }, [backEnabled, toggleOptionsIfNeeded]); - - /** - * Go forward to the next website in history - */ - const goForward = async () => { - if (!forwardEnabled) return; - - toggleOptionsIfNeeded(); - const { current } = webviewRef; - current && current.goForward && current.goForward(); - }; - - /** - * Check if an origin is allowed - */ - const isAllowedOrigin = useCallback((origin) => { - const { PhishingController } = Engine.context; - - // Update phishing configuration if it is out-of-date - // This is async but we are not `await`-ing it here intentionally, so that we don't slow - // down network requests. The configuration is updated for the next request. - PhishingController.maybeUpdateState(); - - const phishingControllerTestResult = PhishingController.test(origin); - - // Only assign the if the hostname is on the block list - if (phishingControllerTestResult.result) - blockListType.current = phishingControllerTestResult.name; - - return ( - (allowList.current && allowList.current.includes(origin)) || - !phishingControllerTestResult.result - ); - }, []); - - const isBookmark = () => { - const { bookmarks } = props; - const maskedUrl = getMaskedUrl(url.current); - return bookmarks.some(({ url: bookmark }) => bookmark === maskedUrl); - }; - - /** - * Show a phishing modal when a url is not allowed - */ - const handleNotAllowedUrl = (urlToGo) => { - setBlockedUrl(urlToGo); - setTimeout(() => setShowPhishingModal(true), 1000); - }; - - /** - * Get IPFS info from a ens url - */ - const handleIpfsContent = useCallback( - async (fullUrl, { hostname, pathname, query }) => { - const { provider } = - Engine.context.NetworkController.getProviderAndBlockTracker(); - let gatewayUrl; - try { - const { type, hash } = await resolveEnsToIpfsContentId({ - provider, - name: hostname, - chainId: props.chainId, - }); - if (type === 'ipfs-ns') { - gatewayUrl = `${props.ipfsGateway}${hash}${pathname || '/'}${ - query || '' - }`; - const response = await fetch(gatewayUrl); - const statusCode = response.status; - if (statusCode >= 400) { - Logger.log('Status code ', statusCode, gatewayUrl); - return null; - } - } else if (type === 'swarm-ns') { - gatewayUrl = `${AppConstants.SWARM_DEFAULT_GATEWAY_URL}${hash}${ - pathname || '/' - }${query || ''}`; - } else if (type === 'ipns-ns') { - gatewayUrl = `${AppConstants.IPNS_DEFAULT_GATEWAY_URL}${hostname}${ - pathname || '/' - }${query || ''}`; - } - return { - url: gatewayUrl, - hash, - type, - }; - } catch (err) { - //if it's not a ENS but a TLD (Top Level Domain) - if (isTLD(hostname, err)) { - ensIgnoreList.push(hostname); - return { url: fullUrl, reload: true }; - } - if ( - err?.message?.startsWith( - 'EnsIpfsResolver - no known ens-ipfs registry for chainId', - ) - ) { - trackErrorAsAnalytics( - 'Browser: Failed to resolve ENS name for chainId', - err?.message, - ); - } else { - Logger.error(err, 'Failed to resolve ENS name'); - } - - if (err?.message?.startsWith(IPFS_GATEWAY_DISABLED_ERROR)) { - setIpfsBannerVisible(true); - goBack(); - throw new Error(err?.message); - } else { - Alert.alert( - strings('browser.failed_to_resolve_ens_name'), - err.message, - ); - } - goBack(); - } - }, - [goBack, props.ipfsGateway, setIpfsBannerVisible, props.chainId], - ); - - const triggerDappViewedEvent = (url) => { - const permissionsControllerState = - Engine.context.PermissionController.state; - const hostname = new URL(url).hostname; - const connectedAccounts = getPermittedAccountsByHostname( - permissionsControllerState, - hostname, - ); - - // Check if there are any connected accounts - if (!connectedAccounts.length) { - return; - } - - // Track dapp viewed event - trackDappViewedEvent({ - hostname, - numberOfConnectedAccounts: connectedAccounts.length, - }); - }; - - /** - * Go to a url - */ - const go = useCallback( - async (url, initialCall) => { - setIsResolvedIpfsUrl(false); - const prefixedUrl = prefixUrlWithProtocol(url); - const { hostname, query, pathname, origin } = new URL(prefixedUrl); - let urlToGo = prefixedUrl; - const isEnsUrl = isENSUrl(url); - const { current } = webviewRef; - if (isEnsUrl) { - current && current.stopLoading(); - try { - const { - url: ensUrl, - type, - hash, - reload, - } = await handleIpfsContent(url, { hostname, query, pathname }); - if (reload) return go(ensUrl); - urlToGo = ensUrl; - sessionENSNames[urlToGo] = { hostname, hash, type }; - setIsResolvedIpfsUrl(true); - } catch (error) { - return null; - } - } - - if (isAllowedOrigin(origin)) { - if (initialCall || !firstUrlLoaded) { - setInitialUrl(urlToGo); - setFirstUrlLoaded(true); - } else { - current && - current.injectJavaScript( - `(function(){window.location.href = '${sanitizeUrlInput( - urlToGo, - )}' })()`, - ); - } - - // Skip tracking on initial open - if (!initialCall) { - triggerDappViewedEvent(urlToGo); - } - - setProgress(0); - return prefixedUrl; - } - handleNotAllowedUrl(urlToGo); - return null; - }, - [firstUrlLoaded, handleIpfsContent, isAllowedOrigin], - ); - - /** - * Open a new tab - */ - const openNewTab = useCallback( - (url) => { - toggleOptionsIfNeeded(); - dismissTextSelectionIfNeeded(); - props.newTab(url); - }, - [dismissTextSelectionIfNeeded, props, toggleOptionsIfNeeded], - ); - - /** - * Reload current page - */ - const reload = useCallback(() => { - const { current } = webviewRef; - - current && current.reload(); - triggerDappViewedEvent(url.current); - }, []); - - /** - * Handle when the drawer (app menu) is opened - */ - const drawerOpenHandler = useCallback(() => { - dismissTextSelectionIfNeeded(); - }, [dismissTextSelectionIfNeeded]); - - /** - * Set initial url, dapp scripts and engine. Similar to componentDidMount - */ - useEffect(() => { - const initialUrl = props.initialUrl || HOMEPAGE_URL; - go(initialUrl, true); - - const getEntryScriptWeb3 = async () => { - const entryScriptWeb3 = await EntryScriptWeb3.get(); - setEntryScriptWeb3(entryScriptWeb3 + SPA_urlChangeListener); - }; - - getEntryScriptWeb3(); - - // Specify how to clean up after this effect: - return function cleanup() { - backgroundBridges.current.forEach((bridge) => bridge.onDisconnect()); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - useEffect(() => { - if (Device.isAndroid()) { - DrawerStatusTracker.hub.on('drawer::open', drawerOpenHandler); - } - - return function cleanup() { - if (Device.isAndroid()) { - DrawerStatusTracker && - DrawerStatusTracker.hub && - DrawerStatusTracker.hub.removeListener( - 'drawer::open', - drawerOpenHandler, - ); - } - }; - }, [drawerOpenHandler]); - - /** - * Set navigation listeners - */ - useEffect(() => { - const handleAndroidBackPress = () => { - if (!isTabActive) return false; - goBack(); - return true; - }; - - BackHandler.addEventListener('hardwareBackPress', handleAndroidBackPress); - - // Handle hardwareBackPress event only for browser, not components rendered on top - props.navigation.addListener('willFocus', () => { - BackHandler.addEventListener('hardwareBackPress', handleAndroidBackPress); - }); - props.navigation.addListener('willBlur', () => { - BackHandler.removeEventListener( - 'hardwareBackPress', - handleAndroidBackPress, - ); - }); - - return function cleanup() { - BackHandler.removeEventListener( - 'hardwareBackPress', - handleAndroidBackPress, - ); - }; - }, [goBack, isTabActive, props.navigation]); - - /** - * Inject home page scripts to get the favourites and set analytics key - */ - const injectHomePageScripts = async (bookmarks) => { - const { current } = webviewRef; - const analyticsEnabled = isEnabled(); - const disctinctId = await getMetaMetricsId(); - const homepageScripts = ` - window.__mmFavorites = ${JSON.stringify(bookmarks || props.bookmarks)}; - window.__mmSearchEngine = "${props.searchEngine}"; - window.__mmMetametrics = ${analyticsEnabled}; - window.__mmDistinctId = "${disctinctId}"; - window.__mmMixpanelToken = "${MM_MIXPANEL_TOKEN}"; - (function () { - try { - window.dispatchEvent(new Event('metamask_onHomepageScriptsInjected')); - } catch (e) { - //Nothing to do - } - })() - `; - - current.injectJavaScript(homepageScripts); - }; - - /** - * Handles state changes for when the url changes - */ - const changeUrl = async (siteInfo) => { - url.current = siteInfo.url; - title.current = siteInfo.title; - if (siteInfo.icon) icon.current = siteInfo.icon; - }; - - /** - * Handles state changes for when the url changes - */ - const changeAddressBar = (siteInfo) => { - setBackEnabled(siteInfo.canGoBack); - setForwardEnabled(siteInfo.canGoForward); - - isTabActive && - props.navigation.setParams({ - url: getMaskedUrl(siteInfo.url), - icon: siteInfo.icon, - silent: true, - }); - - props.updateTabInfo(getMaskedUrl(siteInfo.url), props.id); - - props.addToBrowserHistory({ - name: siteInfo.title, - url: getMaskedUrl(siteInfo.url), - }); - }; - - /** - * Go to eth-phishing-detect page - */ - const goToETHPhishingDetector = () => { - setShowPhishingModal(false); - go(MM_PHISH_DETECT_URL); - }; - - /** - * Continue to phishing website - */ - const continueToPhishingSite = () => { - const urlObj = new URL(blockedUrl); - props.addToWhitelist(urlObj.hostname); - setShowPhishingModal(false); - blockedUrl !== url.current && - setTimeout(() => { - go(blockedUrl); - setBlockedUrl(undefined); - }, 1000); - }; - - /** - * Go to etherscam websiter - */ - const goToEtherscam = () => { - setShowPhishingModal(false); - go(MM_ETHERSCAN_URL); - }; - - /** - * Go to eth-phishing-detect issue - */ - const goToFilePhishingIssue = () => { - setShowPhishingModal(false); - blockListType.current === 'MetaMask' - ? go(MM_BLOCKLIST_ISSUE_URL) - : go(PHISHFORT_BLOCKLIST_ISSUE_URL); - }; - - /** - * Go back from phishing website alert - */ - const goBackToSafety = () => { - blockedUrl === url.current && goBack(); - setTimeout(() => { - setShowPhishingModal(false); - setBlockedUrl(undefined); - }, 500); - }; - - /** - * Renders the phishing modal - */ - const renderPhishingModal = () => ( - - - - ); - - const trackEventSearchUsed = useCallback(() => { - trackEvent( - createEventBuilder(MetaMetricsEvents.BROWSER_SEARCH_USED) - .addProperties({ - option_chosen: 'Search on URL', - number_of_tabs: undefined, - }) - .build(), - ); - }, [trackEvent, createEventBuilder]); - - /** - * Function that allows custom handling of any web view requests. - * Return `true` to continue loading the request and `false` to stop loading. - */ - const onShouldStartLoadWithRequest = ({ url }) => { - const { origin } = new URL(url); - - // Stops normal loading when it's ens, instead call go to be properly set up - if (isENSUrl(url)) { - go(url.replace(regex.urlHttpToHttps, 'https://')); - return false; - } - - // Cancel loading the page if we detect its a phishing page - if (!isAllowedOrigin(origin)) { - handleNotAllowedUrl(url); - return false; - } - - if (!props.isIpfsGatewayEnabled && isResolvedIpfsUrl) { - setIpfsBannerVisible(true); - return false; - } - - // Continue request loading it the protocol is whitelisted - const { protocol } = new URL(url); - if (protocolAllowList.includes(protocol)) return true; - Logger.log(`Protocol not allowed ${protocol}`); - - // If it is a trusted deeplink protocol, do not show the - // warning alert. Allow the OS to deeplink the URL - // and stop the webview from loading it. - if (trustedProtocolToDeeplink.includes(protocol)) { - allowLinkOpen(url); - return false; - } - - // TODO: add logging for untrusted protocol being used - // Sentry - // - const alertMsg = getAlertMessage(protocol, strings); - - // Pop up an alert dialog box to prompt the user for permission - // to execute the request - Alert.alert(strings('onboarding.warning_title'), alertMsg, [ - { - text: strings('browser.protocol_alert_options.ignore'), - onPress: () => null, - style: 'cancel', - }, - { - text: strings('browser.protocol_alert_options.allow'), - onPress: () => allowLinkOpen(url), - style: 'default', - }, - ]); - - return false; - }; - - /** - * Sets loading bar progress - */ - const onLoadProgress = ({ nativeEvent: { progress } }) => { - setProgress(progress); - }; - - // We need to be sure we can remove this property https://github.com/react-native-webview/react-native-webview/issues/2970 - // We should check if this is fixed on the newest versions of react-native-webview - const onLoad = ({ nativeEvent }) => { - //For iOS url on the navigation bar should only update upon load. - if (Device.isIos()) { - const { origin, pathname = '', query = '' } = new URL(nativeEvent.url); - const realUrl = `${origin}${pathname}${query}`; - changeUrl({ ...nativeEvent, url: realUrl, icon: favicon }); - changeAddressBar({ ...nativeEvent, url: realUrl, icon: favicon }); - } - }; - - /** - * When website finished loading - */ - const onLoadEnd = ({ nativeEvent }) => { - // Do not update URL unless website has successfully completed loading. - if (nativeEvent.loading) { - return; - } - }; - - /** - * Handle message from website - */ - const onMessage = ({ nativeEvent }) => { - let data = nativeEvent.data; - try { - if (data.length > MAX_MESSAGE_LENGTH) { - console.warn( - `message exceeded size limit and will be dropped: ${data.slice( - 0, - 1000, - )}...`, - ); - return; - } - data = typeof data === 'string' ? JSON.parse(data) : data; - if (!data || (!data.type && !data.name)) { - return; - } - if (data.name) { - const origin = new URL(nativeEvent.url).origin; - backgroundBridges.current.forEach((bridge) => { - const bridgeOrigin = new URL(bridge.url).origin; - bridgeOrigin === origin && bridge.onMessage(data); - }); - return; - } - } catch (e) { - Logger.error(e, `Browser::onMessage on ${url.current}`); - } - }; - - /** - * Go to home page, reload if already on homepage - */ - const goToHomepage = async () => { - toggleOptionsIfNeeded(); - if (url.current === HOMEPAGE_URL) return reload(); - await go(HOMEPAGE_URL); - trackEvent(createEventBuilder(MetaMetricsEvents.DAPP_HOME).build()); - }; - - /** - * Go to favorites page - */ - const goToFavorites = async () => { - toggleOptionsIfNeeded(); - if (url.current === OLD_HOMEPAGE_URL_HOST) return reload(); - await go(OLD_HOMEPAGE_URL_HOST); - trackEvent( - createEventBuilder(MetaMetricsEvents.DAPP_GO_TO_FAVORITES).build(), - ); - }; - - /** - * Handle url input submit - */ - const onUrlInputSubmit = useCallback( - async (inputValue = undefined) => { - trackEventSearchUsed(); - if (!inputValue) { - return; - } - const { defaultProtocol, searchEngine } = props; - const sanitizedInput = onUrlSubmit( - inputValue, - searchEngine, - defaultProtocol, - ); - await go(sanitizedInput); - }, - /* we do not want to depend on the props object - - since we are changing it here, this would give us a circular dependency and infinite re renders - */ - // eslint-disable-next-line react-hooks/exhaustive-deps - [], - ); - - /** - * Shows or hides the url input modal. - * When opened it sets the current website url on the input. - */ - const toggleUrlModal = useCallback( - (shouldClearInput = false) => { - const urlToShow = shouldClearInput ? '' : getMaskedUrl(url.current); - props.navigation.navigate( - ...createBrowserUrlModalNavDetails({ - url: urlToShow, - onUrlInputSubmit, - }), - ); - }, - /* we do not want to depend on the props.navigation object - - since we are changing it here, this would give us a circular dependency and infinite re renders - */ - // eslint-disable-next-line react-hooks/exhaustive-deps - [onUrlInputSubmit], - ); - - const initializeBackgroundBridge = (urlBridge, isMainFrame) => { - const newBridge = new BackgroundBridge({ - webview: webviewRef, - url: urlBridge, - getRpcMethodMiddleware: ({ hostname, getProviderState }) => - getRpcMethodMiddleware({ - hostname, - getProviderState, - navigation: props.navigation, - // Website info - url, - title, - icon, - // Bookmarks - isHomepage, - // Show autocomplete - fromHomepage, - toggleUrlModal, - // Wizard - wizardScrollAdjusted, - tabId: props.id, - injectHomePageScripts, - }), - isMainFrame, - }); - backgroundBridges.current.push(newBridge); - }; - - const sendActiveAccount = useCallback(async () => { - notifyAllConnections({ - method: NOTIFICATION_NAMES.accountsChanged, - params: permittedAccountsList, - }); - - if (isTabActive) { - props.navigation.setParams({ - connectedAccounts: permittedAccountsList, - }); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [notifyAllConnections, permittedAccountsList, isTabActive]); - - /** - * Website started to load - */ - const onLoadStart = async ({ nativeEvent }) => { - // Use URL to produce real url. This should be the actual website that the user is viewing. - const { origin, pathname = '', query = '' } = new URL(nativeEvent.url); - - // Reset the previous bridges - backgroundBridges.current.length && - backgroundBridges.current.forEach((bridge) => bridge.onDisconnect()); - - // Cancel loading the page if we detect its a phishing page - if (!isAllowedOrigin(origin)) { - handleNotAllowedUrl(url); - return false; - } - - const realUrl = `${origin}${pathname}${query}`; - if (nativeEvent.url !== url.current) { - // Update navigation bar address with title of loaded url. - changeUrl({ ...nativeEvent, url: realUrl, icon: favicon }); - changeAddressBar({ ...nativeEvent, url: realUrl, icon: favicon }); - } - - sendActiveAccount(); - - icon.current = null; - if (isHomepage(nativeEvent.url)) { - injectHomePageScripts(); - } - - backgroundBridges.current = []; - initializeBackgroundBridge(origin, true); - }; - - /** - * Enable the header to toggle the url modal and update other header data - */ - useEffect(() => { - const updateNavbar = async () => { - if (isTabActive) { - const hostname = new URL(url.current).hostname; - const accounts = await getPermittedAccounts(hostname); - props.navigation.setParams({ - showUrlModal: toggleUrlModal, - url: getMaskedUrl(url.current), - icon: icon.current, - error, - setAccountsPermissionsVisible: () => { - // Track Event: "Opened Acount Switcher" - trackEvent( - createEventBuilder(MetaMetricsEvents.BROWSER_OPEN_ACCOUNT_SWITCH) - .addProperties({ - number_of_accounts: accounts?.length, - }) - .build(), - ); - props.navigation.navigate(Routes.MODAL.ROOT_MODAL_FLOW, { - screen: Routes.SHEET.ACCOUNT_PERMISSIONS, - params: { - hostInfo: { - metadata: { - origin: url.current && new URL(url.current).hostname, - }, - }, - }, - }); - }, - connectedAccounts: accounts, - }); - } - }; - - updateNavbar(); - /* we do not want to depend on the entire props object - - since we are changing it here, this would give us a circular dependency and infinite re renders - */ - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [error, isTabActive, toggleUrlModal]); - - /** - * Check whenever permissions change / account changes for Dapp - */ - useEffect(() => { - sendActiveAccount(); - }, [sendActiveAccount, permittedAccountsList]); - - /** - * Check when the ipfs gateway is enabled to hide the banner - */ - useEffect(() => { - if (props.isIpfsGatewayEnabled) { - setIpfsBannerVisible(false); - } - }, [props.isIpfsGatewayEnabled]); - - /** - * Allow list updates do not propigate through the useCallbacks this updates a ref that is use in the callbacks - */ - const updateAllowList = () => { - allowList.current = props.whitelist; - }; - - /** - * Render the progress bar - */ - const renderProgressBar = () => ( - - - - ); - - /** - * Handle error, for example, ssl certificate error - */ - const onError = ({ nativeEvent: errorInfo }) => { - Logger.log(errorInfo); - props.navigation.setParams({ - error: true, - }); - setError(errorInfo); - }; - - /** - * Track new tab event - */ - const trackNewTabEvent = () => { - trackEvent( - createEventBuilder(MetaMetricsEvents.BROWSER_NEW_TAB) - .addProperties({ - option_chosen: 'Browser Options', - number_of_tabs: undefined, - }) - .build(), - ); - }; - - /** - * Track add site to favorites event - */ - const trackAddToFavoritesEvent = () => { - trackEvent( - createEventBuilder(MetaMetricsEvents.BROWSER_ADD_FAVORITES) - .addProperties({ - dapp_name: title.current || '', - }) - .build(), - ); - }; - - /** - * Track share site event - */ - const trackShareEvent = () => { - trackEvent( - createEventBuilder(MetaMetricsEvents.BROWSER_SHARE_SITE).build(), - ); - }; - - /** - * Track reload site event - */ - const trackReloadEvent = () => { - trackEvent(createEventBuilder(MetaMetricsEvents.BROWSER_RELOAD).build()); - }; - - /** - * Add bookmark - */ - const addBookmark = () => { - toggleOptionsIfNeeded(); - props.navigation.push('AddBookmarkView', { - screen: 'AddBookmark', - params: { - title: title.current || '', - url: getMaskedUrl(url.current), - onAddBookmark: async ({ name, url }) => { - props.addBookmark({ name, url }); - if (Device.isIos()) { - const item = { - uniqueIdentifier: url, - title: name || getMaskedUrl(url), - contentDescription: `Launch ${name || url} on MetaMask`, - keywords: [name.split(' '), url, 'dapp'], - thumbnail: { - uri: icon.current || favicon, - }, - }; - try { - SearchApi.indexSpotlightItem(item); - } catch (e) { - Logger.error(e, 'Error adding to spotlight'); - } - } - }, - }, - }); - trackAddToFavoritesEvent(); - trackEvent( - createEventBuilder(MetaMetricsEvents.DAPP_ADD_TO_FAVORITE).build(), - ); - }; - - /** - * Share url - */ - const share = () => { - toggleOptionsIfNeeded(); - Share.open({ - url: url.current, - }).catch((err) => { - Logger.log('Error while trying to share address', err); - }); - trackShareEvent(); - }; - - /** - * Open external link - */ - const openInBrowser = () => { - toggleOptionsIfNeeded(); - Linking.openURL(url.current).catch((error) => - Logger.log( - `Error while trying to open external link: ${url.current}`, - error, - ), - ); - trackEvent( - createEventBuilder(MetaMetricsEvents.DAPP_OPEN_IN_BROWSER).build(), - ); - }; - - /** - * Handles reload button press - */ - const onReloadPress = () => { - toggleOptionsIfNeeded(); - reload(); - trackReloadEvent(); - }; - - /** - * Renders Go to Favorites option - */ - const renderGoToFavorites = () => ( - - ); - - /** - * Render non-homepage options menu - */ - const renderNonHomeOptions = () => { - if (isHomepage()) return renderGoToFavorites(); - - return ( - - - {!isBookmark() && ( - - )} - {renderGoToFavorites()} - - - - ); - }; - - /** - * Handle new tab button press - */ - const onNewTabPress = () => { - openNewTab(); - trackNewTabEvent(); - }; - - /** - * Render options menu - */ - const renderOptions = () => { - if (showOptions) { - return ( - - - - - {renderNonHomeOptions()} - - - - ); - } - }; - - /** - * Show the different tabs - */ - const showTabs = () => { - dismissTextSelectionIfNeeded(); - props.showTabs(); - }; - - /** - * Render the bottom (navigation/options) bar - */ - const renderBottomBar = () => ( - - ); - - /** - * Render the onboarding wizard browser step - */ - const renderOnboardingWizard = () => { - const { wizardStep } = props; - if ([7].includes(wizardStep)) { - if (!wizardScrollAdjusted.current) { - setTimeout(() => { - reload(); - }, 1); - wizardScrollAdjusted.current = true; - } - return ; - } - return null; - }; - - /** - * Return to the MetaMask Dapp Homepage - */ - const returnHome = () => { - go(HOMEPAGE_HOST); - }; - - const handleOnFileDownload = useCallback( - async ({ nativeEvent: { downloadUrl } }) => { - const downloadResponse = await downloadFile(downloadUrl); - if (downloadResponse) { - reload(); - } else { - Alert.alert(strings('download_files.error')); - reload(); - } - }, - [reload], - ); - - const renderIpfsBanner = () => ( - - - {strings('ipfs_gateway_banner.ipfs_gateway_banner_content1')}{' '} - - {strings('ipfs_gateway_banner.ipfs_gateway_banner_content2')} - {' '} - {strings('ipfs_gateway_banner.ipfs_gateway_banner_content3')}{' '} - - {strings('ipfs_gateway_banner.ipfs_gateway_banner_content4')} - - - } - actionButtonProps={{ - variant: ButtonVariants.Link, - onPress: () => - props.navigation.navigate(Routes.MODAL.ROOT_MODAL_FLOW, { - screen: Routes.SHEET.SHOW_IPFS, - params: { - setIpfsBannerVisible: () => setIpfsBannerVisible(false), - }, - }), - textVariant: TextVariant.BodyMD, - label: 'Turn on IPFS gateway', - }} - variant={BannerVariant.Alert} - severity={BannerAlertSeverity.Info} - onClose={() => setIpfsBannerVisible(false)} - /> - - ); - - const isExternalLink = useMemo( - () => props.linkType === EXTERNAL_LINK_TYPE, - [props.linkType], - ); - - const checkTabPermissions = useCallback(() => { - if (!url.current) return; - - const hostname = new URL(url.current).hostname; - const permissionsControllerState = - Engine.context.PermissionController.state; - const permittedAccounts = getPermittedAccountsByHostname( - permissionsControllerState, - hostname, - ); - - const isConnected = permittedAccounts.length > 0; - - if (isConnected) { - let permittedChains = []; - try { - const caveat = Engine.context.PermissionController.getCaveat( - hostname, - PermissionKeys.permittedChains, - CaveatTypes.restrictNetworkSwitching, - ); - permittedChains = Array.isArray(caveat?.value) ? caveat.value : []; - - const currentChainId = props.chainId; - const isCurrentChainIdAlreadyPermitted = - permittedChains.includes(currentChainId); - - if (!isCurrentChainIdAlreadyPermitted) { - props.navigation.navigate(Routes.MODAL.ROOT_MODAL_FLOW, { - screen: Routes.SHEET.ACCOUNT_PERMISSIONS, - params: { - isNonDappNetworkSwitch: true, - hostInfo: { - metadata: { - origin: hostname, - }, - }, - isRenderedAsBottomSheet: true, - initialScreen: AccountPermissionsScreens.Connected, - }, - }); - } - } catch (e) { - Logger.error(e, 'Error in checkTabPermissions'); - } - } - }, [props.chainId, props.navigation]); - - const urlRef = useRef(url.current); - useEffect(() => { - urlRef.current = url.current; - if ( - isMultichainVersion1Enabled && - urlRef.current && - isFocused && - !props.isInTabsView && - isTabActive - ) { - checkTabPermissions(); - } - }, [checkTabPermissions, isFocused, props.isInTabsView, isTabActive]); - - /** - * Main render - */ - return ( - - - - {!!entryScriptWeb3 && firstUrlLoaded && ( - <> - ( - - )} - source={{ - uri: initialUrl, - ...(isExternalLink ? { headers: { Cookie: '' } } : null), - }} - injectedJavaScriptBeforeContentLoaded={entryScriptWeb3} - style={styles.webview} - onLoadStart={onLoadStart} - onLoad={onLoad} - onLoadEnd={onLoadEnd} - onLoadProgress={onLoadProgress} - onMessage={onMessage} - onError={onError} - onShouldStartLoadWithRequest={onShouldStartLoadWithRequest} - allowsInlineMediaPlayback - testID={BrowserViewSelectorsIDs.BROWSER_WEBVIEW_ID} - applicationNameForUserAgent={'WebView MetaMaskMobile'} - onFileDownload={handleOnFileDownload} - webviewDebuggingEnabled={isTest} - /> - {ipfsBannerVisible && renderIpfsBanner()} - - )} - - {updateAllowList()} - {renderProgressBar()} - {isTabActive && renderPhishingModal()} - {isTabActive && renderOptions()} - - {isTabActive && renderBottomBar()} - {isTabActive && renderOnboardingWizard()} - - - ); -}; - -BrowserTab.propTypes = { - /** - * The ID of the current tab - */ - id: PropTypes.number, - /** - * The ID of the active tab - */ - activeTab: PropTypes.number, - /** - * InitialUrl - */ - initialUrl: PropTypes.string, - /** - * linkType - type of link to open - */ - linkType: PropTypes.string, - /** - * Protocol string to append to URLs that have none - */ - defaultProtocol: PropTypes.string, - /** - * A string that of the chosen ipfs gateway - */ - ipfsGateway: PropTypes.string, - /** - * Object containing the information for the current transaction - */ - transaction: PropTypes.object, - /** - * react-navigation object used to switch between screens - */ - navigation: PropTypes.object, - /** - * A string that represents the selected address - */ - selectedAddress: PropTypes.string, - /** - * whitelisted url to bypass the phishing detection - */ - whitelist: PropTypes.array, - /** - * Url coming from an external source - * For ex. deeplinks - */ - url: PropTypes.string, - /** - * Function to open a new tab - */ - newTab: PropTypes.func, - /** - * Function to store bookmarks - */ - addBookmark: PropTypes.func, - /** - * Array of bookmarks - */ - bookmarks: PropTypes.array, - /** - * String representing the current search engine - */ - searchEngine: PropTypes.string, - /** - * Function to store the a page in the browser history - */ - addToBrowserHistory: PropTypes.func, - /** - * Function to store the a website in the browser whitelist - */ - addToWhitelist: PropTypes.func, - /** - * Function to update the tab information - */ - updateTabInfo: PropTypes.func, - /** - * Function to update the tab information - */ - showTabs: PropTypes.func, - /** - * Action to set onboarding wizard step - */ - setOnboardingWizardStep: PropTypes.func, - /** - * Current onboarding wizard step - */ - wizardStep: PropTypes.number, - /** - * the current version of the app - */ - app_version: PropTypes.string, - /** - * Represents ipfs gateway toggle - */ - isIpfsGatewayEnabled: PropTypes.bool, - /** - * Represents the current chain id - */ - chainId: PropTypes.string, - /** - * Boolean indicating if browser is in tabs view - */ - isInTabsView: PropTypes.bool, -}; - -BrowserTab.defaultProps = { - defaultProtocol: 'https://', -}; - -const mapStateToProps = (state) => ({ - bookmarks: state.bookmarks, - ipfsGateway: selectIpfsGateway(state), - selectedAddress: - selectSelectedInternalAccountFormattedAddress(state)?.toLowerCase(), - isIpfsGatewayEnabled: selectIsIpfsGatewayEnabled(state), - searchEngine: state.settings.searchEngine, - whitelist: state.browser.whitelist, - wizardStep: state.wizard.step, - chainId: selectChainId(state), -}); - -const mapDispatchToProps = (dispatch) => ({ - addBookmark: (bookmark) => dispatch(addBookmark(bookmark)), - addToBrowserHistory: ({ url, name }) => dispatch(addToHistory({ url, name })), - addToWhitelist: (url) => dispatch(addToWhitelist(url)), - setOnboardingWizardStep: (step) => dispatch(setOnboardingWizardStep(step)), -}); - -export default connect( - mapStateToProps, - mapDispatchToProps, -)(withNavigation(BrowserTab)); diff --git a/app/components/Views/BrowserTab/index.test.tsx b/app/components/Views/BrowserTab/index.test.tsx index f02f4784ddb..ee341d53211 100644 --- a/app/components/Views/BrowserTab/index.test.tsx +++ b/app/components/Views/BrowserTab/index.test.tsx @@ -1,10 +1,26 @@ import React from 'react'; -import { shallow } from 'enzyme'; -import { BrowserTab } from './'; -import { Provider } from 'react-redux'; -import configureMockStore from 'redux-mock-store'; +import renderWithProvider from '../../../util/test/renderWithProvider'; import { backgroundState } from '../../../util/test/initial-root-state'; import { MOCK_ACCOUNTS_CONTROLLER_STATE } from '../../../util/test/accountsControllerTestUtils'; +import BrowserTab from './BrowserTab'; +import AppConstants from '../../../core/AppConstants'; + +const mockNavigation = { + goBack: jest.fn(), + goForward: jest.fn(), + canGoBack: true, + canGoForward: true, + addListener: jest.fn(), +}; + +jest.mock('@react-navigation/native', () => { + const actualNav = jest.requireActual('@react-navigation/native'); + return { + ...actualNav, + useNavigation: () => mockNavigation, + useIsFocused: () => true, + }; +}); const mockInitialState = { browser: { activeTab: '' }, @@ -19,21 +35,47 @@ const mockInitialState = { }, }; -jest.mock('react-redux', () => ({ - ...jest.requireActual('react-redux'), - useSelector: jest.fn().mockImplementation(() => mockInitialState), +jest.mock('../../../core/Engine', () => ({ + context: { + PhishingController: { + maybeUpdateState: jest.fn(), + test: () => ({ result: true, name: 'test' }), + }, + }, })); -const mockStore = configureMockStore(); -const store = mockStore(mockInitialState); +const mockProps = { + id: 1, + activeTab: 1, + defaultProtocol: 'https://', + selectedAddress: '0x123', + whitelist: [], + bookmarks: [], + searchEngine: 'Google', + newTab: jest.fn(), + addBookmark: jest.fn(), + addToBrowserHistory: jest.fn(), + addToWhitelist: jest.fn(), + updateTabInfo: jest.fn(), + showTabs: jest.fn(), + setOnboardingWizardStep: jest.fn(), + wizardStep: 1, + isIpfsGatewayEnabled: false, + chainId: '0x1', + isInTabsView: false, + initialUrl: 'https://metamask.io', + homePageUrl: AppConstants.HOMEPAGE_URL, +}; + +describe('BrowserTab', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); -describe('Browser', () => { it('should render correctly', () => { - const wrapper = shallow( - - - , - ); - expect(wrapper).toMatchSnapshot(); + const { toJSON } = renderWithProvider(, { + state: mockInitialState, + }); + expect(toJSON()).toMatchSnapshot(); }); }); diff --git a/app/components/Views/BrowserTab/styles.ts b/app/components/Views/BrowserTab/styles.ts new file mode 100644 index 00000000000..d8fbb1dee14 --- /dev/null +++ b/app/components/Views/BrowserTab/styles.ts @@ -0,0 +1,157 @@ +import { StyleSheet } from 'react-native'; +import { Theme } from '@metamask/design-tokens'; +import { baseStyles, fontStyles } from '../../../styles/common'; +import Device from '../../../util/device'; + +const styleSheet = ({ theme: { colors, shadows } }: { theme: Theme }) => { + const getUrlModalContentPaddingTop = () => { + if (Device.isAndroid()) { + return 10; + } + if (Device.isIphoneX()) { + return 50; + } + return 27; + }; + + const getUrlModalContentHeight = () => { + if (Device.isAndroid()) { + return 59; + } + if (Device.isIphoneX()) { + return 87; + } + return 65; + }; + + return StyleSheet.create({ + wrapper: { + ...baseStyles.flexGrow, + backgroundColor: colors.background.default, + }, + hide: { + flex: 0, + opacity: 0, + display: 'none', + width: 0, + height: 0, + }, + progressBarWrapper: { + height: 3, + width: '100%', + borderBottomWidth: 1, + borderBottomColor: colors.border.default, + }, + optionsOverlay: { + position: 'absolute', + zIndex: 99999998, + top: 0, + bottom: 0, + left: 0, + right: 0, + }, + optionsWrapper: { + position: 'absolute', + zIndex: 99999999, + width: 200, + borderWidth: 1, + borderColor: colors.border.default, + backgroundColor: colors.background.default, + borderRadius: 10, + paddingBottom: 5, + paddingTop: 10, + }, + optionsWrapperAndroid: { + ...shadows.size.xs, + bottom: 65, + right: 5, + }, + optionsWrapperIos: { + ...shadows.size.xs, + bottom: 90, + right: 5, + }, + option: { + paddingVertical: 10, + height: 'auto', + minHeight: 44, + paddingHorizontal: 15, + backgroundColor: colors.background.default, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'flex-start', + marginTop: Device.isAndroid() ? 0 : -5, + }, + optionText: { + fontSize: 16, + lineHeight: 16, + alignSelf: 'center', + justifyContent: 'center', + marginTop: 3, + color: colors.primary.default, + flex: 1, + ...fontStyles.fontPrimary, + }, + optionIconWrapper: { + flex: 0, + borderRadius: 5, + backgroundColor: colors.primary.muted, + padding: 3, + marginRight: 10, + alignSelf: 'center', + }, + optionIcon: { + color: colors.primary.default, + textAlign: 'center', + alignSelf: 'center', + fontSize: 18, + }, + webview: { + ...baseStyles.flexGrow, + // zIndex: 1, + }, + urlModalContent: { + flexDirection: 'row', + paddingTop: getUrlModalContentPaddingTop(), + paddingHorizontal: 10, + height: getUrlModalContentHeight(), + backgroundColor: colors.background.default, + }, + searchWrapper: { + flexDirection: 'row', + borderRadius: 30, + backgroundColor: colors.background.alternative, + height: Device.isAndroid() ? 40 : 30, + flex: 1, + }, + clearButton: { paddingHorizontal: 12, justifyContent: 'center' }, + urlModal: { + justifyContent: 'flex-start', + margin: 0, + }, + urlInput: { + ...fontStyles.normal, + fontSize: Device.isAndroid() ? 16 : 14, + paddingLeft: 15, + flex: 1, + color: colors.text.default, + }, + bottomModal: { + justifyContent: 'flex-end', + margin: 0, + }, + fullScreenModal: { + flex: 1, + }, + bannerContainer: { + backgroundColor: colors.background.default, + position: 'absolute', + bottom: 16, + left: 16, + right: 16, + borderRadius: 4, + }, + }); +}; + +export default styleSheet; diff --git a/app/components/Views/BrowserTab/types.ts b/app/components/Views/BrowserTab/types.ts new file mode 100644 index 00000000000..e91a1782230 --- /dev/null +++ b/app/components/Views/BrowserTab/types.ts @@ -0,0 +1,128 @@ +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type IpfsContentResult = { + url?: string; + hash?: string; + type?: string; + reload?: boolean; +}; + +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type SessionENSNames = { + [key: string]: { + hostname: string; + hash: string; + type: string; + }; +}; + +export enum WebViewNavigationEventName { + OnLoadEnd, + OnLoadProgress, + OnLoadStart, +} + +/** + * The props for the BrowserTab component + */ +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type BrowserTabProps = { + /** + * The ID of the current tab + */ + id: number; + /** + * The ID of the active tab + */ + activeTab: number; + /** + * InitialUrl + */ + initialUrl: string; + /** + * linkType - type of link to open + */ + linkType?: string; + /** + * Protocol string to append to URLs that have none + */ + defaultProtocol: string; + /** + * A string that of the chosen ipfs gateway + */ + ipfsGateway: string; + /** + * Object containing the information for the current transaction + */ + transaction?: Record; + /** + * A string that represents the selected address + */ + selectedAddress: string | undefined; // This should never be undefined, need to fix the accounts controller selector + /** + * Url coming from an external source + * For ex. deeplinks + */ + url?: string; + /** + * Function to open a new tab + */ + newTab: (url?: string) => void; + /** + * Function to store bookmarks + */ + addBookmark: (bookmark: { name: string; url: string }) => void; + /** + * Array of bookmarks + */ + bookmarks: { name: string; url: string }[]; + /** + * String representing the current search engine + */ + searchEngine: string; + /** + * Function to store the a page in the browser history + */ + addToBrowserHistory: (entry: { url: string; name: string }) => void; + /** + * Function to store the a website in the browser whitelist + */ + addToWhitelist: (url: string) => void; + /** + * Function to update the tab information + */ + updateTabInfo: (url: string, tabID: number) => void; + /** + * Function to update the tab information + */ + showTabs: () => void; + /** + * Current onboarding wizard step + */ + wizardStep: number; + /** + * the current version of the app + */ + app_version?: string; + /** + * Represents ipfs gateway toggle + */ + isIpfsGatewayEnabled: boolean; + /** + * Represents the current chain id + */ + activeChainId: string; + /** + * Boolean indicating if browser is in tabs view + */ + isInTabsView: boolean; + /** + * Home page url that is appended with metricsEnabled and marketingEnabled + */ + homePageUrl: string; +}; +// This event should be exported from the webview package +export interface WebViewErrorEvent { + domain?: string; + code: number; + description: string; +} diff --git a/app/components/Views/BrowserTab/utils.test.ts b/app/components/Views/BrowserTab/utils.test.ts new file mode 100644 index 00000000000..b2b8e749203 --- /dev/null +++ b/app/components/Views/BrowserTab/utils.test.ts @@ -0,0 +1,94 @@ +import { isValidUrl, isENSUrl, getMaskedUrl } from './utils'; +import URLParse from 'url-parse'; +import AppConstants from '../../../core/AppConstants'; +import { SessionENSNames } from './types'; + +describe('BrowserTab utils', () => { + describe('isValidUrl', () => { + const testCases = [ + { url: 'https://google.com', expected: true }, + { url: 'http://localhost:3000', expected: true }, + { url: 'http://example.com:8080', expected: false }, + { url: 'https://sub.domain.com', expected: true }, + { url: 'invalid-url', expected: false }, + { url: 'ftp://invalid-protocol.com', expected: false }, + ]; + + testCases.forEach(({ url, expected }) => { + it(`should return ${expected} for ${url}`, () => { + const parsedUrl = new URLParse(url); + expect(isValidUrl(parsedUrl)).toBe(expected); + }); + }); + }); + + describe('isENSUrl', () => { + const ensIgnoreList = ['ignored.eth']; + + const testCases = [ + { url: 'https://example.eth', expected: true }, + { url: 'https://test.xyz', expected: true }, + { url: 'https://domain.test', expected: true }, + { url: 'https://ignored.eth', expected: false }, + { url: 'https://example.com', expected: false }, + ]; + + testCases.forEach(({ url, expected }) => { + it(`should return ${expected} for ${url}`, () => { + expect(isENSUrl(url, ensIgnoreList)).toBe(expected); + }); + }); + }); + + describe('getMaskedUrl', () => { + const sessionENSNames: SessionENSNames = { + // For IPFS: gateway + hash + [`${AppConstants.IPFS_DEFAULT_GATEWAY_URL}Qm123`]: { + hash: 'Qm123', + hostname: 'example.eth', + type: 'ipfs', + }, + // For IPNS: gateway + hostname + [`${AppConstants.IPNS_DEFAULT_GATEWAY_URL}test.eth`]: { + hash: 'Qm456', + hostname: 'test.eth', + type: 'ipns', + }, + // For Swarm: gateway + hash + [`${AppConstants.SWARM_DEFAULT_GATEWAY_URL}Qm789`]: { + hash: 'Qm789', + hostname: 'swarm.eth', + type: 'swarm', + }, + }; + + const testCases = [ + { + input: `${AppConstants.IPFS_DEFAULT_GATEWAY_URL}Qm123/path`, + expected: 'https://example.eth/path', + }, + { + input: `${AppConstants.IPNS_DEFAULT_GATEWAY_URL}test.eth/path`, + expected: 'https://test.eth/path', + }, + { + input: `${AppConstants.SWARM_DEFAULT_GATEWAY_URL}Qm789/path`, + expected: 'https://swarm.eth/path', + }, + { + input: 'https://regular-url.com', + expected: 'https://regular-url.com', + }, + { + input: '', + expected: '', + }, + ]; + + testCases.forEach(({ input, expected }) => { + it(`should correctly mask ${input}`, () => { + expect(getMaskedUrl(input, sessionENSNames)).toBe(expected); + }); + }); + }); +}); diff --git a/app/components/Views/BrowserTab/utils.ts b/app/components/Views/BrowserTab/utils.ts new file mode 100644 index 00000000000..ff5b093d339 --- /dev/null +++ b/app/components/Views/BrowserTab/utils.ts @@ -0,0 +1,81 @@ +import AppConstants from '../../../core/AppConstants'; +import URLParse from 'url-parse'; +import { SessionENSNames } from './types'; + +/** + * Validates url for browser + * + * Regular domains (e.g., google.com) + * Localhost URLs (e.g., http://localhost:3000) + * URLs with ports (e.g., http://localhost:1234) + * HTTPS/HTTP protocols + * + * @param url - The url to validate + * @returns + */ +export const isValidUrl = (url: URLParse): boolean => { + const urlPattern = /^(https?:\/\/)?([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}(\/\S*)?$/; + try { + return ( + (url.protocol === 'http:' || url.protocol === 'https:') && + (urlPattern.test(url.origin) || url.hostname === 'localhost') + ); + } catch { + return false; + } +}; + +/** + * Checks if it is a ENS website + */ +export const isENSUrl = (urlToCheck: string, ensIgnoreList: string[]) => { + const { hostname } = new URLParse(urlToCheck); + const tld = hostname.split('.').pop(); + if ( + tld && + AppConstants.supportedTLDs.indexOf( + tld.toLowerCase() as 'eth' | 'xyz' | 'test', + ) !== -1 + ) { + // Make sure it's not in the ignore list + if (ensIgnoreList.indexOf(hostname) === -1) { + return true; + } + } + return false; +}; + +/** + * Gets the url to be displayed to the user + * For example, if it's ens then show [site].eth instead of ipfs url + */ +export const getMaskedUrl = ( + urlToMask: string, + sessionENSNames: SessionENSNames, +) => { + if (!urlToMask) return urlToMask; + let replace = null; + if (urlToMask.startsWith(AppConstants.IPFS_DEFAULT_GATEWAY_URL)) { + replace = (key: string) => + `${AppConstants.IPFS_DEFAULT_GATEWAY_URL}${sessionENSNames[key].hash}/`; + } else if (urlToMask.startsWith(AppConstants.IPNS_DEFAULT_GATEWAY_URL)) { + replace = (key: string) => + `${AppConstants.IPNS_DEFAULT_GATEWAY_URL}${sessionENSNames[key].hostname}/`; + } else if (urlToMask.startsWith(AppConstants.SWARM_DEFAULT_GATEWAY_URL)) { + replace = (key: string) => + `${AppConstants.SWARM_DEFAULT_GATEWAY_URL}${sessionENSNames[key].hash}/`; //TODO: This was SWARM_GATEWAY_URL before, it was broken, understand what it does + } + + if (replace) { + const key = Object.keys(sessionENSNames).find((ens) => + urlToMask.startsWith(ens), + ); + if (key) { + urlToMask = urlToMask.replace( + replace(key), + `https://${sessionENSNames[key].hostname}/`, + ); + } + } + return urlToMask; +}; diff --git a/app/components/Views/BrowserUrlModal/BrowserUrlModal.test.tsx b/app/components/Views/BrowserUrlModal/BrowserUrlModal.test.tsx deleted file mode 100644 index 945d3fe9aa1..00000000000 --- a/app/components/Views/BrowserUrlModal/BrowserUrlModal.test.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import React from 'react'; -import { shallow } from 'enzyme'; -import BrowserUrlModal from './'; -import { createNavigationProps } from '../../../util/testUtils'; -import { BrowserUrlParams } from './BrowserUrlModal'; - -function mockOnUrlInputSubmit(_inputValue: string | undefined) { - // noop -} - -const mockParams: BrowserUrlParams = { - onUrlInputSubmit: mockOnUrlInputSubmit, - url: 'www.test.io', -}; - -const mockNavigation = createNavigationProps(mockParams); - -jest.mock('@react-navigation/native', () => { - const navigation = { - params: {}, - }; - return { - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ...jest.requireActual('@react-navigation/native'), - useRoute: jest.fn(() => ({ params: navigation.params })), - useNavigation: jest.fn(() => mockNavigation), - }; -}); -describe('BrowserUrlModal', () => { - it('should render correctly', () => { - const wrapper = shallow(); - expect(wrapper).toMatchSnapshot(); - }); -}); diff --git a/app/components/Views/BrowserUrlModal/BrowserUrlModal.tsx b/app/components/Views/BrowserUrlModal/BrowserUrlModal.tsx deleted file mode 100644 index 76db05783ca..00000000000 --- a/app/components/Views/BrowserUrlModal/BrowserUrlModal.tsx +++ /dev/null @@ -1,131 +0,0 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { - View, - Text, - TouchableOpacity, - TextInput, - InteractionManager, -} from 'react-native'; -import ReusableModal, { ReusableModalRef } from '../../UI/ReusableModal'; -import Icon from 'react-native-vector-icons/FontAwesome'; -import { strings } from '../../../../locales/i18n'; -import { createStyles } from './styles'; -import { useTheme } from '../../../util/theme'; -import UrlAutocomplete from '../../UI/UrlAutocomplete'; -import { - createNavigationDetails, - useParams, -} from '../../../util/navigation/navUtils'; -import Routes from '../../../constants/navigation/Routes'; -import Device from '../../../util/device'; - -import { BrowserURLBarSelectorsIDs } from '../../../../e2e/selectors/Browser/BrowserURLBar.selectors'; -import { SafeAreaView } from 'react-native-safe-area-context'; -export interface BrowserUrlParams { - onUrlInputSubmit: (inputValue: string | undefined) => void; - url: string | undefined; -} - -export const createBrowserUrlModalNavDetails = - createNavigationDetails(Routes.BROWSER.URL_MODAL); - -const BrowserUrlModal = () => { - const { onUrlInputSubmit, url } = useParams(); - const modalRef = useRef(null); - const { colors, themeAppearance } = useTheme(); - const styles = createStyles(colors); - const [autocompleteValue, setAutocompleteValue] = useState< - string | undefined - >(url); - const inputRef = useRef(null); - const dismissModal = useCallback( - (callback?: () => void) => modalRef?.current?.dismissModal(callback), - [], - ); - - /** Clear search input and focus */ - const clearSearchInput = useCallback(() => { - setAutocompleteValue(undefined); - inputRef.current?.focus?.(); - }, []); - - useEffect(() => { - InteractionManager.runAfterInteractions(() => { - // Needed to focus the input after modal renders on Android - inputRef.current?.focus?.(); - // Needed to manually selectTextOnFocus on iOS - // https://github.com/facebook/react-native/issues/30585 - if (Device.isIos()) { - if (inputRef.current && autocompleteValue) { - inputRef.current.setNativeProps({ - selection: { start: 0, end: autocompleteValue.length }, - }); - } - } - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const triggerClose = useCallback(() => dismissModal(), [dismissModal]); - const triggerOnSubmit = useCallback( - (val: string) => dismissModal(() => onUrlInputSubmit(val)), - [dismissModal, onUrlInputSubmit], - ); - - const renderContent = () => ( - - - - triggerOnSubmit(autocompleteValue || '')} - placeholder={strings('autocomplete.placeholder')} - placeholderTextColor={colors.text.muted} - returnKeyType="go" - style={styles.urlInput} - value={autocompleteValue} - selectTextOnFocus - keyboardAppearance={themeAppearance} - autoFocus - /> - {autocompleteValue ? ( - - - - ) : null} - - - - {strings('browser.cancel')} - - - - - - ); - - return ( - - {renderContent()} - - ); -}; - -export default React.memo(BrowserUrlModal); diff --git a/app/components/Views/BrowserUrlModal/__snapshots__/BrowserUrlModal.test.tsx.snap b/app/components/Views/BrowserUrlModal/__snapshots__/BrowserUrlModal.test.tsx.snap deleted file mode 100644 index e7cd28cd291..00000000000 --- a/app/components/Views/BrowserUrlModal/__snapshots__/BrowserUrlModal.test.tsx.snap +++ /dev/null @@ -1,94 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`BrowserUrlModal should render correctly 1`] = ` - - - - - - - - - Cancel - - - - - - -`; diff --git a/app/components/Views/BrowserUrlModal/index.ts b/app/components/Views/BrowserUrlModal/index.ts deleted file mode 100644 index c003e6a4805..00000000000 --- a/app/components/Views/BrowserUrlModal/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -import BrowserUrlModal from './BrowserUrlModal'; -export default BrowserUrlModal; diff --git a/app/components/Views/BrowserUrlModal/styles.ts b/app/components/Views/BrowserUrlModal/styles.ts deleted file mode 100644 index 8db6557d936..00000000000 --- a/app/components/Views/BrowserUrlModal/styles.ts +++ /dev/null @@ -1,46 +0,0 @@ -/* eslint-disable import/prefer-default-export */ -import { fontStyles } from '../../../styles/common'; -import { StyleSheet } from 'react-native'; -import Device from '../../../util/device'; - -// TODO: Replace "any" with type -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export const createStyles = (colors: any) => - StyleSheet.create({ - screen: { - flex: 1, - backgroundColor: colors.background.default, - }, - urlModalContent: { - flexDirection: 'row', - paddingHorizontal: 10, - alignItems: 'center', - }, - clearButton: { paddingHorizontal: 12, justifyContent: 'center' }, - urlInput: { - ...fontStyles.normal, - fontSize: Device.isAndroid() ? 16 : 14, - paddingLeft: 15, - paddingVertical: 6, - flex: 1, - color: colors.text.default, - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any, - cancelButton: { - marginLeft: 10, - }, - cancelButtonText: { - fontSize: 14, - color: colors.primary.default, - ...fontStyles.normal, - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any, - searchWrapper: { - flexDirection: 'row', - borderRadius: 30, - backgroundColor: colors.background.alternative, - flex: 1, - }, - }); diff --git a/app/components/Views/Settings/SecuritySettings/SecuritySettings.tsx b/app/components/Views/Settings/SecuritySettings/SecuritySettings.tsx index dcc691d35da..d94a8dbe702 100644 --- a/app/components/Views/Settings/SecuritySettings/SecuritySettings.tsx +++ b/app/components/Views/Settings/SecuritySettings/SecuritySettings.tsx @@ -153,6 +153,10 @@ const Settings: React.FC = () => { (state: RootState) => state.user.seedphraseBackedUp, ); + const isDataCollectionForMarketingEnabled = useSelector( + (state: RootState) => state.security.dataCollectionForMarketing, + ); + /** * Shows Nft auto detect modal if the user is on mainnet, never saw the modal and have nft detection off */ @@ -413,7 +417,7 @@ const Settings: React.FC = () => { ); const clearBrowserHistory = () => { - dispatch(clearHistory()); + dispatch(clearHistory(isEnabled(), isDataCollectionForMarketingEnabled)); toggleClearBrowserHistoryModal(); }; diff --git a/app/constants/navigation/Routes.ts b/app/constants/navigation/Routes.ts index d7bc8c39c29..ec146d9addc 100644 --- a/app/constants/navigation/Routes.ts +++ b/app/constants/navigation/Routes.ts @@ -1,7 +1,6 @@ const Routes = { WALLET_VIEW: 'WalletView', BROWSER_TAB_HOME: 'BrowserTabHome', - BROWSER_URL_MODAL: 'BrowserUrlModal', BROWSER_VIEW: 'BrowserView', SETTINGS_VIEW: 'SettingsView', DEPRECATED_NETWORK_DETAILS: 'DeprecatedNetworkDetails', @@ -119,7 +118,6 @@ const Routes = { }, BROWSER: { HOME: 'BrowserTabHome', - URL_MODAL: 'BrowserUrlModal', VIEW: 'BrowserView', }, WEBVIEW: { diff --git a/app/core/Analytics/MetaMetrics.events.ts b/app/core/Analytics/MetaMetrics.events.ts index 8610a0c4d9b..a8a61fd698f 100644 --- a/app/core/Analytics/MetaMetrics.events.ts +++ b/app/core/Analytics/MetaMetrics.events.ts @@ -1135,6 +1135,11 @@ const legacyMetaMetricsEvents = { ACTIONS.DAPP_VIEW, DESCRIPTION.DAPP_OPEN_IN_BROWSER, ), + DAPP_GO_TO_FAVORITES: generateOpt( + EVENT_NAME.DAPP_VIEW, + ACTIONS.DAPP_VIEW, + DESCRIPTION.DAPP_GO_TO_FAVORITES, + ), // Wallet WALLET_TOKENS: generateOpt( EVENT_NAME.WALLET_VIEW, diff --git a/app/core/AppConstants.ts b/app/core/AppConstants.ts index 7f48671fa74..33d22389490 100644 --- a/app/core/AppConstants.ts +++ b/app/core/AppConstants.ts @@ -5,7 +5,9 @@ import { DEFAULT_SERVER_URL } from '@metamask/sdk-communication-layer'; const DEVELOPMENT = 'development'; const PORTFOLIO_URL = process.env.MM_PORTFOLIO_URL || 'https://portfolio.metamask.io'; -const SECURITY_ALERTS_API_URL = process.env.SECURITY_ALERTS_API_URL ?? 'https://security-alerts.api.cx.metamask.io'; +const SECURITY_ALERTS_API_URL = + process.env.SECURITY_ALERTS_API_URL ?? + 'https://security-alerts.api.cx.metamask.io'; export default { IS_DEV: process.env?.NODE_ENV === DEVELOPMENT, @@ -44,9 +46,7 @@ export default { MM_UNIVERSAL_LINK_HOST: 'metamask.app.link', MM_DEEP_ITMS_APP_LINK: 'https://metamask.app.link/skAH3BaF99', SAI_ADDRESS: '0x89d24A6b4CcB1B6fAA2625fE562bDD9a23260359', - HOMEPAGE_URL: - process.env.MM_HOMEPAGE || - 'https://portfolio.metamask.io/explore?MetaMaskEntry=mobile/', + HOMEPAGE_URL: 'https://portfolio.metamask.io/explore?MetaMaskEntry=mobile/', OLD_HOMEPAGE_URL_HOST: 'home.metamask.io', SHORT_HOMEPAGE_URL: 'MetaMask.io', ZERO_ADDRESS: '0x0000000000000000000000000000000000000000', diff --git a/app/core/Engine/Engine.ts b/app/core/Engine/Engine.ts index 740c50e7b45..103dd04a583 100644 --- a/app/core/Engine/Engine.ts +++ b/app/core/Engine/Engine.ts @@ -858,9 +858,6 @@ export class Engine { hostname, getProviderState, navigation: null, - getApprovedHosts: () => null, - setApprovedHosts: () => null, - approveHost: () => null, title: { current: 'Snap' }, icon: { current: undefined }, isHomepage: () => false, diff --git a/app/core/RPCMethods/RPCMethodMiddleware.ts b/app/core/RPCMethods/RPCMethodMiddleware.ts index 787fe3f9b38..932d82dc63f 100644 --- a/app/core/RPCMethods/RPCMethodMiddleware.ts +++ b/app/core/RPCMethods/RPCMethodMiddleware.ts @@ -1,4 +1,5 @@ -import { Alert } from 'react-native'; +import { MutableRefObject } from 'react'; +import { Alert, ImageSourcePropType } from 'react-native'; import { getVersion } from 'react-native-device-info'; import { createAsyncMiddleware } from '@metamask/json-rpc-engine'; import { providerErrors, rpcErrors } from '@metamask/rpc-errors'; @@ -86,9 +87,9 @@ export interface RPCMethodsMiddleParameters { // TODO: Replace "any" with type // eslint-disable-next-line @typescript-eslint/no-explicit-any navigation: any; - url: { current: string }; - title: { current: string }; - icon: { current: string | undefined }; + url: MutableRefObject; + title: MutableRefObject; + icon: MutableRefObject; // Bookmarks isHomepage: () => boolean; // Show autocomplete @@ -102,13 +103,6 @@ export interface RPCMethodsMiddleParameters { isWalletConnect: boolean; // For MM SDK isMMSDK: boolean; - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - getApprovedHosts: any; - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - setApprovedHosts: (approvedHosts: any) => void; - approveHost: (fullHostname: string) => void; injectHomePageScripts: (bookmarks?: []) => void; analytics: { [key: string]: string | boolean }; } @@ -230,9 +224,9 @@ const generateRawSignature = async ({ // eslint-disable-next-line @typescript-eslint/no-explicit-any req: any; hostname: string; - url: { current: string }; - title: { current: string }; - icon: { current: string | undefined }; + url: MutableRefObject; + title: MutableRefObject; + icon: MutableRefObject; analytics: { [key: string]: string | boolean }; chainId: number; isMMSDK: boolean; diff --git a/app/core/SDKConnect/AndroidSDK/getDefaultBridgeParams.ts b/app/core/SDKConnect/AndroidSDK/getDefaultBridgeParams.ts index 9dff00b67a3..e5ebb333137 100644 --- a/app/core/SDKConnect/AndroidSDK/getDefaultBridgeParams.ts +++ b/app/core/SDKConnect/AndroidSDK/getDefaultBridgeParams.ts @@ -1,3 +1,4 @@ +import { ImageSourcePropType } from 'react-native'; import AppConstants from '../../AppConstants'; import getRpcMethodMiddleware from '../../RPCMethods/RPCMethodMiddleware'; import { DappClient } from './dapp-sdk-types'; @@ -23,11 +24,6 @@ const getDefaultBridgeParams = (clientInfo: DappClient) => ({ getProviderState, isMMSDK: true, navigation: null, //props.navigation, - getApprovedHosts: (host: string) => ({ - [host]: true, - }), - setApprovedHosts: () => true, - approveHost: () => ({}), // Website info url: { current: clientInfo.originatorInfo?.url, @@ -36,7 +32,7 @@ const getDefaultBridgeParams = (clientInfo: DappClient) => ({ current: clientInfo.originatorInfo?.title, }, icon: { - current: clientInfo.originatorInfo?.icon, + current: clientInfo.originatorInfo?.icon as ImageSourcePropType, // TODO: Need to change the type at the @metamask/sdk-communication-layer from string to ImageSourcePropType }, // Bookmarks isHomepage: () => false, diff --git a/app/core/SDKConnect/handlers/setupBridge.ts b/app/core/SDKConnect/handlers/setupBridge.ts index 638d4497d80..6bc926dd671 100644 --- a/app/core/SDKConnect/handlers/setupBridge.ts +++ b/app/core/SDKConnect/handlers/setupBridge.ts @@ -10,6 +10,7 @@ import Logger from '../../../util/Logger'; import { Connection } from '../Connection'; import DevLogger from '../utils/DevLogger'; import handleSendMessage from './handleSendMessage'; +import { ImageSourcePropType } from 'react-native'; export const setupBridge = ({ originatorInfo, @@ -55,21 +56,6 @@ export const setupBridge = ({ getProviderState, isMMSDK: true, navigation: null, //props.navigation, - getApprovedHosts: () => - connection.getApprovedHosts('rpcMethodMiddleWare'), - setApprovedHosts: (hostname: string) => { - connection.approveHost({ - host: hostname, - hostname, - context: 'setApprovedHosts', - }); - }, - approveHost: (approveHostname) => - connection.approveHost({ - host: connection.host, - hostname: approveHostname, - context: 'rpcMethodMiddleWare', - }), // Website info url: { current: originatorInfo?.url, @@ -77,7 +63,7 @@ export const setupBridge = ({ title: { current: originatorInfo?.title, }, - icon: { current: originatorInfo.icon }, + icon: { current: originatorInfo.icon as ImageSourcePropType }, // TODO: Need to change the type at the @metamask/sdk-communication-layer from string to ImageSourcePropType // Bookmarks isHomepage: () => false, // Show autocomplete diff --git a/app/core/WalletConnect/WalletConnect2Session.ts b/app/core/WalletConnect/WalletConnect2Session.ts index 4bafe965ab5..d24bcc976b9 100644 --- a/app/core/WalletConnect/WalletConnect2Session.ts +++ b/app/core/WalletConnect/WalletConnect2Session.ts @@ -4,7 +4,7 @@ import { PermissionController } from '@metamask/permission-controller'; import { NavigationContainerRef } from '@react-navigation/native'; import { ErrorResponse } from '@walletconnect/jsonrpc-types'; import { SessionTypes } from '@walletconnect/types'; -import { Platform, Linking } from 'react-native'; +import { Platform, Linking, ImageSourcePropType } from 'react-native'; import Routes from '../../../app/constants/navigation/Routes'; import ppomUtil from '../../../app/lib/ppom/ppom-util'; @@ -113,13 +113,10 @@ class WalletConnect2Session { hostname: url, getProviderState, channelId, - setApprovedHosts: () => false, - getApprovedHosts: () => false, analytics: {}, isMMSDK: false, isHomepage: () => false, fromHomepage: { current: false }, - approveHost: () => false, injectHomePageScripts: () => false, navigation: this.navigation, // Website info @@ -130,7 +127,7 @@ class WalletConnect2Session { current: name, }, icon: { - current: icons?.[0], + current: icons?.[0] as ImageSourcePropType, // Need to cast here because this cames from @walletconnect/types as string }, toggleUrlModal: () => null, wizardScrollAdjusted: { current: false }, diff --git a/app/declarations/index.d.ts b/app/declarations/index.d.ts index 094baa70c44..aa91f35967b 100644 --- a/app/declarations/index.d.ts +++ b/app/declarations/index.d.ts @@ -293,3 +293,5 @@ declare module '@metamask/react-native-actionsheet' { const ActionSheet; export default ActionSheet; } + +declare module '@metamask/react-native-search-api'; diff --git a/app/lib/ens-ipfs/resolver.js b/app/lib/ens-ipfs/resolver.js index 6df0b72b34a..a6482de3ab7 100644 --- a/app/lib/ens-ipfs/resolver.js +++ b/app/lib/ens-ipfs/resolver.js @@ -83,10 +83,10 @@ function hexValueIsEmpty(value) { function getRegistryForChainId(chainId) { switch (chainId) { // mainnet - case '1': + case '0x1': return '0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e'; // sepolia - case '11155111': + case '0xaa36a7': return '0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e'; default: return null; diff --git a/app/reducers/browser/index.js b/app/reducers/browser/index.js index 44e07d58a54..178a00c386f 100644 --- a/app/reducers/browser/index.js +++ b/app/reducers/browser/index.js @@ -1,5 +1,6 @@ import { BrowserActionTypes } from '../../actions/browser'; import AppConstants from '../../core/AppConstants'; +import { appendURLParams } from '../../util/browser'; const initialState = { history: [], @@ -40,7 +41,15 @@ const browserReducer = (state = initialState, action) => { ...state, history: [], favicons: [], - tabs: [{ url: AppConstants.HOMEPAGE_URL, id: action.id }], + tabs: [ + { + url: appendURLParams(AppConstants.HOMEPAGE_URL, { + metricsEnabled: action.metricsEnabled, + marketingEnabled: action.marketingEnabled, + }).href, + id: action.id, + }, + ], activeTab: action.id, }; case 'CLOSE_ALL_TABS': diff --git a/app/reducers/browser/selectors.ts b/app/reducers/browser/selectors.ts new file mode 100644 index 00000000000..b5e31f4240c --- /dev/null +++ b/app/reducers/browser/selectors.ts @@ -0,0 +1,11 @@ +import { RootState } from '..'; + +export const selectBrowserHistory = (state: RootState) => state.browser.history; + +/** + * Gets the selected search engine from the Redux state + * @param state - Redux state + * @returns - Selected search engine + */ +export const selectSearchEngine = (state: RootState) => + state.settings.searchEngine; diff --git a/app/util/browser/index.test.ts b/app/util/browser/index.test.ts index 42bac963a0d..48a487666e3 100644 --- a/app/util/browser/index.test.ts +++ b/app/util/browser/index.test.ts @@ -1,4 +1,4 @@ -import onUrlSubmit, { +import { isTLD, getAlertMessage, protocolAllowList, @@ -6,6 +6,8 @@ import onUrlSubmit, { prefixUrlWithProtocol, getUrlObj, getHost, + appendURLParams, + processUrlForBrowser, } from '.'; import { strings } from '../../../locales/i18n'; @@ -28,18 +30,18 @@ describe('Browser utils :: prefixUrlWithProtocol', () => { describe('Browser utils :: onUrlSubmit', () => { it('should prefix url with https: protocol', () => { - const url = onUrlSubmit('test.com'); + const url = processUrlForBrowser('test.com'); expect(url).toBe('https://test.com'); }); it('should respect the default protocol', () => { - const url = onUrlSubmit('test.com', 'Google', 'http://'); - expect(url).toBe('http://test.com'); + const url = processUrlForBrowser('test.com', 'Google'); + expect(url).toBe('https://test.com'); }); it('should generate a seach engine link if it we pass non url', () => { const keyword = 'test'; - const url = onUrlSubmit(keyword, 'Google'); + const url = processUrlForBrowser(keyword, 'Google'); const expectedUrl = 'https://www.google.com/search?q=' + encodeURIComponent(keyword); expect(url).toBe(expectedUrl); @@ -47,7 +49,7 @@ describe('Browser utils :: onUrlSubmit', () => { it('should choose the search engine based on the params', () => { const keyword = 'test'; - const url = onUrlSubmit(keyword, 'DuckDuckGo'); + const url = processUrlForBrowser(keyword, 'DuckDuckGo'); const expectedUrl = 'https://duckduckgo.com/?q=' + encodeURIComponent(keyword); expect(url).toBe(expectedUrl); @@ -55,7 +57,7 @@ describe('Browser utils :: onUrlSubmit', () => { it('should detect keywords with several words', () => { const keyword = 'what is a test'; - const url = onUrlSubmit(keyword, 'DuckDuckGo'); + const url = processUrlForBrowser(keyword, 'DuckDuckGo'); const expectedUrl = 'https://duckduckgo.com/?q=' + encodeURIComponent(keyword); expect(url).toBe(expectedUrl); @@ -63,43 +65,43 @@ describe('Browser utils :: onUrlSubmit', () => { it('should detect urls without path', () => { const input = 'https://metamask.io'; - const url = onUrlSubmit(input, 'DuckDuckGo'); + const url = processUrlForBrowser(input, 'DuckDuckGo'); expect(url).toBe(input); }); it('should detect urls with empty path', () => { const input = 'https://metamask.io/'; - const url = onUrlSubmit(input, 'DuckDuckGo'); + const url = processUrlForBrowser(input, 'DuckDuckGo'); expect(url).toBe(input); }); it('should detect urls with path', () => { const input = 'https://metamask.io/about'; - const url = onUrlSubmit(input, 'DuckDuckGo'); + const url = processUrlForBrowser(input, 'DuckDuckGo'); expect(url).toBe(input); }); it('should detect urls with path and slash at the end', () => { const input = 'https://metamask.io/about'; - const url = onUrlSubmit(input, 'DuckDuckGo'); + const url = processUrlForBrowser(input, 'DuckDuckGo'); expect(url).toBe(input); }); it('should detect urls with path and querystring', () => { const input = 'https://metamask.io/about?utm_content=tests'; - const url = onUrlSubmit(input, 'DuckDuckGo'); + const url = processUrlForBrowser(input, 'DuckDuckGo'); expect(url).toBe(input); }); it('should detect urls with path and querystring with multiple params', () => { const input = 'https://metamask.io/about?utm_content=tests&utm_source=jest'; - const url = onUrlSubmit(input, 'DuckDuckGo'); + const url = processUrlForBrowser(input, 'DuckDuckGo'); expect(url).toBe(input); }); it('should detect urls with querystring params with escape characters', () => { const input = 'https://some.com/search?q=what+is+going&a=i+dont+know'; - const url = onUrlSubmit(input, 'DuckDuckGo'); + const url = processUrlForBrowser(input, 'DuckDuckGo'); expect(url).toBe(input); }); }); @@ -242,3 +244,54 @@ describe('Browser utils :: trustedProtocolToDeeplink', () => { expect(trustedProtocolToDeeplink.includes(protocol)).toBeFalsy(); }); }); + +describe('Browser utils :: appendURLParams', () => { + it('should append search parameters to a URL string', () => { + const baseUrl = 'https://metamask.io'; + const params = { + utm_source: 'test', + utm_medium: 'unit', + active: true, + count: 42, + }; + + const result = appendURLParams(baseUrl, params); + + expect(result.toString()).toBe( + 'https://metamask.io/?utm_source=test&utm_medium=unit&active=true&count=42', + ); + }); + + it('should append parameters to a URL with existing params', () => { + const baseUrl = 'https://metamask.io/?existing=param'; + const params = { + new: 'parameter', + }; + + const result = appendURLParams(baseUrl, params); + + expect(result.toString()).toBe( + 'https://metamask.io/?existing=param&new=parameter', + ); + }); + + it('should work with URL object input', () => { + const baseUrl = new URL('https://metamask.io'); + const params = { + test: 'value', + }; + + const result = appendURLParams(baseUrl, params); + + expect(result.toString()).toBe('https://metamask.io/?test=value'); + }); + + it('should handle empty params object', () => { + const baseUrl = 'https://metamask.io'; + const params = {}; + + const result = appendURLParams(baseUrl, params); + + expect(result.toString()).toBe('https://metamask.io/'); + }); +}); diff --git a/app/util/browser/index.ts b/app/util/browser/index.ts index 2965bdd8ed4..d6c67fbf851 100644 --- a/app/util/browser/index.ts +++ b/app/util/browser/index.ts @@ -27,11 +27,8 @@ export const prefixUrlWithProtocol = ( * @param defaultProtocol - Protocol string to append to URLs that have none * @returns - String corresponding to sanitized input depending if it's a search or url */ -export default function onUrlSubmit( - input: string, - searchEngine = 'Google', - defaultProtocol = 'https://', -) { +export function processUrlForBrowser(input: string, searchEngine = 'Google') { + const defaultProtocol = 'https://'; //Check if it's a url or a keyword if (!isUrl(input) && !input.match(regex.url)) { // Add exception for localhost @@ -123,7 +120,7 @@ export const trustedProtocolToDeeplink = [ */ export const getAlertMessage = ( protocol: string, - i18nService: (id: string) => void, + i18nService: (id: string) => string, ) => { switch (protocol) { case 'tel:': @@ -155,3 +152,23 @@ export const allowLinkOpen = (url: string) => .catch((e) => { console.warn(`Error opening URL: ${e}`); }); + +/** + * Appends search parameters to a URL and returns the complete URL string + * + * @param baseUrl - Base URL string or URL object + * @param params - Record of key-value pairs to append as search parameters + * @returns - String containing complete URL with appended parameters + */ +export const appendURLParams = ( + baseUrl: string | URL, + params: Record, +): URL => { + const url = baseUrl instanceof URL ? baseUrl : new URL(baseUrl); + + Object.entries(params).forEach(([key, value]) => { + url.searchParams.append(key, String(value)); + }); + + return url; +}; diff --git a/bitrise.yml b/bitrise.yml index 0cfc20b1827..6a09104b9d7 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -1795,13 +1795,13 @@ app: VERSION_NAME: 7.37.1 - opts: is_expand: false - VERSION_NUMBER: 1520 + VERSION_NUMBER: 1534 - opts: is_expand: false FLASK_VERSION_NAME: 7.37.1 - opts: is_expand: false - FLASK_VERSION_NUMBER: 1520 + FLASK_VERSION_NUMBER: 1534 - opts: is_expand: false ANDROID_APK_LINK: '' diff --git a/e2e/fixtures/fixture-builder.js b/e2e/fixtures/fixture-builder.js index 1f7fcf30f89..cf4137c58ad 100644 --- a/e2e/fixtures/fixture-builder.js +++ b/e2e/fixtures/fixture-builder.js @@ -448,7 +448,7 @@ class FixtureBuilder { whitelist: [], tabs: [ { - url: 'https://metamask.github.io/test-dapp/', + url: 'https://google.com', id: 1692550481062, }, ], diff --git a/e2e/pages/Browser/BrowserView.js b/e2e/pages/Browser/BrowserView.js index bca647e9332..2a94e789e4a 100644 --- a/e2e/pages/Browser/BrowserView.js +++ b/e2e/pages/Browser/BrowserView.js @@ -145,6 +145,7 @@ class Browser { } async tapNetworkAvatarButtonOnBrowser() { + await TestHelpers.delay(4000); await Gestures.waitAndTap(this.networkAvatarButton); } diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index 28089e0db17..1c638e401b1 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -1380,7 +1380,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 1520; + CURRENT_PROJECT_VERSION = 1534; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1449,7 +1449,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 1520; + CURRENT_PROJECT_VERSION = 1534; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1514,7 +1514,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMaskDebug.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1520; + CURRENT_PROJECT_VERSION = 1534; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1580,7 +1580,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 1520; + CURRENT_PROJECT_VERSION = 1534; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1739,7 +1739,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMaskDebug.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1520; + CURRENT_PROJECT_VERSION = 1534; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1808,7 +1808,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 1520; + CURRENT_PROJECT_VERSION = 1534; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG;