Skip to content

Commit

Permalink
initial implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
achou11 committed Feb 26, 2025
1 parent ae87f94 commit 50de297
Show file tree
Hide file tree
Showing 18 changed files with 809 additions and 154 deletions.
3 changes: 2 additions & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ const toolingConfig = pluginTs.config({
name: 'tooling',
files: [
'*.config.{js,mjs,cjs}',
'*.setup.js',
'scripts/*.{js,mjs,cjs}',
'expo-config-plugins/*.{js,mjs,cjs}',
],
Expand Down Expand Up @@ -101,7 +102,7 @@ const frontendConfig = pluginTs.config(
{
...pluginJest.configs['flat/recommended'],
name: 'eslint-plugin-jest',
files: ['src/frontend/**/*.test.{js,jsx,mts,ts,tsx}'],
files: ['jest.setup.js', 'src/frontend/**/*.test.{js,jsx,mts,ts,tsx}'],
},
);

Expand Down
2 changes: 1 addition & 1 deletion jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
const config = {
preset: 'jest-expo',
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
setupFilesAfterEnv: ['@rnmapbox/maps/setup-jest'],
setupFilesAfterEnv: ['@rnmapbox/maps/setup-jest', './jest.setup.js'],
transformIgnorePatterns: [
'node_modules/(?!(...|@rnmapbox|(jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@sentry/react-native|native-base|react-native-svg)',
],
Expand Down
34 changes: 34 additions & 0 deletions jest.setup.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/**
* @import {Locale} from 'expo-localization'
*/

jest.mock('expo-localization', () => {
return {
getLocales: () => [createBaseLocale('en-US')],
useLocales: () => {
return [createBaseLocale('en-US')];
},
};

/**
* @param {string} languageTag
* @returns {Locale}
*/
function createBaseLocale(languageTag) {
return {
languageTag,
languageCode: null,
langageCurrencyCode: null,
langageCurrencySymbol: null,
languageRegionCode: null,
regionCode: null,
currencyCode: null,
currencySymbol: null,
decimalSeparator: null,
digitGroupingSeparator: null,
textDirection: null,
measurementSystem: null,
temperatureUnit: null,
};
}
});
8 changes: 7 additions & 1 deletion src/frontend/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {getSentryUserId} from './metrics/getSentryUserId';
import {AppDiagnosticMetrics} from './metrics/AppDiagnosticMetrics';
import {DeviceDiagnosticMetrics} from './metrics/DeviceDiagnosticMetrics';
import {createDraftObservationStore} from './contexts/PersistedStores/DraftObservationStore';
import {createSelectedLocaleStore} from './contexts/SelectedLocaleContext';

type SentryEnvironment = 'development' | 'qa' | 'production';

Expand Down Expand Up @@ -77,6 +78,10 @@ const persistedDraftObservationStore = createDraftObservationStore({
persist: true,
});

const persistedSelectedLocaleStore = createSelectedLocaleStore({
persist: true,
});

const App = () => {
const [permissionsAsked, setPermissionsAsked] = React.useState(false);
React.useEffect(() => {
Expand All @@ -96,7 +101,8 @@ const App = () => {
mapeoApi={mapeoApi}
appMetrics={appDiagnosticMetrics}
deviceMetrics={deviceDiagnosticMetrics}
persistedDrafObservationStore={persistedDraftObservationStore}>
persistedDrafObservationStore={persistedDraftObservationStore}
selectedLocaleStore={persistedSelectedLocaleStore}>
<AppNavigator permissionAsked={permissionsAsked} />
</AppProviders>
);
Expand Down
78 changes: 44 additions & 34 deletions src/frontend/contexts/AppProviders.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ import {AppDiagnosticMetrics} from '../metrics/AppDiagnosticMetrics';
import {DeviceDiagnosticMetrics} from '../metrics/DeviceDiagnosticMetrics';
import {DraftObservationProvider} from './DraftObservationContext';
import {DraftObservationStore} from './PersistedStores/DraftObservationStore';
import {
SelectedLocaleStore,
SelectedLocaleStoreProvider,
} from './SelectedLocaleContext';

type AppProvidersProps = {
children: React.ReactNode;
Expand All @@ -37,6 +41,7 @@ type AppProvidersProps = {
appMetrics: AppDiagnosticMetrics;
deviceMetrics: DeviceDiagnosticMetrics;
persistedDrafObservationStore: DraftObservationStore;
selectedLocaleStore: SelectedLocaleStore;
};

const queryClient = new QueryClient();
Expand All @@ -49,42 +54,47 @@ export const AppProviders = ({
appMetrics,
deviceMetrics,
persistedDrafObservationStore,
selectedLocaleStore,
}: AppProvidersProps) => {
return (
<IntlProvider>
<QueryClientProvider client={queryClient}>
<SafeAreaProvider>
<GestureHandlerRootView style={styles.flex}>
<TrackTimerContextProvider>
<GPSModalContextProvider>
<ServerLoading messagePort={messagePort}>
<LocalDiscoveryProvider value={localDiscoveryController}>
<ClientApiProvider clientApi={mapeoApi}>
<MetricsProvider
appMetrics={appMetrics}
deviceMetrics={deviceMetrics}>
<ActiveProjectProvider>
<BottomSheetModalProvider>
<PhotoPromiseProvider>
<DraftObservationProvider
draftObservationStore={
persistedDrafObservationStore
}>
<SecurityProvider>{children}</SecurityProvider>
</DraftObservationProvider>
</PhotoPromiseProvider>
</BottomSheetModalProvider>
</ActiveProjectProvider>
</MetricsProvider>
</ClientApiProvider>
</LocalDiscoveryProvider>
</ServerLoading>
</GPSModalContextProvider>
</TrackTimerContextProvider>
</GestureHandlerRootView>
</SafeAreaProvider>
</QueryClientProvider>
</IntlProvider>
<SelectedLocaleStoreProvider value={selectedLocaleStore}>
<IntlProvider>
<QueryClientProvider client={queryClient}>
<SafeAreaProvider>
<GestureHandlerRootView style={styles.flex}>
<TrackTimerContextProvider>
<GPSModalContextProvider>
<ServerLoading messagePort={messagePort}>
<LocalDiscoveryProvider value={localDiscoveryController}>
<ClientApiProvider clientApi={mapeoApi}>
<MetricsProvider
appMetrics={appMetrics}
deviceMetrics={deviceMetrics}>
<ActiveProjectProvider>
<BottomSheetModalProvider>
<PhotoPromiseProvider>
<DraftObservationProvider
draftObservationStore={
persistedDrafObservationStore
}>
<SecurityProvider>
{children}
</SecurityProvider>
</DraftObservationProvider>
</PhotoPromiseProvider>
</BottomSheetModalProvider>
</ActiveProjectProvider>
</MetricsProvider>
</ClientApiProvider>
</LocalDiscoveryProvider>
</ServerLoading>
</GPSModalContextProvider>
</TrackTimerContextProvider>
</GestureHandlerRootView>
</SafeAreaProvider>
</QueryClientProvider>
</IntlProvider>
</SelectedLocaleStoreProvider>
);
};

Expand Down
27 changes: 13 additions & 14 deletions src/frontend/contexts/IntlContext.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import * as React from 'react';
import {IntlProvider as ReactIntlProvider, CustomFormats} from 'react-intl';
import {CustomFormats, IntlProvider as ReactIntlProvider} from 'react-intl';
import {StyleSheet, Text} from 'react-native';

import messages from '../../../translations/messages.json';
import {usePersistedLocale} from '../hooks/persistedState/usePersistedLocale';
import {TranslatedLocale} from '../lib/intl';
import {useResolvedLanguageTag} from '../hooks/useResolvedLanguageTag';
import {extractLanguageCode, type TranslatedLanguageTag} from '../lib/intl';

export const formats: CustomFormats = {
date: {
Expand All @@ -25,20 +25,19 @@ const DEFAULT_RICH_TEXT_MAPPINGS: NonNullable<
};

export const IntlProvider = ({children}: {children: React.ReactNode}) => {
const appLocale = usePersistedLocale(store => store.locale);

const languageCode = appLocale.split('-')[0];

// Add fallbacks for non-regional locales (e.g. "en" for "en-GB")
const localeMessages = {
...messages[languageCode as TranslatedLocale],
...(messages[appLocale as TranslatedLocale] || {}),
};
const resolvedLanguageTag = useResolvedLanguageTag();
const languageCode = extractLanguageCode(resolvedLanguageTag);

return (
<ReactIntlProvider
locale={appLocale}
messages={localeMessages}
locale={resolvedLanguageTag}
messages={
// Add fallbacks for non-regional tags (e.g. "en" for "en-GB")
{
...(messages[languageCode as TranslatedLanguageTag] || {}),
...(messages[resolvedLanguageTag as TranslatedLanguageTag] || {}),
}
}
formats={formats}
onError={onError}
wrapRichTextChunksInFragment
Expand Down
103 changes: 103 additions & 0 deletions src/frontend/contexts/SelectedLocaleContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import {createContext, useContext} from 'react';
import {createStore, useStore, type StoreApi} from 'zustand';
import {
createJSONStorage,
persist as createPersistedState,
} from 'zustand/middleware';

import {MMKVZustandStorage} from '../hooks/persistedState/createPersistedState';

export const STORAGE_KEY = 'MapeoLocale';

export type SelectedLocaleState = {
/**
* Value consisting of a language tag (see https://en.wikipedia.org/wiki/IETF_language_tag)
* Represents the language that is explicitly chosen via a user action within the app. If null, it means that either:
*
* 1. The user has never chosen the language explicitly.
* 2. The user has unset the language (e.g. to defer to system preferences)
*/
languageTag: string | null;
};

function createInitialState() {
return {
languageTag: null,
};
}

export function createSelectedLocaleStore({persist} = {persist: false}) {
let store: StoreApi<SelectedLocaleState>;

if (persist) {
store = createStore(
createPersistedState(createInitialState, {
name: STORAGE_KEY,
storage: createJSONStorage(() => MMKVZustandStorage),
version: 1,
migrate: (persistedState, version) => {
/**
* Version 0 stores the state as `{ locale: string, setLocale: (locale: string) => void }`.
* We only need to handle the `locale` field, which is more specifically a language tag.
*/
if (version === 0) {
// Ensure that the persisted state for version has expected shape before attempting to migrate
if (
typeof persistedState === 'object' &&
persistedState !== null &&
'locale' in persistedState &&
typeof persistedState.locale === 'string'
) {
// TODO: log to Sentry to help understand how often this is happening?
return {languageTag: persistedState.locale};
}
}

return {languageTag: null};
},
}),
);
} else {
store = createStore(createInitialState);
}

const actions = {
setLanguageTag: (languageTag: string | null) => {
store.setState({languageTag});
},
};

return {instance: store, actions};
}

export type SelectedLocaleStore = ReturnType<typeof createSelectedLocaleStore>;

const SelectedLocaleContext = createContext<SelectedLocaleStore | null>(null);

export const SelectedLocaleStoreProvider = SelectedLocaleContext.Provider;

function useSelectedLocaleContext() {
const value = useContext(SelectedLocaleContext);

if (!value) {
throw new Error('Must set up SelectedLocaleStoreProvider first');
}

return value;
}

export function useSelectedLocaleState(): SelectedLocaleState;
export function useSelectedLocaleState<T>(
selector: (state: SelectedLocaleState) => T,
): T;
export function useSelectedLocaleState<T>(
selector?: (state: SelectedLocaleState) => T,
) {
const {instance} = useSelectedLocaleContext();
return useStore(instance, selector!);
}

export function useSelectedLocaleActions() {
const {actions} = useSelectedLocaleContext();
return actions;
}
2 changes: 1 addition & 1 deletion src/frontend/hooks/persistedState/createPersistedState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ type PersistedStoreKey =
| 'ActiveProjectId'
| 'Settings'
| 'MetricDiagnosticsPermission';
const MMKVZustandStorage: StateStorage = {
export const MMKVZustandStorage: StateStorage = {
setItem: (name, value) => {
return storage.set(name, value);
},
Expand Down
26 changes: 0 additions & 26 deletions src/frontend/hooks/persistedState/usePersistedLocale.ts

This file was deleted.

4 changes: 2 additions & 2 deletions src/frontend/hooks/server/fields.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import {useQuery} from '@tanstack/react-query';
import {useActiveProject} from '../../contexts/ActiveProjectContext';
import {usePersistedLocale} from '../persistedState/usePersistedLocale';
import {useResolvedLanguageTag} from '../useResolvedLanguageTag';

export const FIELDS_KEY = 'fields';

export const useFieldsQuery = () => {
const {projectId, projectApi} = useActiveProject();
const lang = usePersistedLocale(store => store.locale);
const lang = useResolvedLanguageTag();

return useQuery({
queryKey: [FIELDS_KEY, projectId, lang],
Expand Down
Loading

0 comments on commit 50de297

Please sign in to comment.