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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion src/components/MapView/MapView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ function MapView({
const [userLocation] = useOnyx(ONYXKEYS.USER_LOCATION);
const navigation = useNavigation();
const {isOffline} = useNetwork();
const {translate} = useLocalize();
const {translate, preferredLocale} = useLocalize();
const styles = useThemeStyles();
const theme = useTheme();
const expensifyIcons = useMemoizedLazyExpensifyIcons(['Crosshair', 'MapCurrentLocation']);
Expand Down Expand Up @@ -261,11 +261,18 @@ function MapView({
const initCenterCoordinate = useMemo(() => (interactive ? centerCoordinate : undefined), [interactive, centerCoordinate]);
const initBounds = useMemo(() => (interactive ? undefined : waypointsBounds), [interactive, waypointsBounds]);

// Localize the map labels to the user's preferred app locale so they match the rest of the app.
const localizeLabels = useMemo<{locale: string} | undefined>(() => {
const language = utils.getMapboxLanguage(preferredLocale);
return language ? {locale: language} : undefined;
}, [preferredLocale]);

return !isOffline && isAccessTokenReady && !!defaultSettings ? (
<View style={[style, !interactive ? styles.pointerEventsNone : {}]}>
<Mapbox.MapView
style={{flex: 1}}
styleURL={styleURL}
localizeLabels={localizeLabels}
onMapIdle={setMapIdle}
onCameraChanged={onCameraChanged}
onTouchStart={() => setUserInteractedWithMap(true)}
Expand Down
29 changes: 28 additions & 1 deletion src/components/MapView/MapViewImpl.web.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,10 @@ function MapViewImpl({
const hasAlternateDirection = !!alternateDirection?.coordinates?.length;

const [userLocation] = useOnyx(ONYXKEYS.USER_LOCATION);
const [countryByIp] = useOnyx(ONYXKEYS.COUNTRY);

const {isOffline} = useNetwork();
const {translate} = useLocalize();
const {translate, preferredLocale} = useLocalize();

const theme = useTheme();
const styles = useThemeStyles();
Expand Down Expand Up @@ -198,6 +199,32 @@ function MapViewImpl({
};
}, [mapRef]);

// cspell:ignore styledata
// Keep the map labels in the user's preferred app locale and its disputed borders drawn from the user's
// own worldview, reapplying whenever the map, the locale or the country changes.
useEffect(() => {
if (!mapRef) {
return;
}

const map = mapRef.getMap();
const applyLocalization = () => {
map.setLanguage(utils.getMapboxLanguage(preferredLocale));
map.setWorldview(utils.getMapboxWorldview(countryByIp));
};

if (map.isStyleLoaded()) {
applyLocalization();
return;
}

// The style must be loaded before labels and borders can be localized, so defer until it is ready.
map.once('styledata', applyLocalization);
return () => {
map.off('styledata', applyLocalization);
};
}, [mapRef, preferredLocale, countryByIp]);

useImperativeHandle(
ref,
() => ({
Expand Down
40 changes: 40 additions & 0 deletions src/components/MapView/utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,43 @@
import {LOCALES} from '@src/CONST/LOCALES';
import type {Locale} from '@src/CONST/LOCALES';

import type {AlternateDirection, Coordinate} from './MapViewTypes';

/** App locales whose value isn't already the BCP-47 code Mapbox expects for label localization. */
const LOCALE_TO_MAPBOX_LANGUAGE: Partial<Record<Locale, string>> = {
[LOCALES.PT_BR]: 'pt',
[LOCALES.ZH_HANS]: 'zh-Hans',
};

/**
* Maps an app locale to the BCP-47 language code Mapbox uses to localize map labels.
* Most app locales are already valid Mapbox codes, so only a couple need remapping.
* Unsupported codes fall back to each label's local language on the Mapbox side.
*/
function getMapboxLanguage(locale: Locale | undefined): string | undefined {
if (!locale) {
return undefined;
}
return LOCALE_TO_MAPBOX_LANGUAGE[locale] ?? locale;
}

/** A worldview is an ISO 3166-1 alpha-2 country code, so anything that isn't two letters can't be one. */
const ISO_ALPHA_2_COUNTRY = /^[A-Z]{2}$/;

/**
* Maps the user's country to the Mapbox worldview used to draw disputed borders.
* Mapbox only defines a worldview for the handful of countries that dispute borders and falls back to the
* style's default for every other country code, so the country is passed straight through rather than
* matched against a list that would go stale as Mapbox adds worldviews. Anything that isn't a country code
* is dropped, because Mapbox raises an error for codes it can't parse.
*/
function getMapboxWorldview(country: string | undefined): string | undefined {
if (!country || !ISO_ALPHA_2_COUNTRY.test(country)) {
return undefined;
}
return country;
}

/** A geographic point as a plain longitude/latitude pair. Mapbox's `LngLat` became a class in mapbox-gl 3.x, but these helpers only read `.lng`/`.lat`, so a literal shape is all that's needed. */
type LngLatLiteral = {lng: number; lat: number};

Expand Down Expand Up @@ -241,4 +279,6 @@ export default {
isSingleSegmentRoute,
convertSegmentedRouteToSingleSegmentRoute,
getCoordinatesFromAllDirections,
getMapboxLanguage,
getMapboxWorldview,
};
39 changes: 39 additions & 0 deletions tests/unit/MapViewUtilsTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,4 +180,43 @@ describe('MapView utils', () => {
expect(utils.isSingleSegmentRoute([])).toBe(true);
});
});

describe('getMapboxLanguage', () => {
it('passes locales that are already valid Mapbox codes through unchanged', () => {
expect(utils.getMapboxLanguage('en')).toBe('en');
expect(utils.getMapboxLanguage('es')).toBe('es');
expect(utils.getMapboxLanguage('fr')).toBe('fr');
expect(utils.getMapboxLanguage('de')).toBe('de');
});

it('remaps locales whose value differs from the Mapbox code', () => {
expect(utils.getMapboxLanguage('pt-BR')).toBe('pt');
expect(utils.getMapboxLanguage('zh-hans')).toBe('zh-Hans');
});

it('returns undefined when there is no locale', () => {
expect(utils.getMapboxLanguage(undefined)).toBeUndefined();
});
});

describe('getMapboxWorldview', () => {
it('passes country codes Mapbox defines a worldview for through unchanged', () => {
expect(utils.getMapboxWorldview('US')).toBe('US');
expect(utils.getMapboxWorldview('CN')).toBe('CN');
expect(utils.getMapboxWorldview('IN')).toBe('IN');
expect(utils.getMapboxWorldview('JP')).toBe('JP');
});

it('passes countries without a dedicated worldview through so Mapbox falls back to the style default', () => {
expect(utils.getMapboxWorldview('DE')).toBe('DE');
expect(utils.getMapboxWorldview('AU')).toBe('AU');
});

it('drops anything that is not a country code, which Mapbox would reject', () => {
expect(utils.getMapboxWorldview(undefined)).toBeUndefined();
expect(utils.getMapboxWorldview('')).toBeUndefined();
expect(utils.getMapboxWorldview('USA')).toBeUndefined();
expect(utils.getMapboxWorldview('us')).toBeUndefined();
});
});
});
Loading