feat(mobx): implement locale options

pull/1049/head
Paul 2021-12-11 11:56:33 +00:00
parent 3fc49a3835
commit 7c665b3b40
5 changed files with 155 additions and 144 deletions

View File

@ -1,23 +1,21 @@
import { dispatch } from "../../redux"; import { useApplicationState } from "../../mobx/State";
import { connectState } from "../../redux/connector";
import { Language, Languages } from "../../context/Locale"; import { Language, Languages } from "../../context/Locale";
import ComboBox from "../ui/ComboBox"; import ComboBox from "../ui/ComboBox";
type Props = { /**
locale: string; * Component providing a language selector combobox.
}; * Note: this is not an observer but this is fine as we are just using a combobox.
*/
export default function LocaleSelector() {
const locale = useApplicationState().locale;
export function LocaleSelector(props: Props) {
return ( return (
<ComboBox <ComboBox
value={props.locale} value={locale.getLanguage()}
onChange={(e) => onChange={(e) =>
dispatch({ locale.setLanguage(e.currentTarget.value as Language)
type: "SET_LOCALE",
locale: e.currentTarget.value as Language,
})
}> }>
{Object.keys(Languages).map((x) => { {Object.keys(Languages).map((x) => {
const l = Languages[x as keyof typeof Languages]; const l = Languages[x as keyof typeof Languages];
@ -30,9 +28,3 @@ export function LocaleSelector(props: Props) {
</ComboBox> </ComboBox>
); );
} }
export default connectState(LocaleSelector, (state) => {
return {
locale: state.locale,
};
});

View File

@ -3,11 +3,12 @@ import calendar from "dayjs/plugin/calendar";
import format from "dayjs/plugin/localizedFormat"; import format from "dayjs/plugin/localizedFormat";
import update from "dayjs/plugin/updateLocale"; import update from "dayjs/plugin/updateLocale";
import defaultsDeep from "lodash.defaultsdeep"; import defaultsDeep from "lodash.defaultsdeep";
import { observer } from "mobx-react-lite";
import { IntlProvider } from "preact-i18n"; import { IntlProvider } from "preact-i18n";
import { useCallback, useEffect, useState } from "preact/hooks"; import { useCallback, useEffect, useState } from "preact/hooks";
import { connectState } from "../redux/connector"; import { useApplicationState } from "../mobx/State";
import definition from "../../external/lang/en.json"; import definition from "../../external/lang/en.json";
@ -222,59 +223,14 @@ export interface Dictionary {
| undefined; | undefined;
} }
function Locale({ children, locale }: Props) { export default observer(({ children }: Props) => {
const [defns, setDefinition] = useState<Dictionary>( const locale = useApplicationState().locale;
const [definitions, setDefinition] = useState<Dictionary>(
definition as Dictionary, definition as Dictionary,
); );
// Load relevant language information, fallback to English if invalid. const lang = locale.getLanguage();
const lang = Languages[locale] ?? Languages.en; const source = Languages[lang];
function transformLanguage(source: Dictionary) {
// Fallback untranslated strings to English (UK)
const obj = defaultsDeep(source, definition);
// Take relevant objects out, dayjs and defaults
// should exist given we just took defaults above.
const { dayjs } = obj;
const { defaults } = dayjs;
// Determine whether we are using 12-hour clock.
const twelvehour = defaults?.twelvehour
? defaults.twelvehour === "yes"
: false;
// Determine what date separator we are using.
const separator: string = defaults?.date_separator ?? "/";
// Determine what date format we are using.
const date: "traditional" | "simplified" | "ISO8601" =
defaults?.date_format ?? "traditional";
// Available date formats.
const DATE_FORMATS = {
traditional: `DD${separator}MM${separator}YYYY`,
simplified: `MM${separator}DD${separator}YYYY`,
ISO8601: "YYYY-MM-DD",
};
// Replace data in dayjs object, make sure to provide fallbacks.
dayjs["sameElse"] = DATE_FORMATS[date] ?? DATE_FORMATS.traditional;
dayjs["timeFormat"] = twelvehour ? "hh:mm A" : "HH:mm";
// Replace {{time}} format string in dayjs strings with the time format.
Object.keys(dayjs)
.filter((k) => typeof dayjs[k] === "string")
.forEach(
(k) =>
(dayjs[k] = dayjs[k].replace(
/{{time}}/g,
dayjs["timeFormat"],
)),
);
return obj;
}
const loadLanguage = useCallback( const loadLanguage = useCallback(
(locale: string) => { (locale: string) => {
@ -288,13 +244,13 @@ function Locale({ children, locale }: Props) {
return; return;
} }
import(`../../external/lang/${lang.i18n}.json`).then( import(`../../external/lang/${source.i18n}.json`).then(
async (lang_file) => { async (lang_file) => {
// Transform the definitions data. // Transform the definitions data.
const defn = transformLanguage(lang_file.default); const defn = transformLanguage(lang_file.default);
// Determine and load dayjs locales. // Determine and load dayjs locales.
const target = lang.dayjs ?? lang.i18n; const target = source.dayjs ?? source.i18n;
const dayjs_locale = await import( const dayjs_locale = await import(
`../../node_modules/dayjs/esm/locale/${target}.js` `../../node_modules/dayjs/esm/locale/${target}.js`
); );
@ -312,25 +268,63 @@ function Locale({ children, locale }: Props) {
}, },
); );
}, },
[lang.dayjs, lang.i18n], [source.dayjs, source.i18n],
); );
useEffect(() => loadLanguage(locale), [locale, lang, loadLanguage]); useEffect(() => loadLanguage(lang), [lang, source, loadLanguage]);
useEffect(() => { useEffect(() => {
// Apply RTL language format. // Apply RTL language format.
document.body.style.direction = lang.rtl ? "rtl" : ""; document.body.style.direction = source.rtl ? "rtl" : "";
}, [lang.rtl]); }, [source.rtl]);
return <IntlProvider definition={defns}>{children}</IntlProvider>; return <IntlProvider definition={definitions}>{children}</IntlProvider>;
});
/**
* Apply defaults and process dayjs entries for a langauge.
* @param source Dictionary definition to transform
* @returns Transformed dictionary definition
*/
function transformLanguage(source: Dictionary) {
// Fallback untranslated strings to English (UK)
const obj = defaultsDeep(source, definition);
// Take relevant objects out, dayjs and defaults
// should exist given we just took defaults above.
const { dayjs } = obj;
const { defaults } = dayjs;
// Determine whether we are using 12-hour clock.
const twelvehour = defaults?.twelvehour
? defaults.twelvehour === "yes"
: false;
// Determine what date separator we are using.
const separator: string = defaults?.date_separator ?? "/";
// Determine what date format we are using.
const date: "traditional" | "simplified" | "ISO8601" =
defaults?.date_format ?? "traditional";
// Available date formats.
const DATE_FORMATS = {
traditional: `DD${separator}MM${separator}YYYY`,
simplified: `MM${separator}DD${separator}YYYY`,
ISO8601: "YYYY-MM-DD",
};
// Replace data in dayjs object, make sure to provide fallbacks.
dayjs["sameElse"] = DATE_FORMATS[date] ?? DATE_FORMATS.traditional;
dayjs["timeFormat"] = twelvehour ? "hh:mm A" : "HH:mm";
// Replace {{time}} format string in dayjs strings with the time format.
Object.keys(dayjs)
.filter((k) => typeof dayjs[k] === "string")
.forEach(
(k) =>
(dayjs[k] = dayjs[k].replace(/{{time}}/g, dayjs["timeFormat"])),
);
return obj;
} }
export default connectState<Omit<Props, "locale">>(
Locale,
(state) => {
return {
locale: state.locale,
};
},
true,
);

View File

@ -5,6 +5,7 @@ import { useContext } from "preact/hooks";
import Auth from "./stores/Auth"; import Auth from "./stores/Auth";
import Draft from "./stores/Draft"; import Draft from "./stores/Draft";
import LocaleOptions from "./stores/LocaleOptions";
interface StoreDefinition { interface StoreDefinition {
id: string; id: string;
@ -20,6 +21,7 @@ interface StoreDefinition {
export default class State { export default class State {
auth: Auth; auth: Auth;
draft: Draft; draft: Draft;
locale: LocaleOptions;
/** /**
* Construct new State. * Construct new State.
@ -27,6 +29,7 @@ export default class State {
constructor() { constructor() {
this.auth = new Auth(); this.auth = new Auth();
this.draft = new Draft(); this.draft = new Draft();
this.locale = new LocaleOptions();
makeAutoObservable(this); makeAutoObservable(this);
} }

View File

@ -72,13 +72,21 @@ export default class LocaleOptions implements Persistent<Data> {
// eslint-disable-next-line require-jsdoc // eslint-disable-next-line require-jsdoc
@action hydrate(data: Data) { @action hydrate(data: Data) {
this.lang = data.lang; this.setLanguage(data.lang);
} }
/** /**
* Get current language. * Get current language.
*/ */
@computed getLang() { @computed getLanguage() {
return this.lang; return this.lang;
} }
/**
* Set current language.
*/
@action setLanguage(language: Language) {
if (typeof Languages[language] === "undefined") return;
this.lang = language;
}
} }

View File

@ -1,6 +1,13 @@
import { observer } from "mobx-react-lite";
import styles from "./Panes.module.scss"; import styles from "./Panes.module.scss";
import { Text } from "preact-i18n"; import { Text } from "preact-i18n";
import { useMemo } from "preact/hooks";
import PaintCounter from "../../../lib/PaintCounter";
import { useApplicationState } from "../../../mobx/State";
import LocaleOptions from "../../../mobx/stores/LocaleOptions";
import { dispatch } from "../../../redux"; import { dispatch } from "../../../redux";
import { connectState } from "../../../redux/connector"; import { connectState } from "../../../redux/connector";
@ -17,26 +24,25 @@ import enchantingTableWEBP from "../assets/enchanting_table.webp";
import tamilFlagPNG from "../assets/tamil_nadu_flag.png"; import tamilFlagPNG from "../assets/tamil_nadu_flag.png";
import tokiponaSVG from "../assets/toki_pona.svg"; import tokiponaSVG from "../assets/toki_pona.svg";
type Props = { type Key = [Language, LanguageEntry];
locale: Language;
};
type Key = [string, LanguageEntry]; interface Props {
entry: Key;
selected: boolean;
onSelect: () => void;
}
function Entry({ entry: [x, lang], locale }: { entry: Key } & Props) { /**
* Component providing individual language entries.
* @param param0 Entry data
*/
function Entry({ entry: [x, lang], selected, onSelect }: Props) {
return ( return (
<Checkbox <Checkbox
key={x} key={x}
className={styles.entry} className={styles.entry}
checked={locale === x} checked={selected}
onChange={(v) => { onChange={onSelect}>
if (v) {
dispatch({
type: "SET_LOCALE",
locale: x as Language,
});
}
}}>
<div className={styles.flag}> <div className={styles.flag}>
{lang.i18n === "ta" ? ( {lang.i18n === "ta" ? (
<img <img
@ -61,36 +67,58 @@ function Entry({ entry: [x, lang], locale }: { entry: Key } & Props) {
); );
} }
export function Component(props: Props) { /**
const languages = Object.keys(Langs).map((x) => [ * Component providing the language selection menu.
x, */
Langs[x as keyof typeof Langs], export const Languages = observer(() => {
]) as Key[]; const locale = useApplicationState().locale;
const language = locale.getLanguage();
// Get the user's system language. Check for exact // Generate languages array.
// matches first, otherwise check for partial matches const languages = useMemo(() => {
const preferredLanguage = const languages = Object.keys(Langs).map((x) => [
navigator.languages.filter((lang) => x,
languages.find((l) => l[0].replace(/_/g, "-") == lang), Langs[x as keyof typeof Langs],
)?.[0] || ]) as Key[];
navigator.languages
?.map((x) => x.split("-")[0])
?.filter((lang) => languages.find((l) => l[0] == lang))?.[0]
?.split("-")[0];
if (preferredLanguage) { // Get the user's system language. Check for exact
// This moves the user's system language to the top of the language list // matches first, otherwise check for partial matches
const prefLangKey = languages.find( const preferredLanguage =
(lang) => lang[0].replace(/_/g, "-") == preferredLanguage, navigator.languages.filter((lang) =>
); languages.find((l) => l[0].replace(/_/g, "-") == lang),
if (prefLangKey) { )?.[0] ||
languages.splice( navigator.languages
0, ?.map((x) => x.split("-")[0])
0, ?.filter((lang) => languages.find((l) => l[0] == lang))?.[0]
languages.splice(languages.indexOf(prefLangKey), 1)[0], ?.split("-")[0];
if (preferredLanguage) {
// This moves the user's system language to the top of the language list
const prefLangKey = languages.find(
(lang) => lang[0].replace(/_/g, "-") == preferredLanguage,
); );
if (prefLangKey) {
languages.splice(
0,
0,
languages.splice(languages.indexOf(prefLangKey), 1)[0],
);
}
} }
}
return languages;
}, []);
// Creates entries with given key.
const EntryFactory = ([x, lang]: Key) => (
<Entry
key={x}
entry={[x, lang]}
selected={language === x}
onSelect={() => locale.setLanguage(x)}
/>
);
return ( return (
<div className={styles.languages}> <div className={styles.languages}>
@ -98,11 +126,7 @@ export function Component(props: Props) {
<Text id="app.settings.pages.language.select" /> <Text id="app.settings.pages.language.select" />
</h3> </h3>
<div className={styles.list}> <div className={styles.list}>
{languages {languages.filter(([, lang]) => !lang.cat).map(EntryFactory)}
.filter(([, lang]) => !lang.cat)
.map(([x, lang]) => (
<Entry key={x} entry={[x, lang]} {...props} />
))}
</div> </div>
<h3> <h3>
<Text id="app.settings.pages.language.const" /> <Text id="app.settings.pages.language.const" />
@ -110,9 +134,7 @@ export function Component(props: Props) {
<div className={styles.list}> <div className={styles.list}>
{languages {languages
.filter(([, lang]) => lang.cat === "const") .filter(([, lang]) => lang.cat === "const")
.map(([x, lang]) => ( .map(EntryFactory)}
<Entry key={x} entry={[x, lang]} {...props} />
))}
</div> </div>
<h3> <h3>
<Text id="app.settings.pages.language.other" /> <Text id="app.settings.pages.language.other" />
@ -120,9 +142,7 @@ export function Component(props: Props) {
<div className={styles.list}> <div className={styles.list}>
{languages {languages
.filter(([, lang]) => lang.cat === "alt") .filter(([, lang]) => lang.cat === "alt")
.map(([x, lang]) => ( .map(EntryFactory)}
<Entry key={x} entry={[x, lang]} {...props} />
))}
</div> </div>
<Tip> <Tip>
<span> <span>
@ -137,10 +157,4 @@ export function Component(props: Props) {
</Tip> </Tip>
</div> </div>
); );
}
export const Languages = connectState(Component, (state) => {
return {
locale: state.locale,
};
}); });