Compare commits
	
		
			6 commits
		
	
	
		
			d3a7602fb5
			...
			69f7f37a2c
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 69f7f37a2c | |||
|  | e6a14a8a26 | ||
|  | e56dc4bf7b | ||
|  | 55705b0a6d | ||
|  | c49ae6fe0a | ||
|  | b4fa751345 | 
					 15 changed files with 638 additions and 44 deletions
				
			
		
							
								
								
									
										
											BIN
										
									
								
								bun.lockb
									
										
									
									
									
								
							
							
						
						
									
										
											BIN
										
									
								
								bun.lockb
									
										
									
									
									
								
							
										
											Binary file not shown.
										
									
								
							|  | @ -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", | ||||
|  |  | |||
							
								
								
									
										15
									
								
								src/App.tsx
									
										
									
									
									
								
							
							
						
						
									
										15
									
								
								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<Session[]>([]); | ||||
|   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 = () => { | |||
|       }} | ||||
|     > | ||||
|       <ThemeProvider theme={theme()}> | ||||
|         <DateFnScope> | ||||
|           <ClientProvider value={clientStore}> | ||||
|             <Routing /> | ||||
|           </ClientProvider> | ||||
|         </DateFnScope> | ||||
|       </ThemeProvider> | ||||
|     </ErrorBoundary> | ||||
|   ); | ||||
|  |  | |||
|  | @ -12,11 +12,16 @@ export function useSignedInProfiles() { | |||
|   }); | ||||
|   return [ | ||||
|     () => { | ||||
|       try { | ||||
|         const value = accessor(); | ||||
|       if (!value) { | ||||
|         return sessions().map((x) => ({ ...x, inf: x.account.inf })); | ||||
|       } | ||||
|         if (value) { | ||||
|           return value; | ||||
|         } | ||||
|       } catch (reason) { | ||||
|         console.error("useSignedInProfiles: update acct info failed", reason); | ||||
|       } | ||||
| 
 | ||||
|       return sessions().map((x) => ({ ...x, inf: x.account.inf })); | ||||
|     }, | ||||
|     tools, | ||||
|   ] as const; | ||||
|  |  | |||
							
								
								
									
										188
									
								
								src/platform/i18n.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										188
									
								
								src/platform/i18n.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -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> | void, | ||||
