diff --git a/bun.lockb b/bun.lockb index 024e997..25e9097 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index b638162..700f2f4 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", @@ -40,6 +42,7 @@ "date-fns": "^3.6.0", "fast-average-color": "^9.4.0", "hammerjs": "^2.0.8", + "iso-639-1": "^3.1.3", "masto": "^6.8.0", "nanostores": "^0.11.3", "solid-js": "^1.8.22", 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/masto/acct.ts b/src/masto/acct.ts index 97bbcec..ff9d488 100644 --- a/src/masto/acct.ts +++ b/src/masto/acct.ts @@ -12,11 +12,16 @@ export function useSignedInProfiles() { }); return [ () => { - const value = accessor(); - if (!value) { - return sessions().map((x) => ({ ...x, inf: x.account.inf })); + try { + const value = accessor(); + if (value) { + return value; + } + } catch (reason) { + console.error("useSignedInProfiles: update acct info failed", reason); } - return value; + + return sessions().map((x) => ({ ...x, inf: x.account.inf })); }, tools, ] as const; diff --git a/src/platform/i18n.tsx b/src/platform/i18n.tsx new file mode 100644 index 0000000..b31d888 --- /dev/null +++ b/src/platform/i18n.tsx @@ -0,0 +1,188 @@ +import { + ParentComponent, + createContext, + createMemo, + 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 { resolveTemplate, translator, 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"] as const; + +export const SUPPORTED_REGIONS = ["en_US", "en_GB", "zh_CN"] as const; + +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 = /* __@PURE__ */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(); +} + +type ImportFn = (name: string) => Promise<{default: T}> + +type ImportedModule = F extends ImportFn ? T: never + +type MergedImportedModule = + T extends [] ? {} : + T extends [infer I] ? ImportedModule : + T extends [infer I, ...infer J] ? ImportedModule & MergedImportedModule : never + +export function createStringResource< + T extends ImportFn | undefined>>[], +>(...importFns: T) { + const language = useLanguage(); + const cache: Record> = {}; + + return createResource( + () => [language()] as const, + async ([nlang]) => { + if (cache[nlang]) { + return cache[nlang]; + } + + const results = await Promise.all(importFns.map(x => x(nlang).then(v => v.default))) + + const merged: MergedImportedModule = Object.assign({}, ...results) + + cache[nlang] = merged; + + return merged; + }, + ); +} + +export function createTranslator | undefined>>[],>(...importFns: T) { + const res = createStringResource(...importFns) + + return [translator(res[0], resolveTemplate), res] as const +} diff --git a/src/settings/ChooseLang.tsx b/src/settings/ChooseLang.tsx new file mode 100644 index 0000000..ac03c7b --- /dev/null +++ b/src/settings/ChooseLang.tsx @@ -0,0 +1,116 @@ +import { createMemo, For, type Component, type JSX } from "solid-js"; +import Scaffold from "../material/Scaffold"; +import { + AppBar, + IconButton, + List, + ListItem, + ListItemButton, + ListItemSecondaryAction, + ListItemText, + ListSubheader, + Radio, + Switch, + Toolbar, +} from "@suid/material"; +import { Close as CloseIcon } from "@suid/icons-material"; +import iso639_1 from "iso-639-1"; +import { + autoMatchLangTag, + createTranslator, + SUPPORTED_LANGS, +} from "../platform/i18n"; +import { Title } from "../material/typography"; +import type { Template } from "@solid-primitives/i18n"; + +type ChooseLangProps = { + code?: string; + onCodeChange: (ncode?: string) => void; + onClose?: JSX.EventHandlerUnion; +}; + +const ChooseLang: Component = (props) => { + const [t] = createTranslator( + () => import("./i18n/lang-names.json"), + (code) => + import(`./i18n/${code}.json`) as Promise<{ + default: Record & { + ["lang.auto"]: Template<{ detected: string }>; + }; + }>, + ); + + const unsupportedLangCodes = createMemo(() => { + return iso639_1.getAllCodes().filter((x) => !["zh", "en"].includes(x)); + }); + + const matchedLangCode = createMemo(() => autoMatchLangTag()); + + return ( + + + + + + {t("Choose Language")} + + + } + > + + { + props.onCodeChange(props.code ? undefined : matchedLangCode()); + }} + > + + {t("lang.auto", { + detected: t(`lang.${matchedLangCode()}`) ?? matchedLangCode(), + })} + + + + + + {t("Supported")}}> + + {(code) => ( + + {t(`lang.${code}`)} + + + + + )} + + + + {t("Unsupported")}}> + + {(code) => ( + + {iso639_1.getNativeName(code)} + + )} + + + + + ); +}; + +export default ChooseLang; diff --git a/src/settings/ChooseRegion.tsx b/src/settings/ChooseRegion.tsx new file mode 100644 index 0000000..77a969e --- /dev/null +++ b/src/settings/ChooseRegion.tsx @@ -0,0 +1,106 @@ +import { createMemo, For, type Component, type JSX } from "solid-js"; +import Scaffold from "../material/Scaffold"; +import { + AppBar, + IconButton, + List, + ListItemButton, + ListItemSecondaryAction, + ListItemText, + ListSubheader, + Radio, + Switch, + Toolbar, +} from "@suid/material"; +import { Close as CloseIcon } from "@suid/icons-material"; +import iso639_1 from "iso-639-1"; +import { + autoMatchRegion, + createTranslator, + SUPPORTED_REGIONS, +} from "../platform/i18n"; +import { Title } from "../material/typography"; +import type { Template } from "@solid-primitives/i18n"; + +type ChooseRegionProps = { + code?: string; + onCodeChange: (ncode?: string) => void; + onClose?: JSX.EventHandlerUnion; +}; + +const ChooseRegion: Component = (props) => { + const [t] = createTranslator( + () => import("./i18n/lang-names.json"), + (code) => + import(`./i18n/${code}.json`) as Promise<{ + default: Record & { + ["lang.auto"]: Template<{ detected: string }>; + }; + }>, + ); + + const unsupportedLangCodes = createMemo(() => { + return iso639_1.getAllCodes().filter((x) => !["zh", "en"].includes(x)); + }); + + const matchedRegionCode = createMemo(() => autoMatchRegion()); + + return ( + + + + + + {t("Choose Language")} + + + } + > + + { + props.onCodeChange(props.code ? undefined : matchedRegionCode()); + }} + > + + {t("region.auto", { + detected: t(`region.${matchedRegionCode()}`) ?? matchedRegionCode(), + })} + + + + + + + {t("Supported")}}> + + {(code) => ( + + {t(`region.${code}`)} + + + + + )} + + + + + ); +}; + +export default ChooseRegion; diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index 9eebcac..ad9f66b 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -1,4 +1,4 @@ -import { For, Show, type ParentComponent } from "solid-js"; +import { createSignal, For, Show, type ParentComponent } from "solid-js"; import Scaffold from "../material/Scaffold.js"; import { AppBar, @@ -7,32 +7,63 @@ import { List, ListItem, ListItemButton, + ListItemIcon, ListItemSecondaryAction, ListItemText, ListSubheader, + NativeSelect, Switch, Toolbar, } from "@suid/material"; import { Close as CloseIcon, + Logout, + 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 } from "date-fns"; import { useStore } from "@nanostores/solid"; import { $settings } from "./stores.js"; import { useRegisterSW } from "virtual:pwa-register/solid"; +import { + autoMatchLangTag, + autoMatchRegion, + createTranslator, + SUPPORTED_LANGS, + SUPPORTED_REGIONS, + useDateFnLocale, +} from "../platform/i18n.jsx"; +import { type Template } from "@solid-primitives/i18n"; +import BottomSheet from "../material/BottomSheet.jsx"; +import ChooseLang from "./ChooseLang.jsx"; +import ChooseRegion from "./ChooseRegion.jsx"; + +type Strings = { + ["lang.auto"]: Template<{ detected: string }>; +} & Record; const Settings: ParentComponent = () => { + const [t] = createTranslator( + (code) => + import(`./i18n/${code}.json`) as Promise<{ + default: Strings; + }>, + () => import(`./i18n/lang-names.json`), + ); const navigate = useNavigate(); const settings$ = useStore($settings); const { needRefresh: [needRefresh], } = useRegisterSW(); + const dateFnLocale = useDateFnLocale(); + const [langPickerOpen, setLangPickerOpen] = createSignal(false); + const [regionPickerOpen, setRegionPickerOpen] = createSignal(false); const [profiles] = useSignedInProfiles(); @@ -60,7 +91,7 @@ const Settings: ParentComponent = () => { - Settings + {t("Settings")} } @@ -68,32 +99,35 @@ const Settings: ParentComponent = () => {
    • - Accounts - - All Notifications + {t("Accounts")} + + {t("All Notifications")} - + - + - - Sign in... - + + {t("Sign in...")} +
    {({ account: acct, inf }) => (
      {`@${inf?.username ?? "..."}@${new URL(acct.site).host}`} - - Notifications + + {t("Notifications")} - + - + - Sign out + + + + {t("Sign out")}
    @@ -101,12 +135,8 @@ const Settings: ParentComponent = () => {
  • - Reading - - Fonts - - - {t("Reading")} + $settings.setKey( "prefetchTootsDisabled", @@ -114,34 +144,93 @@ const Settings: ParentComponent = () => { ) } > - - Prefetch Toots + + {t("Prefetch Toots")} - +
  • - This Application + {t("This Application")} + + + + + + {t("Language")} + + + + $settings.setKey("language", nval)} + onClose={[setLangPickerOpen, false]} + /> + + + + + + + + {t("Region")} + + + + $settings.setKey("region", nval)} + onClose={[setRegionPickerOpen, false]} + /> + + - - About Tutu + + {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..095bc04 --- /dev/null +++ b/src/settings/i18n/en.json @@ -0,0 +1,30 @@ +{ + "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}}", + "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", + "Sign out": "Sign out", + "Notifications": "Notifications", + + "Choose Language": "Choose Language", + "Supported": "Supported", + "Unsupported": "Unsupported", + "Choose Region": "Choose Region" +} \ No newline at end of file diff --git a/src/settings/i18n/lang-names.json b/src/settings/i18n/lang-names.json new file mode 100644 index 0000000..6d250bd --- /dev/null +++ b/src/settings/i18n/lang-names.json @@ -0,0 +1,4 @@ +{ + "lang.zh-Hans": "中文(简体)", + "lang.en": "English" +} \ 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..8fd70d1 --- /dev/null +++ b/src/settings/i18n/zh-Hans.json @@ -0,0 +1,30 @@ +{ + "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}}", + "region.auto": "(自动){{detected}}", + "region.en_GB": "英国和苏格兰(英语)", + "region.en_US": "美国(英语)", + "region.zh_CN": "中国大陆(中文)", + "datefmt": "yyyy年MM月dd日", + "Sign out": "登出此账户", + "Notifications": "通知", + + "Choose Language": "选择语言", + "Supported": "已支持", + "Unsupported": "尚未支持", + "Choose Region": "选择区域" +} \ 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/Home.tsx b/src/timelines/Home.tsx index c09023d..b83de4d 100644 --- a/src/timelines/Home.tsx +++ b/src/timelines/Home.tsx @@ -10,7 +10,8 @@ import { children, Suspense, Match, - Switch as JsSwitch + Switch as JsSwitch, + ErrorBoundary } from "solid-js"; import { useDocumentTitle } from "../utils"; import { type mastodon } from "masto"; @@ -125,7 +126,9 @@ const TimelinePanel: Component<{ }; return ( - <> + { + return

    Oops: {String(err)}

    + }}> - +
    ); }; 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} diff --git a/tsconfig.json b/tsconfig.json index ccd018b..6cc68b0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,5 +11,6 @@ "types": ["vite/client", "vite-plugin-pwa/solid"], "noEmit": true, "isolatedModules": true, + "resolveJsonModule": true, } }