diff --git a/bun.lockb b/bun.lockb index 024e997..8230f5f 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index b638162..8cc3324 100644 --- a/package.json +++ b/package.json @@ -27,9 +27,11 @@ "wrangler": "^3.78.2" }, "dependencies": { + "@formatjs/intl-localematcher": "^0.5.4", "@nanostores/persistent": "^0.10.2", "@nanostores/solid": "^0.4.2", "@solid-primitives/event-listener": "^2.3.3", + "@solid-primitives/i18n": "^2.1.1", "@solid-primitives/intersection-observer": "^2.1.6", "@solid-primitives/resize-observer": "^2.0.26", "@solidjs/router": "^0.14.5", diff --git a/src/App.tsx b/src/App.tsx index c3573a5..6ed8a9b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,6 +6,7 @@ import { createSignal, ErrorBoundary, lazy, + onCleanup, } from "solid-js"; import { useRootTheme } from "./material/mui.js"; import { @@ -15,6 +16,7 @@ import { } from "./masto/clients.js"; import { $accounts } from "./accounts/stores.js"; import { useStore } from "@nanostores/solid"; +import { DateFnScope, useLanguage } from "./platform/i18n.jsx"; const AccountSignIn = lazy(() => import("./accounts/SignIn.js")); const AccountMastodonOAuth2Callback = lazy( @@ -47,6 +49,7 @@ const App: Component = () => { const theme = useRootTheme(); const accts = useStore($accounts); const clientStore = createSignal([]); + const lang = useLanguage(); createRenderEffect(() => { const [, setClients] = clientStore; @@ -55,6 +58,16 @@ const App: Component = () => { ); }); + createRenderEffect(() => { + const root = document.querySelector(":root")!; + root.setAttribute("lang", lang()); + }); + + onCleanup(() => { + const root = document.querySelector(":root")!; + root.removeAttribute("lang"); + }); + const UnexpectedError = lazy(() => import("./UnexpectedError.js")); return ( @@ -65,9 +78,11 @@ const App: Component = () => { }} > - - - + + + + + ); diff --git a/src/platform/i18n.tsx b/src/platform/i18n.tsx new file mode 100644 index 0000000..7627ea1 --- /dev/null +++ b/src/platform/i18n.tsx @@ -0,0 +1,170 @@ +import { + ParentComponent, + createContext, + createMemo, + createRenderEffect, + createResource, + useContext, +} from "solid-js"; +import { match } from "@formatjs/intl-localematcher"; +import { Accessor, createEffect, createSignal } from "solid-js"; +import { $settings } from "../settings/stores"; +import { enGB } from "date-fns/locale/en-GB"; +import { useStore } from "@nanostores/solid"; +import type { Locale } from "date-fns"; +import type { Template } from "@solid-primitives/i18n"; + +async function synchronised( + name: string, + callback: () => Promise | void, +): Promise { + await navigator.locks.request(name, callback); +} + +export const SUPPORTED_LANGS = ["en", "zh-Hans"]; + +export const SUPPORTED_REGIONS = ["en_US", "en_GB", "zh_CN"]; + +const DEFAULT_LANG = "en"; + +/** + * Decide the using language for the user. + * @returns the selected language tag + */ +export function autoMatchLangTag() { + return match(Array.from(navigator.languages), SUPPORTED_LANGS, DEFAULT_LANG); +} + +const DateFnLocaleCx = createContext>(() => enGB); + +const cachedDateFnLocale: Record = { + enGB, +}; + +export function autoMatchRegion() { + const regions = navigator.languages + .map((x) => { + const parts = x.split("_"); + if (parts.length > 1) { + return parts[1]; + } + return undefined; + }) + .filter((x): x is string => !!x); + for (const r of regions) { + for (const available of SUPPORTED_REGIONS) { + if (available.toLowerCase().endsWith(r.toLowerCase())) { + return available; + } + } + } + return "en_GB"; +} + +export function useRegion() { + const appSettings = useStore($settings); + + return createMemo(() => { + const settings = appSettings(); + if (typeof settings.region !== "undefined") { + return settings.region; + } else { + return autoMatchRegion(); + } + }); +} + +async function importDateFnLocale(tag: string): Promise { + switch (tag.toLowerCase()) { + case "en_us": + return (await import("date-fns/locale/en-US")).enUS; + case "en_gb": + return (await import("date-fns/locale/en-GB")).enGB; + case "zh_cn": + return (await import("date-fns/locale/zh-CN")).zhCN; + default: + throw new TypeError(`unsupported tag "${tag}"`); + } +} + +/** + * Provides runtime values and fetch dependencies for date-fns locale + */ +export const DateFnScope: ParentComponent = (props) => { + const [dateFnLocale, setDateFnLocale] = createSignal(enGB); + const region = useRegion(); + + createEffect(() => { + const dateFnLocaleName = region(); + + if (cachedDateFnLocale[dateFnLocaleName]) { + setDateFnLocale(cachedDateFnLocale[dateFnLocaleName]); + } else { + synchronised("i18n-wrapper-load-date-fns-locale", async () => { + if (cachedDateFnLocale[dateFnLocaleName]) { + setDateFnLocale(cachedDateFnLocale[dateFnLocaleName]); + return; + } + const target = `date-fns/locale/${dateFnLocaleName}`; + try { + const mod = await importDateFnLocale(dateFnLocaleName); + cachedDateFnLocale[dateFnLocaleName] = mod; + setDateFnLocale(mod); + } catch (reason) { + console.error( + { + act: "load-date-fns-locale", + stat: "failed", + reason, + target, + }, + "failed to load date-fns locale", + ); + } + }); + } + }); + + return ( + + {props.children} + + ); +}; + +/** + * Get the {@link Locale} object for date-fns. + * + * This function must be using in {@link DateFnScope} + * + * @returns Accessor for Locale + */ +export function useDateFnLocale(): Accessor { + const cx = useContext(DateFnLocaleCx); + return cx; +} + +export function useLanguage() { + const settings = useStore($settings); + return () => settings().language || autoMatchLangTag(); +} + +export function createStringResource< + M extends Record>, +>(importFn: (code: string) => Promise<{ default: M }>) { + const language = useLanguage(); + const cache: Record = {}; + + return createResource( + () => [language()] as const, + async ([nlang]) => { + if (cache[nlang]) { + return cache[nlang]; + } + + const { default: dict } = await importFn(`${nlang}`); + + return dict; + }, + ); +} diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index 9eebcac..65d94b9 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -7,32 +7,50 @@ import { List, ListItem, ListItemButton, + ListItemIcon, ListItemSecondaryAction, ListItemText, ListSubheader, + NativeSelect, Switch, Toolbar, } from "@suid/material"; import { Close as CloseIcon, + Public as PublicIcon, Refresh as RefreshIcon, + Translate as TranslateIcon, } from "@suid/icons-material"; import { useNavigate } from "@solidjs/router"; import { Title } from "../material/typography.jsx"; import { css } from "solid-styled"; import { useSignedInProfiles } from "../masto/acct.js"; import { signOut, type Account } from "../accounts/stores.js"; -import { intlFormat } from "date-fns"; +import { format, intlFormat } from "date-fns"; import { useStore } from "@nanostores/solid"; import { $settings } from "./stores.js"; import { useRegisterSW } from "virtual:pwa-register/solid"; +import { + autoMatchLangTag, + autoMatchRegion, + createStringResource, + SUPPORTED_LANGS, + SUPPORTED_REGIONS, + useDateFnLocale, +} from "../platform/i18n.jsx"; +import { resolveTemplate, translator } from "@solid-primitives/i18n"; const Settings: ParentComponent = () => { + const [strings] = createStringResource( + (code) => import(`./i18n/${code}.json`), + ); + const t = translator(strings, resolveTemplate); const navigate = useNavigate(); const settings$ = useStore($settings); const { needRefresh: [needRefresh], } = useRegisterSW(); + const dateFnLocale = useDateFnLocale(); const [profiles] = useSignedInProfiles(); @@ -60,7 +78,7 @@ const Settings: ParentComponent = () => { - Settings + {t("Settings")} } @@ -68,16 +86,16 @@ const Settings: ParentComponent = () => {
    • - Accounts + {t("Accounts")} - All Notifications + {t("All Notifications")} - Sign in... + {t("Sign in...")}
    @@ -101,7 +119,7 @@ const Settings: ParentComponent = () => {
  • - Reading + {t("Reading")} Fonts @@ -114,8 +132,8 @@ const Settings: ParentComponent = () => { ) } > - - Prefetch Toots + + {t("Prefetch Toots")} @@ -124,24 +142,93 @@ const Settings: ParentComponent = () => {
  • - This Application + {t("This Application")} - - About Tutu + + + + {t("Language")} + + { + $settings.setKey( + "language", + e.currentTarget.value === "xauto" + ? undefined + : e.currentTarget.value, + ); + }} + > + + + {(code) => } + + + + + + + + + + {t("Region")} + + { + $settings.setKey( + "region", + e.currentTarget.value === "xauto" + ? undefined + : e.currentTarget.value, + ); + }} + > + + + {(code) => ( + + )} + + + + + + + + {t("About Tutu")} - {needRefresh() - ? "An update is ready, restart the Tutu to apply" - : "No updates"} + {needRefresh() ? t("updates.ready") : t("updates.no")} - window.location.reload()}> + window.location.reload()} + > diff --git a/src/settings/i18n/en.json b/src/settings/i18n/en.json new file mode 100644 index 0000000..07fb85d --- /dev/null +++ b/src/settings/i18n/en.json @@ -0,0 +1,25 @@ +{ + "Settings": "Settings", + "Accounts": "Accounts", + "All Notifications": "All Notifications", + "Sign in...": "Sign in...", + "Reading": "Reading", + "Prefetch Toots": "Prefetch Toots", + "Prefetch Toots.2nd": "Tutu will download the toots before you need.", + "This Application": "This Application", + "About Tutu": "About Tutu", + "About Tutu.2nd": "Comfortable tooting experience.", + "updates.ready": "An update is ready, restart the Tutu to apply", + "updates.no": "No updates", + "version": "Using v{{packageVersion}} (built on {{builtAt}}, {{buildMode}})", + "Language": "Language", + "Region": "Region", + "lang.auto": "Auto({{detected}})", + "lang.zh-Hans": "中文(简体)", + "lang.en": "English", + "region.auto": "Auto({{detected}})", + "region.en_GB": "Great Britan (English)", + "region.en_US": "United States (English)", + "region.zh_CN": "China Mainland (Chinese)", + "datefmt": "yyyy/MM/dd" +} \ No newline at end of file diff --git a/src/settings/i18n/zh-Hans.json b/src/settings/i18n/zh-Hans.json new file mode 100644 index 0000000..df9941e --- /dev/null +++ b/src/settings/i18n/zh-Hans.json @@ -0,0 +1,25 @@ +{ + "Settings": "设置", + "Accounts": "所有账号", + "All Notifications": "所有通知", + "Sign in...": "登录新账户...", + "Reading": "阅读", + "Prefetch Toots": "提前下载嘟文", + "Prefetch Toots.2nd": "图图会在你可能需要的时候提前下载嘟文。", + "This Application": "本应用", + "About Tutu": "关于图图", + "About Tutu.2nd": "舒服地刷嘟。", + "updates.ready": "更新已准备好,下次开启会启动新版本", + "updates.no": "已是最新版本", + "version": "正在使用 v{{packageVersion}} ({{builtAt}}构建, {{buildMode}})", + "Language": "语言", + "Region": "区域", + "lang.auto": "自动({{detected}})", + "lang.zh-Hans": "中文(简体)", + "lang.en": "English", + "region.auto": "自动({{detected}})", + "region.en_GB": "英国和苏格兰(英语)", + "region.en_US": "美国(英语)", + "region.zh_CN": "中国大陆(中文)", + "datefmt": "yyyy年MM月dd日" +} \ No newline at end of file diff --git a/src/settings/stores.ts b/src/settings/stores.ts index 25f6484..151f449 100644 --- a/src/settings/stores.ts +++ b/src/settings/stores.ts @@ -3,6 +3,8 @@ import { persistentMap } from "@nanostores/persistent"; type Settings = { onGoingOAuth2Process?: string; prefetchTootsDisabled?: boolean; + language?: string; + region?: string; }; export const $settings = persistentMap( diff --git a/src/timelines/RegularToot.tsx b/src/timelines/RegularToot.tsx index 97178c3..30376de 100644 --- a/src/timelines/RegularToot.tsx +++ b/src/timelines/RegularToot.tsx @@ -36,6 +36,7 @@ import Button from "../material/Button.js"; import MediaAttachmentGrid from "./MediaAttachmentGrid.js"; import { FastAverageColor } from "fast-average-color"; import Color from "colorjs.io"; +import { useDateFnLocale } from "../platform/i18n"; type TootContentViewProps = { source?: string; @@ -170,6 +171,7 @@ function TootActionGroup( function TootAuthorGroup(props: { status: mastodon.v1.Status; now: Date }) { const toot = () => props.status; + const dateFnLocale = useDateFnLocale() return (
    @@ -187,7 +189,7 @@ function TootAuthorGroup(props: { status: mastodon.v1.Status; now: Date }) { }} /> @{toot().account.username}@{new URL(toot().account.url).hostname}