| ): Promise<void> { | ||||
|   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<Accessor<Locale>>(() => enGB); | ||||
| 
 | ||||
| const cachedDateFnLocale: Record<string, Locale> = { | ||||
|   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<Locale> { | ||||
|   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 ( | ||||
|     <DateFnLocaleCx.Provider value={dateFnLocale}> | ||||
|       {props.children} | ||||
|     </DateFnLocaleCx.Provider> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
|  * Get the {@link Locale} object for date-fns. | ||||
|  * | ||||
|  * This function must be using in {@link DateFnScope} | ||||
|  * | ||||
|  * @returns Accessor for Locale | ||||
|  */ | ||||
| export function useDateFnLocale(): Accessor<Locale> { | ||||
|   const cx = useContext(DateFnLocaleCx); | ||||
|   return cx; | ||||
| } | ||||
| 
 | ||||
| export function useLanguage() { | ||||
|   const settings = useStore($settings); | ||||
|   return () => settings().language || autoMatchLangTag(); | ||||
| } | ||||
| 
 | ||||
| type ImportFn<T> = (name: string) => Promise<{default: T}> | ||||
| 
 | ||||
| type ImportedModule<F> = F extends ImportFn<infer T> ? T: never | ||||
| 
 | ||||
| type MergedImportedModule<T> = | ||||
|   T extends [] ? {} : | ||||
|   T extends [infer I] ? ImportedModule<I> : | ||||
|   T extends [infer I, ...infer J] ? ImportedModule<I> & MergedImportedModule<J> : never | ||||
| 
 | ||||
| export function createStringResource< | ||||
|   T extends ImportFn<Record<string, string | Template<any> | undefined>>[], | ||||
| >(...importFns: T) { | ||||
|   const language = useLanguage(); | ||||
|   const cache: Record<string, MergedImportedModule<T>> = {}; | ||||
| 
 | ||||
|   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<T> = Object.assign({}, ...results) | ||||
| 
 | ||||
|       cache[nlang] = merged; | ||||
| 
 | ||||
|       return merged; | ||||
|     }, | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
| export function createTranslator<T extends ImportFn<Record<string, string | Template<any> | undefined>>[],>(...importFns: T) { | ||||
|   const res = createStringResource(...importFns) | ||||
| 
 | ||||
|   return [translator(res[0], resolveTemplate), res] as const | ||||
| } | ||||
							
								
								
									
										116
									
								
								src/settings/ChooseLang.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										116
									
								
								src/settings/ChooseLang.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -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<HTMLButtonElement, MouseEvent>; | ||||
| }; | ||||
| 
 | ||||
| const ChooseLang: Component<ChooseLangProps> = (props) => { | ||||
|   const [t] = createTranslator( | ||||
|     () => import("./i18n/lang-names.json"), | ||||
|     (code) => | ||||
|       import(`./i18n/${code}.json`) as Promise<{ | ||||
|         default: Record<string, string | undefined> & { | ||||
|           ["lang.auto"]: Template<{ detected: string }>; | ||||
|         }; | ||||
|       }>, | ||||
|   ); | ||||
| 
 | ||||
|   const unsupportedLangCodes = createMemo(() => { | ||||
|     return iso639_1.getAllCodes().filter((x) => !["zh", "en"].includes(x)); | ||||
|   }); | ||||
| 
 | ||||
|   const matchedLangCode = createMemo(() => autoMatchLangTag()); | ||||
| 
 | ||||
|   return ( | ||||
|     <Scaffold | ||||
|       topbar={ | ||||
|         <AppBar position="static"> | ||||
|           <Toolbar | ||||
|             variant="dense" | ||||
|             sx={{ paddingTop: "var(--safe-area-inset-top, 0px)" }} | ||||
|           > | ||||
|             <IconButton color="inherit" onClick={props.onClose} disableRipple> | ||||
|               <CloseIcon /> | ||||
|             </IconButton> | ||||
|             <Title>{t("Choose Language")}</Title> | ||||
|           </Toolbar> | ||||
|         </AppBar> | ||||
|       } | ||||
|     > | ||||
|       <List | ||||
|         sx={{ | ||||
|           paddingBottom: "var(--safe-area-inset-bottom, 0)", | ||||
|         }} | ||||
|       > | ||||
|         <ListItemButton | ||||
|           onClick={() => { | ||||
|             props.onCodeChange(props.code ? undefined : matchedLangCode()); | ||||
|           }} | ||||
|         > | ||||
|           <ListItemText> | ||||
|             {t("lang.auto", { | ||||
|               detected: t(`lang.${matchedLangCode()}`) ?? matchedLangCode(), | ||||
|             })} | ||||
|           </ListItemText> | ||||
|           <ListItemSecondaryAction> | ||||
|             <Switch checked={typeof props.code === "undefined"} /> | ||||
|           </ListItemSecondaryAction> | ||||
|         </ListItemButton> | ||||
|         <List subheader={<ListSubheader>{t("Supported")}</ListSubheader>}> | ||||
|           <For each={SUPPORTED_LANGS}> | ||||
|             {(code) => ( | ||||
|               <ListItemButton | ||||
|                 disabled={typeof props.code === "undefined"} | ||||
|                 onClick={[props.onCodeChange, code]} | ||||
|               > | ||||
|                 <ListItemText>{t(`lang.${code}`)}</ListItemText> | ||||
|                 <ListItemSecondaryAction> | ||||
|                   <Radio | ||||
|                     checked={props.code === code || (props.code === undefined && matchedLangCode() == code)} | ||||
|                   /> | ||||
|                 </ListItemSecondaryAction> | ||||
|               </ListItemButton> | ||||
|             )} | ||||
|           </For> | ||||
|         </List> | ||||
| 
 | ||||
|         <List subheader={<ListSubheader>{t("Unsupported")}</ListSubheader>}> | ||||
|           <For each={unsupportedLangCodes()}> | ||||
|             {(code) => ( | ||||
|               <ListItem> | ||||
|                 <ListItemText>{iso639_1.getNativeName(code)}</ListItemText> | ||||
|               </ListItem> | ||||
|             )} | ||||
|           </For> | ||||
|         </List> | ||||
|       </List> | ||||
|     </Scaffold> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| export default ChooseLang; | ||||
							
								
								
									
										106
									
								
								src/settings/ChooseRegion.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										106
									
								
								src/settings/ChooseRegion.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -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<HTMLButtonElement, MouseEvent>; | ||||
| }; | ||||
| 
 | ||||
| const ChooseRegion: Component<ChooseRegionProps> = (props) => { | ||||
|   const [t] = createTranslator( | ||||
|     () => import("./i18n/lang-names.json"), | ||||
|     (code) => | ||||
|       import(`./i18n/${code}.json`) as Promise<{ | ||||
|         default: Record<string, string | undefined> & { | ||||
|           ["lang.auto"]: Template<{ detected: string }>; | ||||
|         }; | ||||
|       }>, | ||||
|   ); | ||||
| 
 | ||||
|   const unsupportedLangCodes = createMemo(() => { | ||||
|     return iso639_1.getAllCodes().filter((x) => !["zh", "en"].includes(x)); | ||||
|   }); | ||||
| 
 | ||||
|   const matchedRegionCode = createMemo(() => autoMatchRegion()); | ||||
| 
 | ||||
|   return ( | ||||
|     <Scaffold | ||||
|       topbar={ | ||||
|         <AppBar position="static"> | ||||
|           <Toolbar | ||||
|             variant="dense" | ||||
|             sx={{ paddingTop: "var(--safe-area-inset-top, 0px)" }} | ||||
|           > | ||||
|             <IconButton color="inherit" onClick={props.onClose} disableRipple> | ||||
|               <CloseIcon /> | ||||
|             </IconButton> | ||||
|             <Title>{t("Choose Language")}</Title> | ||||
|           </Toolbar> | ||||
|         </AppBar> | ||||
|       } | ||||
|     > | ||||
|       <List | ||||
|         sx={{ | ||||
|           paddingBottom: "var(--safe-area-inset-bottom, 0)", | ||||
|         }} | ||||
|       > | ||||
|         <ListItemButton | ||||
|           onClick={() => { | ||||
|             props.onCodeChange(props.code ? undefined : matchedRegionCode()); | ||||
|           }} | ||||
|         > | ||||
|           <ListItemText> | ||||
|             {t("region.auto", { | ||||
|               detected: t(`region.${matchedRegionCode()}`) ?? matchedRegionCode(), | ||||
|             })} | ||||
|           </ListItemText> | ||||
|           <ListItemSecondaryAction> | ||||
|             <Switch checked={typeof props.code === "undefined"} /> | ||||
|           </ListItemSecondaryAction> | ||||
|         </ListItemButton> | ||||
| 
 | ||||
|         <List subheader={<ListSubheader>{t("Supported")}</ListSubheader>}> | ||||
|           <For each={SUPPORTED_REGIONS}> | ||||
|             {(code) => ( | ||||
|               <ListItemButton | ||||
|                 disabled={typeof props.code === "undefined"} | ||||
|                 onClick={[props.onCodeChange, code]} | ||||
|               > | ||||
|                 <ListItemText>{t(`region.${code}`)}</ListItemText> | ||||
|                 <ListItemSecondaryAction> | ||||
|                   <Radio | ||||
|                     checked={props.code === code || (props.code === undefined && matchedRegionCode() == code)} | ||||
|                   /> | ||||
|                 </ListItemSecondaryAction> | ||||
|               </ListItemButton> | ||||
|             )} | ||||
|           </For> | ||||
|         </List> | ||||
|       </List> | ||||
|     </Scaffold> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| export default ChooseRegion; | ||||
|  | @ -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<string, string | undefined>; | ||||
| 
 | ||||
| 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 = () => { | |||
|             <IconButton color="inherit" onClick={[navigate, -1]} disableRipple> | ||||
|               <CloseIcon /> | ||||
|             </IconButton> | ||||
|             <Title>Settings</Title> | ||||
|             <Title>{t("Settings")}</Title> | ||||
|           </Toolbar> | ||||
|         </AppBar> | ||||
|       } | ||||
|  | @ -68,32 +99,35 @@ const Settings: ParentComponent = () => { | |||
|       <List class="setting-list" use:solid-styled> | ||||
|         <li> | ||||
|           <ul> | ||||
|             <ListSubheader>Accounts</ListSubheader> | ||||
|             <ListItem> | ||||
|               <ListItemText>All Notifications</ListItemText> | ||||
|             <ListSubheader>{t("Accounts")}</ListSubheader> | ||||
|             <ListItemButton disabled> | ||||
|               <ListItemText>{t("All Notifications")}</ListItemText> | ||||
|               <ListItemSecondaryAction> | ||||
|                 <Switch value={false} /> | ||||
|                 <Switch value={false} disabled /> | ||||
|               </ListItemSecondaryAction> | ||||
|             </ListItem> | ||||
|             </ListItemButton> | ||||
|             <Divider /> | ||||
|             <ListItem> | ||||
|               <ListItemText>Sign in...</ListItemText> | ||||
|             </ListItem> | ||||
|             <ListItemButton disabled> | ||||
|               <ListItemText>{t("Sign in...")}</ListItemText> | ||||
|             </ListItemButton> | ||||
|             <Divider /> | ||||
|           </ul> | ||||
|           <For each={profiles()}> | ||||
|             {({ account: acct, inf }) => ( | ||||
|               <ul data-site={acct.site} data-username={inf?.username}> | ||||
|                 <ListSubheader>{`@${inf?.username ?? "..."}@${new URL(acct.site).host}`}</ListSubheader> | ||||
|                 <ListItem> | ||||
|                   <ListItemText>Notifications</ListItemText> | ||||
|                 <ListItemButton disabled> | ||||
|                   <ListItemText>{t("Notifications")}</ListItemText> | ||||
|                   <ListItemSecondaryAction> | ||||
|                     <Switch value={false} /> | ||||
|                     <Switch value={false} disabled /> | ||||
|                   </ListItemSecondaryAction> | ||||
|                 </ListItem> | ||||
|                 </ListItemButton> | ||||
|                 <Divider /> | ||||
|                 <ListItemButton onClick={[doSignOut, acct]}> | ||||
|                   <ListItemText>Sign out</ListItemText> | ||||
|                   <ListItemIcon> | ||||
|                     <Logout /> | ||||
|                   </ListItemIcon> | ||||
|                   <ListItemText>{t("Sign out")}</ListItemText> | ||||
|                 </ListItemButton> | ||||
|                 <Divider /> | ||||
|               </ul> | ||||
|  | @ -101,12 +135,8 @@ const Settings: ParentComponent = () => { | |||
|           </For> | ||||
|         </li> | ||||
|         <li> | ||||
|           <ListSubheader>Reading</ListSubheader> | ||||
|           <ListItem> | ||||
|             <ListItemText secondary="Regular">Fonts</ListItemText> | ||||
|           </ListItem> | ||||
|           <Divider /> | ||||
|           <ListItem | ||||
|           <ListSubheader>{t("Reading")}</ListSubheader> | ||||
|           <ListItemButton | ||||
|             onClick={(e) => | ||||
|               $settings.setKey( | ||||
|                 "prefetchTootsDisabled", | ||||
|  | @ -114,34 +144,93 @@ const Settings: ParentComponent = () => { | |||
|               ) | ||||
|             } | ||||
|           > | ||||
|             <ListItemText secondary="Tutu will download toots before you scroll to the position."> | ||||
|               Prefetch Toots | ||||
|             <ListItemText secondary={t("Prefetch Toots.2nd")}> | ||||
|               {t("Prefetch Toots")} | ||||
|             </ListItemText> | ||||
|             <ListItemSecondaryAction> | ||||
|               <Switch checked={!settings$().prefetchTootsDisabled} /> | ||||
|             </ListItemSecondaryAction> | ||||
|           </ListItem> | ||||
|           </ListItemButton> | ||||
|           <Divider /> | ||||
|         </li> | ||||
|         <li> | ||||
|           <ListSubheader>This Application</ListSubheader> | ||||
|           <ListSubheader>{t("This Application")}</ListSubheader> | ||||
|           <ListItemButton onClick={[setLangPickerOpen, true]}> | ||||
|             <ListItemIcon> | ||||
|               <TranslateIcon /> | ||||
|             </ListItemIcon> | ||||
|             <ListItemText | ||||
|               secondary={ | ||||
|                 settings$().language === undefined | ||||
|                   ? t("lang.auto", { | ||||
|                       detected: | ||||
|                         t("lang." + autoMatchLangTag()) ?? autoMatchLangTag(), | ||||
|                     }) | ||||
|                   : t("lang." + settings$().language) | ||||
|               } | ||||
|             > | ||||
|               {t("Language")} | ||||
|             </ListItemText> | ||||
|           </ListItemButton> | ||||
|           <BottomSheet open={langPickerOpen()}> | ||||
|             <ChooseLang | ||||
|               code={settings$().language} | ||||
|               onCodeChange={(nval) => $settings.setKey("language", nval)} | ||||
|               onClose={[setLangPickerOpen, false]} | ||||
|             /> | ||||
|           </BottomSheet> | ||||
|           <Divider /> | ||||
|           <ListItemButton onClick={[setRegionPickerOpen, true]}> | ||||
|             <ListItemIcon> | ||||
|               <PublicIcon /> | ||||
|             </ListItemIcon> | ||||
|             <ListItemText | ||||
|               secondary={ | ||||
|                 settings$().region === undefined | ||||
|                   ? t("region.auto", { | ||||
|                       detected: | ||||
|                         t("region." + autoMatchRegion()) ?? autoMatchRegion(), | ||||
|                     }) | ||||
|                   : t("region." + settings$().region) | ||||
|               } | ||||
|             > | ||||
|               {t("Region")} | ||||
|             </ListItemText> | ||||
|           </ListItemButton> | ||||
|           <BottomSheet open={regionPickerOpen()}> | ||||
|             <ChooseRegion | ||||
|               code={settings$().region} | ||||
|               onCodeChange={(nval) => $settings.setKey("region", nval)} | ||||
|               onClose={[setRegionPickerOpen, false]} | ||||
|             /> | ||||
|           </BottomSheet> | ||||
|           <Divider /> | ||||
|           <ListItem> | ||||
|             <ListItemText secondary="Comformtable tooting experience."> | ||||
|               About Tutu | ||||
|             <ListItemText secondary={t("About Tutu.2nd")}> | ||||
|               {t("About Tutu")} | ||||
|             </ListItemText> | ||||
|           </ListItem> | ||||
|           <Divider /> | ||||
|           <ListItem> | ||||
|             <ListItemText | ||||
|               secondary={`Using v${import.meta.env.PACKAGE_VERSION} (built on ${intlFormat(import.meta.env.BUILT_AT)}, ${import.meta.env.MODE})`} | ||||
|               secondary={t("version", { | ||||
|                 packageVersion: import.meta.env.PACKAGE_VERSION, | ||||
|                 builtAt: format( | ||||
|                   import.meta.env.BUILT_AT, | ||||
|                   t("datefmt") || "yyyy/MM/dd", | ||||
|                   { locale: dateFnLocale() }, | ||||
|                 ), | ||||
|                 buildMode: import.meta.env.MODE, | ||||
|               })} | ||||
|             > | ||||
|               {needRefresh() | ||||
|                 ? "An update is ready, restart the Tutu to apply" | ||||
|                 : "No updates"} | ||||
|               {needRefresh() ? t("updates.ready") : t("updates.no")} | ||||
|             </ListItemText> | ||||
|             <Show when={needRefresh()}> | ||||
|               <ListItemSecondaryAction> | ||||
|                 <IconButton aria-label="Restart Now" onClick={() => window.location.reload()}> | ||||
|                 <IconButton | ||||
|                   aria-label="Restart Now" | ||||
|                   onClick={() => window.location.reload()} | ||||
|                 > | ||||
|                   <RefreshIcon /> | ||||
|                 </IconButton> | ||||
|               </ListItemSecondaryAction> | ||||
|  |  | |||
							
								
								
									
										30
									
								
								src/settings/i18n/en.json
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								src/settings/i18n/en.json
									
										
									
									
									
										Normal file
									
								
							|  | @ -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" | ||||
| } | ||||
							
								
								
									
										4
									
								
								src/settings/i18n/lang-names.json
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								src/settings/i18n/lang-names.json
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,4 @@ | |||
| { | ||||
|   "lang.zh-Hans": "中文(简体)", | ||||
|   "lang.en": "English" | ||||
| } | ||||
							
								
								
									
										30
									
								
								src/settings/i18n/zh-Hans.json
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								src/settings/i18n/zh-Hans.json
									
										
									
									
									
										Normal file
									
								
							|  | @ -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": "选择区域" | ||||
| } | ||||
|  | @ -3,6 +3,8 @@ import { persistentMap } from "@nanostores/persistent"; | |||
| type Settings = { | ||||
|   onGoingOAuth2Process?: string; | ||||
|   prefetchTootsDisabled?: boolean; | ||||
|   language?: string; | ||||
|   region?: string; | ||||
| }; | ||||
| 
 | ||||
| export const $settings = persistentMap<Settings>( | ||||
|  |  | |||
|  | @ -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 ( | ||||
|     <> | ||||
|     <ErrorBoundary fallback={(err, reset) => { | ||||
|       return <p>Oops: {String(err)}</p> | ||||
|     }}> | ||||
|       <PullDownToRefresh | ||||
|         linkedElement={scrollLinked()} | ||||
|         loading={snapshot.loading} | ||||
|  | @ -202,7 +205,7 @@ const TimelinePanel: Component<{ | |||
|           </Match> | ||||
|         </JsSwitch> | ||||
|       </div> | ||||
|     </> | ||||
|     </ErrorBoundary> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
|  |  | |||
|  | @ -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<T extends mastodon.v1.Status>( | |||
| 
 | ||||
| function TootAuthorGroup(props: { status: mastodon.v1.Status; now: Date }) { | ||||
|   const toot = () => props.status; | ||||
|   const dateFnLocale = useDateFnLocale() | ||||
| 
 | ||||
|   return ( | ||||
|     <div class={tootStyle.tootAuthorGrp}> | ||||
|  | @ -187,7 +189,7 @@ function TootAuthorGroup(props: { status: mastodon.v1.Status; now: Date }) { | |||
|           }} | ||||
|         /> | ||||
|         <time datetime={toot().createdAt}> | ||||
|           {formatRelative(toot().createdAt, props.now)} | ||||
|           {formatRelative(toot().createdAt, props.now, {locale: dateFnLocale()})} | ||||
|         </time> | ||||
|         <span> | ||||
|           @{toot().account.username}@{new URL(toot().account.url).hostname} | ||||
|  |  | |||
|  | @ -11,5 +11,6 @@ | |||
|     "types": ["vite/client", "vite-plugin-pwa/solid"], | ||||
|     "noEmit": true, | ||||
|     "isolatedModules": true, | ||||
|     "resolveJsonModule": true, | ||||
|   } | ||||
| } | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue