diff options
author | Kristóf Marussy <kristof@marussy.com> | 2022-03-30 21:47:45 +0200 |
---|---|---|
committer | Kristóf Marussy <kristof@marussy.com> | 2022-05-16 00:54:57 +0200 |
commit | 85d91c64b5b3ec31df8acecd68a1fa6a68d57ff9 (patch) | |
tree | 277ab45a66a1c74e2d0a885c8a354aea27128d12 /packages/renderer | |
parent | feat(main): Translation hot reloading during development (diff) | |
download | sophie-85d91c64b5b3ec31df8acecd68a1fa6a68d57ff9.tar.gz sophie-85d91c64b5b3ec31df8acecd68a1fa6a68d57ff9.tar.zst sophie-85d91c64b5b3ec31df8acecd68a1fa6a68d57ff9.zip |
feat(renderer): Renderer translations
Add react-i18n to make us able to use i18next translations in the
renderer process just like we do in the main process.
Translations are hot-reloaded automatically.
Signed-off-by: Kristóf Marussy <kristof@marussy.com>
Diffstat (limited to 'packages/renderer')
14 files changed, 303 insertions, 27 deletions
diff --git a/packages/renderer/package.json b/packages/renderer/package.json index 81e66d7..4f00a3f 100644 --- a/packages/renderer/package.json +++ b/packages/renderer/package.json | |||
@@ -14,6 +14,7 @@ | |||
14 | "@mui/icons-material": "^5.4.2", | 14 | "@mui/icons-material": "^5.4.2", |
15 | "@mui/material": "^5.4.3", | 15 | "@mui/material": "^5.4.3", |
16 | "@sophie/shared": "workspace:*", | 16 | "@sophie/shared": "workspace:*", |
17 | "i18next": "^21.6.14", | ||
17 | "lodash-es": "^4.17.21", | 18 | "lodash-es": "^4.17.21", |
18 | "loglevel": "^1.8.0", | 19 | "loglevel": "^1.8.0", |
19 | "loglevel-plugin-prefix": "^0.8.4", | 20 | "loglevel-plugin-prefix": "^0.8.4", |
@@ -21,7 +22,8 @@ | |||
21 | "mobx-react-lite": "^3.3.0", | 22 | "mobx-react-lite": "^3.3.0", |
22 | "mobx-state-tree": "^5.1.3", | 23 | "mobx-state-tree": "^5.1.3", |
23 | "react": "^17.0.2", | 24 | "react": "^17.0.2", |
24 | "react-dom": "^17.0.2" | 25 | "react-dom": "^17.0.2", |
26 | "react-i18next": "^11.16.2" | ||
25 | }, | 27 | }, |
26 | "devDependencies": { | 28 | "devDependencies": { |
27 | "@jest/globals": "^27.5.1", | 29 | "@jest/globals": "^27.5.1", |
diff --git a/packages/renderer/src/components/Loading.tsx b/packages/renderer/src/components/Loading.tsx new file mode 100644 index 0000000..019b5ed --- /dev/null +++ b/packages/renderer/src/components/Loading.tsx | |||
@@ -0,0 +1,39 @@ | |||
1 | /* | ||
2 | * Copyright (C) 2022 Kristóf Marussy <kristof@marussy.com> | ||
3 | * | ||
4 | * This file is part of Sophie. | ||
5 | * | ||
6 | * Sophie is free software: you can redistribute it and/or modify | ||
7 | * it under the terms of the GNU Affero General Public License as | ||
8 | * published by the Free Software Foundation, version 3. | ||
9 | * | ||
10 | * This program is distributed in the hope that it will be useful, | ||
11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
13 | * GNU Affero General Public License for more details. | ||
14 | * | ||
15 | * You should have received a copy of the GNU Affero General Public License | ||
16 | * along with this program. If not, see <https://www.gnu.org/licenses/>. | ||
17 | * | ||
18 | * SPDX-License-Identifier: AGPL-3.0-only | ||
19 | */ | ||
20 | |||
21 | import CircularProgress from '@mui/material/CircularProgress'; | ||
22 | import { styled } from '@mui/material/styles'; | ||
23 | import React from 'react'; | ||
24 | |||
25 | const LoadingRoot = styled('div')({ | ||
26 | width: '100vw', | ||
27 | height: '100vh', | ||
28 | display: 'flex', | ||
29 | alignItems: 'center', | ||
30 | justifyContent: 'center', | ||
31 | }); | ||
32 | |||
33 | export default function Loading() { | ||
34 | return ( | ||
35 | <LoadingRoot> | ||
36 | <CircularProgress /> | ||
37 | </LoadingRoot> | ||
38 | ); | ||
39 | } | ||
diff --git a/packages/renderer/src/components/NewWindowBanner.tsx b/packages/renderer/src/components/NewWindowBanner.tsx index a49b4b1..9aa6121 100644 --- a/packages/renderer/src/components/NewWindowBanner.tsx +++ b/packages/renderer/src/components/NewWindowBanner.tsx | |||
@@ -23,6 +23,7 @@ import IconOpenInNew from '@mui/icons-material/OpenInNew'; | |||
23 | import Button from '@mui/material/Button'; | 23 | import Button from '@mui/material/Button'; |
24 | import { observer } from 'mobx-react-lite'; | 24 | import { observer } from 'mobx-react-lite'; |
25 | import React from 'react'; | 25 | import React from 'react'; |
26 | import { Trans, useTranslation } from 'react-i18next'; | ||
26 | 27 | ||
27 | import type Service from '../stores/Service'; | 28 | import type Service from '../stores/Service'; |
28 | 29 | ||
@@ -33,6 +34,10 @@ function NewWindowBanner({ | |||
33 | }: { | 34 | }: { |
34 | service: Service | undefined; | 35 | service: Service | undefined; |
35 | }): JSX.Element | null { | 36 | }): JSX.Element | null { |
37 | const { t } = useTranslation(undefined, { | ||
38 | keyPrefix: 'banner.newWindow', | ||
39 | }); | ||
40 | |||
36 | if (service === undefined) { | 41 | if (service === undefined) { |
37 | // eslint-disable-next-line unicorn/no-null -- React requires `null` to skip rendering. | 42 | // eslint-disable-next-line unicorn/no-null -- React requires `null` to skip rendering. |
38 | return null; | 43 | return null; |
@@ -63,7 +68,7 @@ function NewWindowBanner({ | |||
63 | color="inherit" | 68 | color="inherit" |
64 | size="small" | 69 | size="small" |
65 | > | 70 | > |
66 | Follow link in this window | 71 | {t('followLink')} |
67 | </Button> | 72 | </Button> |
68 | <Button | 73 | <Button |
69 | onClick={() => service.openPopupInExternalBrowser(url)} | 74 | onClick={() => service.openPopupInExternalBrowser(url)} |
@@ -71,7 +76,7 @@ function NewWindowBanner({ | |||
71 | size="small" | 76 | size="small" |
72 | startIcon={<IconOpenInBrowser />} | 77 | startIcon={<IconOpenInBrowser />} |
73 | > | 78 | > |
74 | Open in browser | 79 | {t('openInExternalBrowser')} |
75 | </Button> | 80 | </Button> |
76 | {count > 1 && ( | 81 | {count > 1 && ( |
77 | <> | 82 | <> |
@@ -81,27 +86,29 @@ function NewWindowBanner({ | |||
81 | size="small" | 86 | size="small" |
82 | startIcon={<IconOpenInBrowser />} | 87 | startIcon={<IconOpenInBrowser />} |
83 | > | 88 | > |
84 | Open all | 89 | {t('openAllInExternalBrowser')} |
85 | </Button> | 90 | </Button> |
86 | <Button | 91 | <Button |
87 | onClick={() => service.dismissPopup(url)} | 92 | onClick={() => service.dismissPopup(url)} |
88 | color="inherit" | 93 | color="inherit" |
89 | size="small" | 94 | size="small" |
90 | > | 95 | > |
91 | Ignore | 96 | {t('dismiss')} |
92 | </Button> | 97 | </Button> |
93 | </> | 98 | </> |
94 | )} | 99 | )} |
95 | </> | 100 | </> |
96 | } | 101 | } |
97 | > | 102 | > |
98 | {name} wants to open <b>{url}</b>{' '} | ||
99 | {count === 1 ? ( | 103 | {count === 1 ? ( |
100 | <>in a new window</> | 104 | <Trans i18nKey="messageSingleLink" t={t}> |
105 | {{ name }} wants to open <strong>{{ url }}</strong> in a new window | ||
106 | </Trans> | ||
101 | ) : ( | 107 | ) : ( |
102 | <> | 108 | <Trans i18nKey="messageMultipleLinks" count={count - 1} t={t}> |
103 | and <b>{count}</b> other links in new windows | 109 | {{ name }} wants to open <strong>{{ url }}</strong> and{' '} |
104 | </> | 110 | <strong>{{ count: count - 1 }}</strong> other links in new windows |
111 | </Trans> | ||
105 | )} | 112 | )} |
106 | </NotificationBanner> | 113 | </NotificationBanner> |
107 | ); | 114 | ); |
diff --git a/packages/renderer/src/components/NotificationBanner.tsx b/packages/renderer/src/components/NotificationBanner.tsx index d591e14..36c192a 100644 --- a/packages/renderer/src/components/NotificationBanner.tsx +++ b/packages/renderer/src/components/NotificationBanner.tsx | |||
@@ -23,6 +23,7 @@ import Alert, { AlertColor } from '@mui/material/Alert'; | |||
23 | import Box from '@mui/material/Box'; | 23 | import Box from '@mui/material/Box'; |
24 | import { styled } from '@mui/material/styles'; | 24 | import { styled } from '@mui/material/styles'; |
25 | import React, { ReactNode } from 'react'; | 25 | import React, { ReactNode } from 'react'; |
26 | import { useTranslation } from 'react-i18next'; | ||
26 | 27 | ||
27 | const NotificationBannerRoot = styled(Alert)(({ theme }) => ({ | 28 | const NotificationBannerRoot = styled(Alert)(({ theme }) => ({ |
28 | paddingTop: 7, | 29 | paddingTop: 7, |
@@ -77,11 +78,14 @@ export default function NotificationBanner({ | |||
77 | buttons?: ReactNode; | 78 | buttons?: ReactNode; |
78 | children?: ReactNode; | 79 | children?: ReactNode; |
79 | }): JSX.Element { | 80 | }): JSX.Element { |
81 | const { t } = useTranslation(); | ||
82 | |||
80 | return ( | 83 | return ( |
81 | <NotificationBannerRoot | 84 | <NotificationBannerRoot |
82 | severity={severity ?? 'success'} | 85 | severity={severity ?? 'success'} |
83 | icon={icon ?? false} | 86 | icon={icon ?? false} |
84 | onClose={onClose} | 87 | onClose={onClose} |
88 | closeText={t<string>('banner.close')} | ||
85 | > | 89 | > |
86 | <NotificationBannerText>{children}</NotificationBannerText> | 90 | <NotificationBannerText>{children}</NotificationBannerText> |
87 | {buttons && ( | 91 | {buttons && ( |
diff --git a/packages/renderer/src/components/locationBar/ExtraButtons.tsx b/packages/renderer/src/components/locationBar/ExtraButtons.tsx index 4eaee29..1755495 100644 --- a/packages/renderer/src/components/locationBar/ExtraButtons.tsx +++ b/packages/renderer/src/components/locationBar/ExtraButtons.tsx | |||
@@ -23,6 +23,7 @@ import Box from '@mui/material/Box'; | |||
23 | import IconButton from '@mui/material/IconButton'; | 23 | import IconButton from '@mui/material/IconButton'; |
24 | import { observer } from 'mobx-react-lite'; | 24 | import { observer } from 'mobx-react-lite'; |
25 | import React from 'react'; | 25 | import React from 'react'; |
26 | import { useTranslation } from 'react-i18next'; | ||
26 | 27 | ||
27 | import type Service from '../../stores/Service'; | 28 | import type Service from '../../stores/Service'; |
28 | 29 | ||
@@ -31,6 +32,10 @@ function ExtraButtons({ | |||
31 | }: { | 32 | }: { |
32 | service: Service | undefined; | 33 | service: Service | undefined; |
33 | }): JSX.Element { | 34 | }): JSX.Element { |
35 | const { t } = useTranslation(undefined, { | ||
36 | keyPrefix: 'toolbar', | ||
37 | }); | ||
38 | |||
34 | return ( | 39 | return ( |
35 | <Box | 40 | <Box |
36 | sx={{ | 41 | sx={{ |
@@ -39,7 +44,7 @@ function ExtraButtons({ | |||
39 | }} | 44 | }} |
40 | > | 45 | > |
41 | <IconButton | 46 | <IconButton |
42 | aria-label="Open in browser" | 47 | aria-label={t('openInBrowser')} |
43 | disabled={service?.currentUrl === undefined} | 48 | disabled={service?.currentUrl === undefined} |
44 | onClick={() => service?.openCurrentURLInExternalBrowser()} | 49 | onClick={() => service?.openCurrentURLInExternalBrowser()} |
45 | > | 50 | > |
diff --git a/packages/renderer/src/components/locationBar/NavigationButtons.tsx b/packages/renderer/src/components/locationBar/NavigationButtons.tsx index 9995a21..219ed90 100644 --- a/packages/renderer/src/components/locationBar/NavigationButtons.tsx +++ b/packages/renderer/src/components/locationBar/NavigationButtons.tsx | |||
@@ -28,6 +28,7 @@ import Box from '@mui/material/Box'; | |||
28 | import IconButton from '@mui/material/IconButton'; | 28 | import IconButton from '@mui/material/IconButton'; |
29 | import { observer } from 'mobx-react-lite'; | 29 | import { observer } from 'mobx-react-lite'; |
30 | import React from 'react'; | 30 | import React from 'react'; |
31 | import { useTranslation } from 'react-i18next'; | ||
31 | 32 | ||
32 | import type Service from '../../stores/Service'; | 33 | import type Service from '../../stores/Service'; |
33 | 34 | ||
@@ -36,6 +37,9 @@ function NavigationButtons({ | |||
36 | }: { | 37 | }: { |
37 | service: Service | undefined; | 38 | service: Service | undefined; |
38 | }): JSX.Element { | 39 | }): JSX.Element { |
40 | const { t } = useTranslation(undefined, { | ||
41 | keyPrefix: 'toolbar', | ||
42 | }); | ||
39 | const { direction } = useTheme(); | 43 | const { direction } = useTheme(); |
40 | 44 | ||
41 | return ( | 45 | return ( |
@@ -46,26 +50,26 @@ function NavigationButtons({ | |||
46 | }} | 50 | }} |
47 | > | 51 | > |
48 | <IconButton | 52 | <IconButton |
49 | aria-label="Back" | 53 | aria-label={t('back')} |
50 | disabled={service === undefined || !service.canGoBack} | 54 | disabled={service === undefined || !service.canGoBack} |
51 | onClick={() => service?.goBack()} | 55 | onClick={() => service?.goBack()} |
52 | > | 56 | > |
53 | {direction === 'ltr' ? <IconArrowBack /> : <IconArrowForward />} | 57 | {direction === 'ltr' ? <IconArrowBack /> : <IconArrowForward />} |
54 | </IconButton> | 58 | </IconButton> |
55 | <IconButton | 59 | <IconButton |
56 | aria-label="Forward" | 60 | aria-label={t('forward')} |
57 | disabled={service === undefined || !service.canGoForward} | 61 | disabled={service === undefined || !service.canGoForward} |
58 | onClick={() => service?.goForward()} | 62 | onClick={() => service?.goForward()} |
59 | > | 63 | > |
60 | {direction === 'ltr' ? <IconArrowForward /> : <IconArrowBack />} | 64 | {direction === 'ltr' ? <IconArrowForward /> : <IconArrowBack />} |
61 | </IconButton> | 65 | </IconButton> |
62 | {service?.loading ?? false ? ( | 66 | {service?.loading ?? false ? ( |
63 | <IconButton aria-label="Stop" onClick={() => service?.stop()}> | 67 | <IconButton aria-label={t('stop')} onClick={() => service?.stop()}> |
64 | <IconStop /> | 68 | <IconStop /> |
65 | </IconButton> | 69 | </IconButton> |
66 | ) : ( | 70 | ) : ( |
67 | <IconButton | 71 | <IconButton |
68 | aria-label="Refresh" | 72 | aria-label={t('reload')} |
69 | disabled={service === undefined} | 73 | disabled={service === undefined} |
70 | onClick={(event) => service?.reload(event.shiftKey)} | 74 | onClick={(event) => service?.reload(event.shiftKey)} |
71 | > | 75 | > |
@@ -73,7 +77,7 @@ function NavigationButtons({ | |||
73 | </IconButton> | 77 | </IconButton> |
74 | )} | 78 | )} |
75 | <IconButton | 79 | <IconButton |
76 | aria-label="Home" | 80 | aria-label={t('home')} |
77 | disabled={service === undefined} | 81 | disabled={service === undefined} |
78 | onClick={() => service?.goHome()} | 82 | onClick={() => service?.goHome()} |
79 | > | 83 | > |
diff --git a/packages/renderer/src/components/locationBar/SecurityLabel.tsx b/packages/renderer/src/components/locationBar/SecurityLabel.tsx index 6e27e6b..d9dff86 100644 --- a/packages/renderer/src/components/locationBar/SecurityLabel.tsx +++ b/packages/renderer/src/components/locationBar/SecurityLabel.tsx | |||
@@ -24,6 +24,7 @@ import IconGlobe from '@mui/icons-material/Public'; | |||
24 | import IconWarning from '@mui/icons-material/Warning'; | 24 | import IconWarning from '@mui/icons-material/Warning'; |
25 | import { styled } from '@mui/material/styles'; | 25 | import { styled } from '@mui/material/styles'; |
26 | import React from 'react'; | 26 | import React from 'react'; |
27 | import { useTranslation } from 'react-i18next'; | ||
27 | 28 | ||
28 | import LocationInputAdornment from './LocationInputAdornment'; | 29 | import LocationInputAdornment from './LocationInputAdornment'; |
29 | import getAlertColor from './getAlertColor'; | 30 | import getAlertColor from './getAlertColor'; |
@@ -60,6 +61,10 @@ export default function SecurityLabel({ | |||
60 | changed: boolean; | 61 | changed: boolean; |
61 | position: 'start' | 'end'; | 62 | position: 'start' | 'end'; |
62 | }): JSX.Element { | 63 | }): JSX.Element { |
64 | const { t } = useTranslation(undefined, { | ||
65 | keyPrefix: 'securityLabel', | ||
66 | }); | ||
67 | |||
63 | const { type } = splitResult; | 68 | const { type } = splitResult; |
64 | if (changed || type === 'empty') { | 69 | if (changed || type === 'empty') { |
65 | return ( | 70 | return ( |
@@ -75,14 +80,14 @@ export default function SecurityLabel({ | |||
75 | <SecurityLabelRoot | 80 | <SecurityLabelRoot |
76 | alert={false} | 81 | alert={false} |
77 | position={position} | 82 | position={position} |
78 | aria-label="Secure connection" | 83 | aria-label={t('secureConnection')} |
79 | > | 84 | > |
80 | <IconHttps fontSize="small" /> | 85 | <IconHttps fontSize="small" /> |
81 | </SecurityLabelRoot> | 86 | </SecurityLabelRoot> |
82 | ) : ( | 87 | ) : ( |
83 | <SecurityLabelRoot alert position={position}> | 88 | <SecurityLabelRoot alert position={position}> |
84 | <IconHttp fontSize="small" /> | 89 | <IconHttp fontSize="small" /> |
85 | <SecurityLabelText>Not secure</SecurityLabelText> | 90 | <SecurityLabelText>{t('notSecureConnection')}</SecurityLabelText> |
86 | </SecurityLabelRoot> | 91 | </SecurityLabelRoot> |
87 | ); | 92 | ); |
88 | } | 93 | } |
@@ -90,7 +95,7 @@ export default function SecurityLabel({ | |||
90 | return ( | 95 | return ( |
91 | <SecurityLabelRoot alert position={position}> | 96 | <SecurityLabelRoot alert position={position}> |
92 | <IconWarning fontSize="small" /> | 97 | <IconWarning fontSize="small" /> |
93 | <SecurityLabelText>Unknown site</SecurityLabelText> | 98 | <SecurityLabelText>{t('unknownSite')}</SecurityLabelText> |
94 | </SecurityLabelRoot> | 99 | </SecurityLabelRoot> |
95 | ); | 100 | ); |
96 | default: | 101 | default: |
diff --git a/packages/renderer/src/components/sidebar/ServiceIcon.tsx b/packages/renderer/src/components/sidebar/ServiceIcon.tsx index b8f9b96..1017be9 100644 --- a/packages/renderer/src/components/sidebar/ServiceIcon.tsx +++ b/packages/renderer/src/components/sidebar/ServiceIcon.tsx | |||
@@ -129,7 +129,9 @@ function ServiceIcon({ service }: { service: Service }): JSX.Element { | |||
129 | }} | 129 | }} |
130 | > | 130 | > |
131 | <ServiceIconRoot hasError={hasError}> | 131 | <ServiceIconRoot hasError={hasError}> |
132 | <ServiceIconText>{name.length > 0 ? name[0] : '?'}</ServiceIconText> | 132 | <ServiceIconText aria-hidden="true"> |
133 | {name.length > 0 ? name[0] : '?'} | ||
134 | </ServiceIconText> | ||
133 | </ServiceIconRoot> | 135 | </ServiceIconRoot> |
134 | </ServiceIconBadge> | 136 | </ServiceIconBadge> |
135 | </ServiceIconErrorBadge> | 137 | </ServiceIconErrorBadge> |
diff --git a/packages/renderer/src/components/sidebar/ServiceSwitcher.tsx b/packages/renderer/src/components/sidebar/ServiceSwitcher.tsx index 404149b..010c716 100644 --- a/packages/renderer/src/components/sidebar/ServiceSwitcher.tsx +++ b/packages/renderer/src/components/sidebar/ServiceSwitcher.tsx | |||
@@ -21,9 +21,12 @@ | |||
21 | import Tab from '@mui/material/Tab'; | 21 | import Tab from '@mui/material/Tab'; |
22 | import Tabs from '@mui/material/Tabs'; | 22 | import Tabs from '@mui/material/Tabs'; |
23 | import { alpha, styled } from '@mui/material/styles'; | 23 | import { alpha, styled } from '@mui/material/styles'; |
24 | import type { TFunction } from 'i18next'; | ||
24 | import { observer } from 'mobx-react-lite'; | 25 | import { observer } from 'mobx-react-lite'; |
25 | import React from 'react'; | 26 | import React from 'react'; |
27 | import { useTranslation } from 'react-i18next'; | ||
26 | 28 | ||
29 | import type Service from '../../stores/Service'; | ||
27 | import { useStore } from '../StoreProvider'; | 30 | import { useStore } from '../StoreProvider'; |
28 | 31 | ||
29 | import ServiceIcon from './ServiceIcon'; | 32 | import ServiceIcon from './ServiceIcon'; |
@@ -63,7 +66,36 @@ const ServiceSwitcherTab = styled(Tab, { | |||
63 | }, | 66 | }, |
64 | })); | 67 | })); |
65 | 68 | ||
69 | function getServiceTitle(service: Service, t: TFunction) { | ||
70 | const { | ||
71 | settings: { name }, | ||
72 | directMessageCount, | ||
73 | indirectMessageCount, | ||
74 | } = service; | ||
75 | let messagesText: string | undefined; | ||
76 | if (indirectMessageCount > 0) { | ||
77 | messagesText = | ||
78 | directMessageCount > 0 | ||
79 | ? t('service.title.directAndIndirectMessageCount', { | ||
80 | directMessageCount, | ||
81 | indirectMessageCount, | ||
82 | }) | ||
83 | : t('service.title.indirectMessageCount', { | ||
84 | count: indirectMessageCount, | ||
85 | }); | ||
86 | } else if (directMessageCount > 0) { | ||
87 | messagesText = t('service.title.directMessageCount', { | ||
88 | count: directMessageCount, | ||
89 | }); | ||
90 | } | ||
91 | if (messagesText === undefined) { | ||
92 | return t('service.title.nameWithNoMessages', { name }); | ||
93 | } | ||
94 | return t('service.title.nameWithMessages', { name, messages: messagesText }); | ||
95 | } | ||
96 | |||
66 | function ServiceSwitcher(): JSX.Element { | 97 | function ServiceSwitcher(): JSX.Element { |
98 | const { t } = useTranslation(); | ||
67 | const { settings, services } = useStore(); | 99 | const { settings, services } = useStore(); |
68 | const { selectedService } = settings; | 100 | const { selectedService } = settings; |
69 | 101 | ||
@@ -81,7 +113,7 @@ function ServiceSwitcher(): JSX.Element { | |||
81 | key={service.id} | 113 | key={service.id} |
82 | value={service.id} | 114 | value={service.id} |
83 | icon={<ServiceIcon service={service} />} | 115 | icon={<ServiceIcon service={service} />} |
84 | aria-label={service.settings.name} | 116 | aria-label={getServiceTitle(service, t)} |
85 | /> | 117 | /> |
86 | ))} | 118 | ))} |
87 | </ServiceSwitcherRoot> | 119 | </ServiceSwitcherRoot> |
diff --git a/packages/renderer/src/components/sidebar/ToggleDarkModeButton.tsx b/packages/renderer/src/components/sidebar/ToggleDarkModeButton.tsx index bacbf07..51c3b18 100644 --- a/packages/renderer/src/components/sidebar/ToggleDarkModeButton.tsx +++ b/packages/renderer/src/components/sidebar/ToggleDarkModeButton.tsx | |||
@@ -23,16 +23,18 @@ import LightModeIcon from '@mui/icons-material/LightMode'; | |||
23 | import IconButton from '@mui/material/IconButton'; | 23 | import IconButton from '@mui/material/IconButton'; |
24 | import { observer } from 'mobx-react-lite'; | 24 | import { observer } from 'mobx-react-lite'; |
25 | import React from 'react'; | 25 | import React from 'react'; |
26 | import { useTranslation } from 'react-i18next'; | ||
26 | 27 | ||
27 | import { useStore } from '../StoreProvider'; | 28 | import { useStore } from '../StoreProvider'; |
28 | 29 | ||
29 | export default observer(() => { | 30 | export default observer(() => { |
31 | const { t } = useTranslation(); | ||
30 | const { shared } = useStore(); | 32 | const { shared } = useStore(); |
31 | const { shouldUseDarkColors } = shared; | 33 | const { shouldUseDarkColors } = shared; |
32 | 34 | ||
33 | return ( | 35 | return ( |
34 | <IconButton | 36 | <IconButton |
35 | aria-label="Toggle dark mode" | 37 | aria-label={t('toolbar.toggleDarkMode')} |
36 | onClick={() => shared.toggleDarkMode()} | 38 | onClick={() => shared.toggleDarkMode()} |
37 | > | 39 | > |
38 | {shouldUseDarkColors ? <LightModeIcon /> : <DarkModeIcon />} | 40 | {shouldUseDarkColors ? <LightModeIcon /> : <DarkModeIcon />} |
diff --git a/packages/renderer/src/components/sidebar/ToggleLocationBarButton.tsx b/packages/renderer/src/components/sidebar/ToggleLocationBarButton.tsx index 57b17e9..325160e 100644 --- a/packages/renderer/src/components/sidebar/ToggleLocationBarButton.tsx +++ b/packages/renderer/src/components/sidebar/ToggleLocationBarButton.tsx | |||
@@ -25,6 +25,7 @@ import CircularProgress from '@mui/material/CircularProgress'; | |||
25 | import IconButton from '@mui/material/IconButton'; | 25 | import IconButton from '@mui/material/IconButton'; |
26 | import { observer } from 'mobx-react-lite'; | 26 | import { observer } from 'mobx-react-lite'; |
27 | import React from 'react'; | 27 | import React from 'react'; |
28 | import { useTranslation } from 'react-i18next'; | ||
28 | 29 | ||
29 | import { useStore } from '../StoreProvider'; | 30 | import { useStore } from '../StoreProvider'; |
30 | import { LOCATION_BAR_ID } from '../locationBar/LocationBar'; | 31 | import { LOCATION_BAR_ID } from '../locationBar/LocationBar'; |
@@ -45,6 +46,7 @@ function ToggleLocationBarIcon({ | |||
45 | } | 46 | } |
46 | 47 | ||
47 | function ToggleLocationBarButton(): JSX.Element { | 48 | function ToggleLocationBarButton(): JSX.Element { |
49 | const { t } = useTranslation(); | ||
48 | const { settings } = useStore(); | 50 | const { settings } = useStore(); |
49 | const { selectedService, showLocationBar } = settings; | 51 | const { selectedService, showLocationBar } = settings; |
50 | 52 | ||
@@ -52,7 +54,7 @@ function ToggleLocationBarButton(): JSX.Element { | |||
52 | <IconButton | 54 | <IconButton |
53 | aria-pressed={showLocationBar} | 55 | aria-pressed={showLocationBar} |
54 | aria-controls={LOCATION_BAR_ID} | 56 | aria-controls={LOCATION_BAR_ID} |
55 | aria-label="Show location bar" | 57 | aria-label={t('toolbar.toggleLocationBar')} |
56 | onClick={() => settings.toggleLocationBar()} | 58 | onClick={() => settings.toggleLocationBar()} |
57 | > | 59 | > |
58 | <ToggleLocationBarIcon | 60 | <ToggleLocationBarIcon |
diff --git a/packages/renderer/src/i18n/RendererIpcI18nBackend.ts b/packages/renderer/src/i18n/RendererIpcI18nBackend.ts new file mode 100644 index 0000000..13e03b5 --- /dev/null +++ b/packages/renderer/src/i18n/RendererIpcI18nBackend.ts | |||
@@ -0,0 +1,75 @@ | |||
1 | /* | ||
2 | * Copyright (C) 2022 Kristóf Marussy <kristof@marussy.com> | ||
3 | * | ||
4 | * This file is part of Sophie. | ||
5 | * | ||
6 | * Sophie is free software: you can redistribute it and/or modify | ||
7 | * it under the terms of the GNU Affero General Public License as | ||
8 | * published by the Free Software Foundation, version 3. | ||
9 | * | ||
10 | * This program is distributed in the hope that it will be useful, | ||
11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
13 | * GNU Affero General Public License for more details. | ||
14 | * | ||
15 | * You should have received a copy of the GNU Affero General Public License | ||
16 | * along with this program. If not, see <https://www.gnu.org/licenses/>. | ||
17 | * | ||
18 | * SPDX-License-Identifier: AGPL-3.0-only | ||
19 | */ | ||
20 | |||
21 | import { SophieRenderer } from '@sophie/shared'; | ||
22 | import type { BackendModule, ReadCallback } from 'i18next'; | ||
23 | |||
24 | export default class RendererIpcI18nBackend implements BackendModule<unknown> { | ||
25 | type = 'backend' as const; | ||
26 | |||
27 | constructor( | ||
28 | private readonly ipc: SophieRenderer, | ||
29 | private readonly devMode = false, | ||
30 | ) {} | ||
31 | |||
32 | // eslint-disable-next-line class-methods-use-this -- Method required by interface. | ||
33 | init() {} | ||
34 | |||
35 | read(language: string, namespace: string, callback: ReadCallback): void { | ||
36 | const readAsync = async () => { | ||
37 | const translations = await this.ipc.getTranslation({ | ||
38 | language, | ||
39 | namespace, | ||
40 | }); | ||
41 | // eslint-disable-next-line unicorn/no-null -- `i18next` API requires `null`. | ||
42 | setTimeout(() => callback(null, translations), 0); | ||
43 | }; | ||
44 | |||
45 | readAsync().catch((error) => { | ||
46 | const callbackError = | ||
47 | error instanceof Error | ||
48 | ? error | ||
49 | : new Error(`Unknown error: ${JSON.stringify(error)}`); | ||
50 | /* | ||
51 | eslint-disable-next-line promise/no-callback-in-promise, unicorn/no-null -- | ||
52 | Converting from promise based API to a callback. `i18next` API requires `null`. | ||
53 | */ | ||
54 | setTimeout(() => callback(callbackError, null), 0); | ||
55 | }); | ||
56 | } | ||
57 | |||
58 | create( | ||
59 | languages: string[], | ||
60 | namespace: string, | ||
61 | key: string, | ||
62 | fallbackValue: string, | ||
63 | ): void { | ||
64 | if (!this.devMode) { | ||
65 | throw new Error('Refusing to add missing translation in production mode'); | ||
66 | } | ||
67 | this.ipc.dispatchAction({ | ||
68 | action: 'add-missing-translation', | ||
69 | languages, | ||
70 | namespace, | ||
71 | key, | ||
72 | value: fallbackValue, | ||
73 | }); | ||
74 | } | ||
75 | } | ||
diff --git a/packages/renderer/src/i18n/loadRendererLoalization.ts b/packages/renderer/src/i18n/loadRendererLoalization.ts new file mode 100644 index 0000000..19d1e2d --- /dev/null +++ b/packages/renderer/src/i18n/loadRendererLoalization.ts | |||
@@ -0,0 +1,87 @@ | |||
1 | /* | ||
2 | * Copyright (C) 2021-2022 Kristóf Marussy <kristof@marussy.com> | ||
3 | * | ||
4 | * This file is part of Sophie. | ||
5 | * | ||
6 | * Sophie is free software: you can redistribute it and/or modify | ||
7 | * it under the terms of the GNU Affero General Public License as | ||
8 | * published by the Free Software Foundation, version 3. | ||
9 | * | ||
10 | * This program is distributed in the hope that it will be useful, | ||
11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
13 | * GNU Affero General Public License for more details. | ||
14 | * | ||
15 | * You should have received a copy of the GNU Affero General Public License | ||
16 | * along with this program. If not, see <https://www.gnu.org/licenses/>. | ||
17 | * | ||
18 | * SPDX-License-Identifier: AGPL-3.0-only | ||
19 | */ | ||
20 | |||
21 | import { fallbackLng, SophieRenderer } from '@sophie/shared'; | ||
22 | import i18next from 'i18next'; | ||
23 | import { autorun } from 'mobx'; | ||
24 | import { addDisposer } from 'mobx-state-tree'; | ||
25 | import { initReactI18next } from 'react-i18next'; | ||
26 | |||
27 | import RendererStore from '../stores/RendererStore'; | ||
28 | import { getLogger } from '../utils/log'; | ||
29 | |||
30 | import RendererIpcI18nBackend from './RendererIpcI18nBackend'; | ||
31 | |||
32 | const log = getLogger('loadRendererLocalization'); | ||
33 | |||
34 | export default function loadRendererLocalization( | ||
35 | store: RendererStore, | ||
36 | ipc: SophieRenderer, | ||
37 | devMode: boolean, | ||
38 | ): void { | ||
39 | const loadAsync = async () => { | ||
40 | const i18n = i18next | ||
41 | .createInstance({ | ||
42 | lng: store.shared.language, | ||
43 | fallbackLng, | ||
44 | interpolation: { | ||
45 | escapeValue: false, // Not needed for react | ||
46 | }, | ||
47 | debug: devMode, | ||
48 | saveMissing: devMode, | ||
49 | }) | ||
50 | .use(new RendererIpcI18nBackend(ipc, devMode)) | ||
51 | .use(initReactI18next); | ||
52 | |||
53 | if (devMode) { | ||
54 | const reloadTranslationsAsync = async () => { | ||
55 | await i18n.reloadResources(); | ||
56 | if (i18n.isInitialized) { | ||
57 | // Spuriously change language to re-trigger `useTranslation` hooks. | ||
58 | await i18n.changeLanguage(store.shared.language); | ||
59 | } | ||
60 | log.info('Reloaded translations'); | ||
61 | }; | ||
62 | |||
63 | ipc.onReloadTranslations(() => { | ||
64 | reloadTranslationsAsync().catch((error) => { | ||
65 | log.error('Failed to reload translations', error); | ||
66 | }); | ||
67 | }); | ||
68 | } | ||
69 | |||
70 | await i18n.init(); | ||
71 | const disposeChangeLanguage = autorun(() => { | ||
72 | const { | ||
73 | shared: { language }, | ||
74 | } = store; | ||
75 | if (i18n.language !== language) { | ||
76 | i18n.changeLanguage(language).catch((error) => { | ||
77 | log.error('Failed to change language', error); | ||
78 | }); | ||
79 | } | ||
80 | }); | ||
81 | addDisposer(store, disposeChangeLanguage); | ||
82 | }; | ||
83 | |||
84 | loadAsync().catch((error) => { | ||
85 | log.error('Failed to connect to load localization', error); | ||
86 | }); | ||
87 | } | ||
diff --git a/packages/renderer/src/index.tsx b/packages/renderer/src/index.tsx index 54e157c..0022ec8 100644 --- a/packages/renderer/src/index.tsx +++ b/packages/renderer/src/index.tsx | |||
@@ -24,13 +24,16 @@ import '@fontsource/roboto/500.css'; | |||
24 | import '@fontsource/roboto/700.css'; | 24 | import '@fontsource/roboto/700.css'; |
25 | import CssBaseline from '@mui/material/CssBaseline'; | 25 | import CssBaseline from '@mui/material/CssBaseline'; |
26 | import { autorun } from 'mobx'; | 26 | import { autorun } from 'mobx'; |
27 | import React from 'react'; | 27 | import { addDisposer } from 'mobx-state-tree'; |
28 | import React, { Suspense } from 'react'; | ||
28 | import { render } from 'react-dom'; | 29 | import { render } from 'react-dom'; |
29 | 30 | ||
30 | import App from './components/App'; | 31 | import App from './components/App'; |
32 | import Loading from './components/Loading'; | ||
31 | import StoreProvider from './components/StoreProvider'; | 33 | import StoreProvider from './components/StoreProvider'; |
32 | import ThemeProvider from './components/ThemeProvider'; | 34 | import ThemeProvider from './components/ThemeProvider'; |
33 | import { exposeToReduxDevtools, hotReload } from './devTools'; | 35 | import { exposeToReduxDevtools, hotReload } from './devTools'; |
36 | import loadRendererLocalization from './i18n/loadRendererLoalization'; | ||
34 | import { createAndConnectRendererStore } from './stores/RendererStore'; | 37 | import { createAndConnectRendererStore } from './stores/RendererStore'; |
35 | import { getLogger } from './utils/log'; | 38 | import { getLogger } from './utils/log'; |
36 | 39 | ||
@@ -42,7 +45,9 @@ if (isDevelopment) { | |||
42 | hotReload(); | 45 | hotReload(); |
43 | } | 46 | } |
44 | 47 | ||
45 | const store = createAndConnectRendererStore(window.sophieRenderer); | 48 | const { sophieRenderer: ipc } = window; |
49 | |||
50 | const store = createAndConnectRendererStore(ipc); | ||
46 | 51 | ||
47 | if (isDevelopment) { | 52 | if (isDevelopment) { |
48 | exposeToReduxDevtools(store).catch((error) => { | 53 | exposeToReduxDevtools(store).catch((error) => { |
@@ -50,13 +55,16 @@ if (isDevelopment) { | |||
50 | }); | 55 | }); |
51 | } | 56 | } |
52 | 57 | ||
53 | autorun(() => { | 58 | loadRendererLocalization(store, ipc, isDevelopment); |
59 | |||
60 | const disposeSetTitle = autorun(() => { | ||
54 | const titlePrefix = isDevelopment ? '[dev] ' : ''; | 61 | const titlePrefix = isDevelopment ? '[dev] ' : ''; |
55 | const serviceTitle = store.settings.selectedService?.title; | 62 | const serviceTitle = store.settings.selectedService?.title; |
56 | const formattedServiceTitle = | 63 | const formattedServiceTitle = |
57 | serviceTitle === undefined ? '' : `${serviceTitle} - `; | 64 | serviceTitle === undefined ? '' : `${serviceTitle} - `; |
58 | document.title = `${titlePrefix}${formattedServiceTitle}Sophie`; | 65 | document.title = `${titlePrefix}${formattedServiceTitle}Sophie`; |
59 | }); | 66 | }); |
67 | addDisposer(store, disposeSetTitle); | ||
60 | 68 | ||
61 | function Root(): JSX.Element { | 69 | function Root(): JSX.Element { |
62 | return ( | 70 | return ( |
@@ -64,7 +72,9 @@ function Root(): JSX.Element { | |||
64 | <StoreProvider store={store}> | 72 | <StoreProvider store={store}> |
65 | <ThemeProvider> | 73 | <ThemeProvider> |
66 | <CssBaseline enableColorScheme /> | 74 | <CssBaseline enableColorScheme /> |
67 | <App /> | 75 | <Suspense fallback={<Loading />}> |
76 | <App /> | ||
77 | </Suspense> | ||
68 | </ThemeProvider> | 78 | </ThemeProvider> |
69 | </StoreProvider> | 79 | </StoreProvider> |
70 | </React.StrictMode> | 80 | </React.StrictMode> |