diff --git a/config/webpack/webpack.common.js b/config/webpack/webpack.common.js index f3c02b286623..7dc851c95c9e 100644 --- a/config/webpack/webpack.common.js +++ b/config/webpack/webpack.common.js @@ -194,7 +194,7 @@ const webpackConfig = ({envFile = '.env', platform = 'web'}) => ({ // This is also why we have to use .website.js for our own web-specific files... // Because desktop also relies on "web-specific" module implementations // This also skips packing web only dependencies to desktop and vice versa - extensions: ['.web.js', platform === 'web' ? '.website.js' : '.desktop.js', '.js', '.jsx', '.web.ts', platform === 'web' ? '.website.ts' : '.desktop.ts', '.ts', '.tsx'], + extensions: ['.web.js', platform === 'web' ? '.website.js' : '.desktop.js', '.js', '.jsx', '.web.ts', platform === 'web' ? '.website.ts' : '.desktop.ts', '.ts', '.web.tsx', '.tsx'], fallback: { 'process/browser': require.resolve('process/browser'), }, diff --git a/package-lock.json b/package-lock.json index fc027851ff93..a8ad41839e16 100644 --- a/package-lock.json +++ b/package-lock.json @@ -106,7 +106,6 @@ "react-native-web-linear-gradient": "^1.1.2", "react-native-web-lottie": "^1.4.4", "react-native-webview": "^11.17.2", - "react-native-x-maps": "1.0.10", "react-pdf": "^6.2.2", "react-plaid-link": "3.3.2", "react-web-config": "^1.0.0", @@ -41222,18 +41221,6 @@ "node": ">=8" } }, - "node_modules/react-native-x-maps": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/react-native-x-maps/-/react-native-x-maps-1.0.10.tgz", - "integrity": "sha512-jBRl5JzP3QmGY6tj5CR9UwbREZ3tnuSa7puZozai3bRFrN68k3W6x1p6O8SGp91VvcQlaqJUPFZ+bkYiY3XRvA==", - "peerDependencies": { - "@rnmapbox/maps": "^10.0.11", - "mapbox-gl": "^2.15.0", - "react": "^18.2.0", - "react-map-gl": "^7.1.3", - "react-native": "^0.72.3" - } - }, "node_modules/react-native/node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -76892,12 +76879,6 @@ } } }, - "react-native-x-maps": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/react-native-x-maps/-/react-native-x-maps-1.0.10.tgz", - "integrity": "sha512-jBRl5JzP3QmGY6tj5CR9UwbREZ3tnuSa7puZozai3bRFrN68k3W6x1p6O8SGp91VvcQlaqJUPFZ+bkYiY3XRvA==", - "requires": {} - }, "react-pdf": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/react-pdf/-/react-pdf-6.2.2.tgz", diff --git a/package.json b/package.json index e64569cad326..7fe4d322aff5 100644 --- a/package.json +++ b/package.json @@ -146,7 +146,6 @@ "react-native-web-linear-gradient": "^1.1.2", "react-native-web-lottie": "^1.4.4", "react-native-webview": "^11.17.2", - "react-native-x-maps": "1.0.10", "react-pdf": "^6.2.2", "react-plaid-link": "3.3.2", "react-web-config": "^1.0.0", diff --git a/src/CONST.ts b/src/CONST.ts index 3cd522027cd3..31f6529672c0 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -2605,7 +2605,6 @@ const CONST = { SF_COORDINATES: [-122.4194, 37.7749], - MAPBOX_STYLE_URL: 'mapbox://styles/expensify/cllcoiqds00cs01r80kp34tmq', NAVIGATION: { TYPE: { FORCED_UP: 'FORCED_UP', @@ -2624,6 +2623,13 @@ const CONST = { SAASTR: 'SaaStrDemoSetup', SBE: 'SbeDemoSetup', }, + + MAPBOX: { + PADDING: 50, + DEFAULT_ZOOM: 10, + DEFAULT_COORDINATE: [-122.4021, 37.7911], + STYLE_URL: 'mapbox://styles/expensify/cllcoiqds00cs01r80kp34tmq', + }, } as const; export default CONST; diff --git a/src/components/ConfirmedRoute.js b/src/components/ConfirmedRoute.js index 07d0fdc6ca97..f57fac050c65 100644 --- a/src/components/ConfirmedRoute.js +++ b/src/components/ConfirmedRoute.js @@ -1,7 +1,7 @@ import React, {useEffect} from 'react'; import PropTypes from 'prop-types'; import {View} from 'react-native'; -import MapView from 'react-native-x-maps'; + import {withOnyx} from 'react-native-onyx'; import lodashGet from 'lodash/get'; import _ from 'underscore'; @@ -15,18 +15,27 @@ import transactionPropTypes from './transactionPropTypes'; import BlockingView from './BlockingViews/BlockingView'; import useNetwork from '../hooks/useNetwork'; import useLocalize from '../hooks/useLocalize'; +import MapView from './MapView'; const propTypes = { /** Transaction that stores the distance request data */ transaction: transactionPropTypes, - /** Token needed to render the map */ - mapboxToken: PropTypes.string, + /** Data about Mapbox token for calling Mapbox API */ + mapboxAccessToken: PropTypes.shape({ + /** Temporary token for Mapbox API */ + token: PropTypes.string, + + /** Time when the token will expire in ISO 8601 */ + expiration: PropTypes.string, + }), }; const defaultProps = { transaction: {}, - mapboxToken: '', + mapboxAccessToken: { + token: '', + }, }; const getWaypointMarkers = (waypoints) => { @@ -49,6 +58,7 @@ const getWaypointMarkers = (waypoints) => { } return { + id: `${waypoint.lng},${waypoint.lat},${index}`, coordinate: [waypoint.lng, waypoint.lat], markerComponent: () => ( { ); }; -function ConfirmedRoute({mapboxToken, transaction}) { +function ConfirmedRoute({mapboxAccessToken, transaction}) { const {isOffline} = useNetwork(); const {translate} = useLocalize(); const {route0: route} = transaction.routes || {}; @@ -78,16 +88,15 @@ function ConfirmedRoute({mapboxToken, transaction}) { return ( <> - {!isOffline && mapboxToken ? ( + {!isOffline && Boolean(mapboxAccessToken.token) ? ( ) : ( @@ -107,9 +116,8 @@ export default withOnyx({ transaction: { key: ({transactionID}) => `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, }, - mapboxToken: { + mapboxAccessToken: { key: ONYXKEYS.MAPBOX_ACCESS_TOKEN, - selector: (mapboxAccessToken) => mapboxAccessToken && mapboxAccessToken.token, }, })(ConfirmedRoute); diff --git a/src/components/DistanceRequest.js b/src/components/DistanceRequest.js index 9ba5fd76a95a..ce6b9880a82c 100644 --- a/src/components/DistanceRequest.js +++ b/src/components/DistanceRequest.js @@ -1,41 +1,45 @@ -import React, {useEffect, useState} from 'react'; +import React, {useEffect, useMemo, useState} from 'react'; import {ScrollView, View} from 'react-native'; +import {withOnyx} from 'react-native-onyx'; import lodashGet from 'lodash/get'; import lodashHas from 'lodash/has'; -import _ from 'underscore'; import PropTypes from 'prop-types'; -import {withOnyx} from 'react-native-onyx'; -import MapView from 'react-native-x-maps'; +import _ from 'underscore'; + +import CONST from '../CONST'; +import ROUTES from '../ROUTES'; import ONYXKEYS from '../ONYXKEYS'; -import * as Transaction from '../libs/actions/Transaction'; -import * as TransactionUtils from '../libs/TransactionUtils'; -import MenuItemWithTopDescription from './MenuItemWithTopDescription'; -import * as Expensicons from './Icon/Expensicons'; -import theme from '../styles/themes/default'; -import Button from './Button'; + import styles from '../styles/styles'; import variables from '../styles/variables'; -import LinearGradient from './LinearGradient'; -import * as MapboxToken from '../libs/actions/MapboxToken'; -import CONST from '../CONST'; -import BlockingView from './BlockingViews/BlockingView'; +import theme from '../styles/themes/default'; + +import transactionPropTypes from './transactionPropTypes'; + import useNetwork from '../hooks/useNetwork'; +import usePrevious from '../hooks/usePrevious'; import useLocalize from '../hooks/useLocalize'; + +import * as ErrorUtils from '../libs/ErrorUtils'; import Navigation from '../libs/Navigation/Navigation'; -import ROUTES from '../ROUTES'; -import * as IOU from '../libs/actions/IOU'; -import reportPropTypes from '../pages/reportPropTypes'; -import transactionPropTypes from './transactionPropTypes'; +import * as MapboxToken from '../libs/actions/MapboxToken'; +import * as Transaction from '../libs/actions/Transaction'; +import * as TransactionUtils from '../libs/TransactionUtils'; + +import Button from './Button'; +import MapView from './MapView'; +import LinearGradient from './LinearGradient'; +import * as Expensicons from './Icon/Expensicons'; +import BlockingView from './BlockingViews/BlockingView'; import DotIndicatorMessage from './DotIndicatorMessage'; -import * as ErrorUtils from '../libs/ErrorUtils'; -import usePrevious from '../hooks/usePrevious'; +import MenuItemWithTopDescription from './MenuItemWithTopDescription'; import {iouPropTypes} from '../pages/iou/propTypes'; +import reportPropTypes from '../pages/reportPropTypes'; +import * as IOU from '../libs/actions/IOU'; const MAX_WAYPOINTS = 25; const MAX_WAYPOINTS_TO_DISPLAY = 4; -const DEFAULT_ZOOM_LEVEL = 10; - const propTypes = { /** Holds data related to Money Request view state, rather than the underlying Money Request data. */ iou: iouPropTypes, @@ -64,7 +68,9 @@ const defaultProps = { iouType: '', report: {}, transaction: {}, - mapboxAccessToken: {}, + mapboxAccessToken: { + token: '', + }, }; function DistanceRequest({iou, iouType, report, transaction, mapboxAccessToken}) { @@ -75,7 +81,7 @@ function DistanceRequest({iou, iouType, report, transaction, mapboxAccessToken}) const {translate} = useLocalize(); const reportID = lodashGet(report, 'reportID', ''); - const waypoints = lodashGet(transaction, 'comment.waypoints', {}); + const waypoints = useMemo(() => lodashGet(transaction, 'comment.waypoints', {}), [transaction]); const numberOfWaypoints = _.size(waypoints); const lastWaypointIndex = numberOfWaypoints - 1; @@ -86,34 +92,39 @@ function DistanceRequest({iou, iouType, report, transaction, mapboxAccessToken}) const doesRouteExist = lodashHas(transaction, 'routes.route0.geometry.coordinates'); const shouldFetchRoute = (!doesRouteExist || haveWaypointsChanged) && !isLoadingRoute && TransactionUtils.validateWaypoints(waypoints); - const waypointMarkers = _.filter( - _.map(waypoints, (waypoint, key) => { - if (!waypoint || !lodashHas(waypoint, 'lat') || !lodashHas(waypoint, 'lng')) { - return; - } + const waypointMarkers = useMemo( + () => + _.filter( + _.map(waypoints, (waypoint, key) => { + if (!waypoint || !lodashHas(waypoint, 'lat') || !lodashHas(waypoint, 'lng')) { + return; + } - const index = Number(key.replace('waypoint', '')); - let MarkerComponent; - if (index === 0) { - MarkerComponent = Expensicons.DotIndicatorUnfilled; - } else if (index === lastWaypointIndex) { - MarkerComponent = Expensicons.Location; - } else { - MarkerComponent = Expensicons.DotIndicator; - } + const index = Number(key.replace('waypoint', '')); + let MarkerComponent; + if (index === 0) { + MarkerComponent = Expensicons.DotIndicatorUnfilled; + } else if (index === lastWaypointIndex) { + MarkerComponent = Expensicons.Location; + } else { + MarkerComponent = Expensicons.DotIndicator; + } - return { - coordinate: [waypoint.lng, waypoint.lat], - markerComponent: () => ( - - ), - }; - }), - (waypoint) => waypoint, + return { + id: `${waypoint.lng},${waypoint.lat},${index}`, + coordinate: [waypoint.lng, waypoint.lat], + markerComponent: () => ( + + ), + }; + }), + (waypoint) => waypoint, + ), + [waypoints, lastWaypointIndex], ); // Show up to the max number of waypoints plus 1/2 of one to hint at scrolling @@ -218,17 +229,16 @@ function DistanceRequest({iou, iouType, report, transaction, mapboxAccessToken}) {!isOffline && Boolean(mapboxAccessToken.token) ? ( ) : ( diff --git a/src/components/MapView/Direction.tsx b/src/components/MapView/Direction.tsx new file mode 100644 index 000000000000..920a3912dca4 --- /dev/null +++ b/src/components/MapView/Direction.tsx @@ -0,0 +1,30 @@ +import Mapbox from '@rnmapbox/maps'; +import {DirectionProps} from './MapViewTypes'; +import styles from '../../styles/styles'; + +function Direction({coordinates}: DirectionProps) { + if (coordinates.length < 1) { + return null; + } + + return ( + + + + ); +} + +export default Direction; diff --git a/src/components/MapView/Direction.web.tsx b/src/components/MapView/Direction.web.tsx new file mode 100644 index 000000000000..190ecb03cb5e --- /dev/null +++ b/src/components/MapView/Direction.web.tsx @@ -0,0 +1,48 @@ +// Explanation: Different Mapbox libraries are required for web and native mobile platforms. +// This is why we have separate components for web and native to handle the specific implementations. +// For the web version, we use the Mapbox Web library called react-map-gl, while for the native mobile version, +// we utilize a different Mapbox library @rnmapbox/maps tailored for mobile development. + +import React from 'react'; +import {View} from 'react-native'; +import {Layer, Source} from 'react-map-gl'; +import {DirectionProps} from './MapViewTypes'; + +import styles from '../../styles/styles'; + +function Direction({coordinates}: DirectionProps) { + const layerLayoutStyle: Record = styles.mapDirectionLayer.layout; + const layerPointStyle: Record = styles.mapDirectionLayer.paint; + + if (coordinates.length < 1) { + return null; + } + return ( + + {coordinates && ( + + + + )} + + ); +} + +export default Direction; diff --git a/src/components/MapView/MapView.tsx b/src/components/MapView/MapView.tsx new file mode 100644 index 000000000000..c1dd064127a9 --- /dev/null +++ b/src/components/MapView/MapView.tsx @@ -0,0 +1,94 @@ +import {View} from 'react-native'; +import {useFocusEffect} from '@react-navigation/native'; +import Mapbox, {MapState, MarkerView, setAccessToken} from '@rnmapbox/maps'; +import {forwardRef, memo, useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react'; + +import utils from './utils'; +import Direction from './Direction'; +import CONST from '../../CONST'; + +import {MapViewProps, MapViewHandle} from './MapViewTypes'; + +const MapView = forwardRef(({accessToken, style, mapPadding, styleURL, pitchEnabled, initialState, waypoints, directionCoordinates}, ref) => { + const cameraRef = useRef(null); + const [isIdle, setIsIdle] = useState(false); + + useImperativeHandle( + ref, + () => ({ + flyTo: (location: [number, number], zoomLevel: number = CONST.MAPBOX.DEFAULT_ZOOM, animationDuration?: number) => + cameraRef.current?.setCamera({zoomLevel, centerCoordinate: location, animationDuration}), + fitBounds: (northEast: [number, number], southWest: [number, number], paddingConfig?: number | number[] | undefined, animationDuration?: number | undefined) => + cameraRef.current?.fitBounds(northEast, southWest, paddingConfig, animationDuration), + }), + [], + ); + + // When the page loses focus, we temporarily set the "idled" state to false. + // When the page regains focus, the onIdled method of the map will set the actual "idled" state, + // which in turn triggers the callback. + useFocusEffect( + useCallback(() => { + if (waypoints?.length && isIdle) { + if (waypoints.length === 1) { + cameraRef.current?.setCamera({ + zoomLevel: 15, + animationDuration: 1500, + centerCoordinate: waypoints[0].coordinate, + }); + } else { + const {southWest, northEast} = utils.getBounds(waypoints.map((waypoint) => waypoint.coordinate)); + cameraRef.current?.fitBounds(northEast, southWest, mapPadding, 1000); + } + } + return () => { + setIsIdle(false); + }; + }, [mapPadding, waypoints, isIdle]), + ); + + useEffect(() => { + setAccessToken(accessToken); + }, [accessToken]); + + const setMapIdle = (e: MapState) => { + if (e.gestures.isGestureActive) return; + setIsIdle(true); + }; + + return ( + + + + + {waypoints?.map(({coordinate, markerComponent, id}) => { + const MarkerComponent = markerComponent; + return ( + + + + ); + })} + + {directionCoordinates && } + + + ); +}); + +export default memo(MapView); diff --git a/src/components/MapView/MapView.web.tsx b/src/components/MapView/MapView.web.tsx new file mode 100644 index 000000000000..446d375da318 --- /dev/null +++ b/src/components/MapView/MapView.web.tsx @@ -0,0 +1,91 @@ +// Explanation: Different Mapbox libraries are required for web and native mobile platforms. +// This is why we have separate components for web and native to handle the specific implementations. +// For the web version, we use the Mapbox Web library called react-map-gl, while for the native mobile version, +// we utilize a different Mapbox library @rnmapbox/maps tailored for mobile development. + +import React, {forwardRef, useCallback, useEffect, useImperativeHandle, useState} from 'react'; +import {View} from 'react-native'; +import Map, {MapRef, Marker} from 'react-map-gl'; + +import utils from './utils'; + +import CONST from '../../CONST'; +import Direction from './Direction'; +import {MapViewHandle, MapViewProps} from './MapViewTypes'; + +import 'mapbox-gl/dist/mapbox-gl.css'; + +const MapView = forwardRef( + ({style, styleURL, waypoints, mapPadding, accessToken, directionCoordinates, initialState = {location: CONST.MAPBOX.DEFAULT_COORDINATE, zoom: CONST.MAPBOX.DEFAULT_ZOOM}}, ref) => { + const [mapRef, setMapRef] = useState(null); + const setRef = useCallback((newRef: MapRef | null) => setMapRef(newRef), []); + + useEffect(() => { + if (!waypoints || waypoints.length === 0) { + return; + } + + if (!mapRef) { + return; + } + + if (waypoints.length === 1) { + mapRef.flyTo({ + center: waypoints[0].coordinate, + zoom: 15, + }); + return; + } + + const map = mapRef.getMap(); + + const {northEast, southWest} = utils.getBounds(waypoints.map((waypoint) => waypoint.coordinate)); + map.fitBounds([northEast, southWest], {padding: mapPadding}); + }, [waypoints, mapRef, mapPadding]); + + useImperativeHandle( + ref, + () => ({ + flyTo: (location: [number, number], zoomLevel: number = CONST.MAPBOX.DEFAULT_ZOOM, animationDuration?: number) => + mapRef?.flyTo({ + center: location, + zoom: zoomLevel, + duration: animationDuration, + }), + fitBounds: (northEast: [number, number], southWest: [number, number]) => mapRef?.fitBounds([northEast, southWest]), + }), + [mapRef], + ); + + return ( + + + {waypoints?.map(({coordinate, markerComponent, id}) => { + const MarkerComponent = markerComponent; + return ( + + + + ); + })} + {directionCoordinates && } + + + ); + }, +); + +export default MapView; diff --git a/src/components/MapView/MapViewTypes.ts b/src/components/MapView/MapViewTypes.ts new file mode 100644 index 000000000000..cf5abeed02b2 --- /dev/null +++ b/src/components/MapView/MapViewTypes.ts @@ -0,0 +1,56 @@ +import {ComponentType} from 'react'; +import type {StyleProp, ViewStyle} from 'react-native'; + +type MapViewProps = { + // Public access token to be used to fetch map data from Mapbox. + accessToken: string; + // Style applied to MapView component. Note some of the View Style props are not available on ViewMap + style: StyleProp; + // Link to the style JSON document. + styleURL?: string; + // Whether map can tilt in the vertical direction. + pitchEnabled?: boolean; + // Padding to apply when the map is adjusted to fit waypoints and directions + mapPadding?: number; + // Initial coordinate and zoom level + initialState?: InitialState; + // Locations on which to put markers + waypoints?: WayPoint[]; + // List of coordinates which together forms a direction. + directionCoordinates?: Array<[number, number]>; +}; + +type DirectionProps = { + // Coordinates of points that constitute the direction + coordinates: Array<[number, number]>; +}; + +// Initial state of the map +type InitialState = { + // Coordinate on which to center the map + location: [number, number]; + zoom: number; +}; + +// Waypoint to be displayed on the map +type WayPoint = { + id: string; + coordinate: [number, number]; + markerComponent: ComponentType; +}; + +// Style used for the line that displays direction +type DirectionStyle = { + width?: number; + color?: string; +}; + +// Represents a handle to interact with a map view. +type MapViewHandle = { + // Fly to a location on the map + flyTo: (location: [number, number], zoomLevel: number, animationDuration?: number) => void; + // Fit the map view to a bounding box + fitBounds: (ne: [number, number], sw: [number, number], paddingConfig?: number | number[], animationDuration?: number) => void; +}; + +export type {DirectionStyle, WayPoint, MapViewProps, DirectionProps, MapViewHandle}; diff --git a/src/components/MapView/index.js b/src/components/MapView/index.js new file mode 100644 index 000000000000..551f57e34ed2 --- /dev/null +++ b/src/components/MapView/index.js @@ -0,0 +1,3 @@ +import MapView from './MapView'; + +export default MapView; diff --git a/src/components/MapView/utils.ts b/src/components/MapView/utils.ts new file mode 100644 index 000000000000..c37d272296e5 --- /dev/null +++ b/src/components/MapView/utils.ts @@ -0,0 +1,13 @@ +function getBounds(waypoints: Array<[number, number]>): {southWest: [number, number]; northEast: [number, number]} { + const lngs = waypoints.map((waypoint) => waypoint[0]); + const lats = waypoints.map((waypoint) => waypoint[1]); + + return { + southWest: [Math.min(...lngs), Math.min(...lats)], + northEast: [Math.max(...lngs), Math.max(...lats)], + }; +} + +export default { + getBounds, +}; diff --git a/src/pages/iou/WaypointEditor.js b/src/pages/iou/WaypointEditor.js index 2e5244d6c0f7..8c168a456210 100644 --- a/src/pages/iou/WaypointEditor.js +++ b/src/pages/iou/WaypointEditor.js @@ -4,6 +4,7 @@ import lodashGet from 'lodash/get'; import {View} from 'react-native'; import PropTypes from 'prop-types'; import {withOnyx} from 'react-native-onyx'; +import {useIsFocused} from '@react-navigation/native'; import AddressSearch from '../../components/AddressSearch'; import ScreenWrapper from '../../components/ScreenWrapper'; import FullPageNotFoundView from '../../components/BlockingViews/FullPageNotFoundView'; @@ -74,6 +75,7 @@ const defaultProps = { function WaypointEditor({transactionID, route: {params: {iouType = '', waypointIndex = ''} = {}} = {}, transaction, recentWaypoints}) { const {windowWidth} = useWindowDimensions(); const [isDeleteStopModalOpen, setIsDeleteStopModalOpen] = useState(false); + const isFocused = useIsFocused(); const {translate} = useLocalize(); const {isOffline} = useNetwork(); const textInput = useRef(null); @@ -161,7 +163,7 @@ function WaypointEditor({transactionID, route: {params: {iouType = '', waypointI onEntryTransitionEnd={() => textInput.current && textInput.current.focus()} shouldEnableMaxHeight > - waypointCount - 1}> + waypointCount - 1) && isFocused}>